From 235dd65487061289741718fc8bb27232edc927ec Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Sun, 4 Oct 2026 10:41:17 -0400 Subject: [PATCH] feat(ios): align chat library memory and tasks with web --- AGENTS.md | 3 + ios/Sources/Chat/ChatFeatures.swift | 305 ++++++++++++++---- ios/Sources/Chat/ChatModels.swift | 22 +- ios/Sources/Chat/ChatStore.swift | 161 +++++++-- ios/Sources/Chat/ChatViews.swift | 138 ++++++-- .../Conversation/ConversationView.swift | 11 +- ios/Sources/LiveKit/VoiceSession.swift | 3 + ios/Tests/ChatFeatureParityTests.swift | 156 +++++++++ ios/Tests/ChatParityTests.swift | 207 ++++++++++++ ios/UITests/ChatUITests.swift | 55 +++- ios/Voice.xcodeproj/project.pbxproj | 8 + ios/docs/PROJECT_STATUS.md | 16 +- 12 files changed, 957 insertions(+), 128 deletions(-) create mode 100644 ios/Tests/ChatFeatureParityTests.swift create mode 100644 ios/Tests/ChatParityTests.swift diff --git a/AGENTS.md b/AGENTS.md index a385d10..ea77403 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,9 @@ ## Git and deployment +- The Gitea Actions runner runs in a Docker container on Unraid. SSH to `root@192.168.86.2` to inspect the runner, host Docker images/containers, and deployment health; use `tea` to track workflow runs. +- The runner uses the host Docker socket: checkout paths inside the runner are not necessarily valid host bind sources. Stream tracked source (`git archive HEAD`) into qualification containers rather than binding runner `$PWD`; use host-network probes for host loopback services. Never print runtime secrets or full container environment inspections. + - `master` of this repository is the single integration branch for API/web/iOS. The old `hope-voice-ios` repository is a read-only archive after migration. - Preserve original iOS ancestry; do not squash away the imported history. See the migration record for original tips and lookup commands. - Use conventional commits and explicitly stage intended files; leave unrelated local design artifacts untouched. diff --git a/ios/Sources/Chat/ChatFeatures.swift b/ios/Sources/Chat/ChatFeatures.swift index 04e7a75..2a843be 100644 --- a/ios/Sources/Chat/ChatFeatures.swift +++ b/ios/Sources/Chat/ChatFeatures.swift @@ -30,24 +30,94 @@ final class ChatFeatureStore: ObservableObject { private let api: ChatTransport private var epoch = 0 private var closed = false + private var capabilityGeneration = 0 + private var loadGeneration = 0 init(api: ChatTransport) { self.api = api } private func call(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T { + guard !closed else { throw CancellationError() } let current = epoch let data = try await api.request(path, method: method, body: body, query: query) guard !closed, current == epoch else { throw CancellationError() } try Task.checkCancellation() return try ChatCoding.decoder.decode(T.self, from: data) } - func load() async { + @discardableResult + func refreshCapabilities() async -> Bool { + guard !closed else { return false } + capabilityGeneration += 1 + let generation = capabilityGeneration + enabled = [] // Never retain image qualification while a refresh is pending or failed. do { let caps: FeatureCapabilities = try await call("api/capabilities") + guard generation == capabilityGeneration else { return false } enabled = Set(caps.features.filter { $0.value.enabled }.map(\.key)) - if enabled.contains("attachments") { struct Page: Decodable { let attachments: [ChatAttachment] }; let page: Page = try await call("api/attachments"); attachments = page.attachments } - if enabled.contains("artifacts") { struct Page: Decodable { let artifacts: [ChatArtifact] }; let page: Page = try await call("api/artifacts"); artifacts = page.artifacts } - if enabled.contains("scheduled_tasks") { struct Page: Decodable { let tasks: [ScheduledChatTask] }; let page: Page = try await call("api/tasks"); tasks = page.tasks } - if enabled.contains("memory_controls") { struct Page: Decodable { let sections: [ChatMemory] }; let page: Page = try await call("api/memory"); memory = page.sections } + return true + } catch { + guard generation == capabilityGeneration else { return false } + report(error); return false + } + } + func load() async { + loadGeneration += 1 + let generation = loadGeneration + guard await refreshCapabilities() else { return } + let capabilities = capabilityGeneration + do { + if enabled.contains("attachments") { + struct Page: Decodable { let attachments: [ChatAttachment] } + let page: Page = try await call("api/attachments") + guard generation == loadGeneration, capabilities == capabilityGeneration else { return } + attachments = page.attachments + } else { attachments = [] } + if enabled.contains("artifacts") { + struct Page: Decodable { let artifacts: [ChatArtifact] } + let page: Page = try await call("api/artifacts") + guard generation == loadGeneration, capabilities == capabilityGeneration else { return } + artifacts = page.artifacts + } else { artifacts = [] } + if enabled.contains("scheduled_tasks") { + struct Page: Decodable { let tasks: [ScheduledChatTask] } + let page: Page = try await call("api/tasks") + guard generation == loadGeneration, capabilities == capabilityGeneration else { return } + tasks = page.tasks + } else { tasks = [] } + if enabled.contains("memory_controls") { + struct Page: Decodable { let sections: [ChatMemory] } + let page: Page = try await call("api/memory") + guard generation == loadGeneration, capabilities == capabilityGeneration else { return } + memory = page.sections + } else { memory = [] } error = nil - } catch { report(error) } + } catch { if generation == loadGeneration { report(error) } } + } + static func blockedReason(_ item: ChatAttachment, imageInput: Bool) -> String? { + if ["failed", "encrypted", "unavailable"].contains(item.extractionStatus) { + return "This file cannot be used: \(item.extractionStatus). Preview it or upload a readable copy." + } + guard ["ready", "truncated", "image"].contains(item.extractionStatus) else { + return "This file is not ready to use. Refresh and try again." + } + if item.mime.hasPrefix("image/") && !imageInput { return "Image input is not available for the current model. Choose a text file or PDF." } + return nil + } + func validatedAttachment(_ item: ChatAttachment) async -> ChatAttachment? { + guard await refreshCapabilities(), enabled.contains("attachments") else { + if !closed { error = "Private files are unavailable. Refresh and try again." }; return nil + } + let generation = capabilityGeneration + do { + struct Envelope: Decodable { let attachment: ChatAttachment } + let result: Envelope = try await call("api/attachments/\(item.id)") + guard generation == capabilityGeneration else { return nil } + if let reason = Self.blockedReason(result.attachment, imageInput: enabled.contains("image_input")) { error = reason; return nil } + if let i = attachments.firstIndex(where: { $0.id == item.id }) { attachments[i] = result.attachment } + error = nil; return result.attachment + } catch { + guard !closed, generation == capabilityGeneration else { return nil } + if case ChatFailure.status(404) = error { self.error = "This file is no longer available. Refresh your files." } + else { report(error) } + return nil + } } private func report(_ value: Error) { guard !(value is CancellationError), !closed else { return } @@ -55,7 +125,9 @@ final class ChatFeatureStore: ObservableObject { error = value.localizedDescription } func upload(_ data: Data, filename: String, mime: String) async -> ChatAttachment? { - guard !busy, enabled.contains("attachments") else { return nil } + guard !busy, !closed else { return nil } + guard await refreshCapabilities(), enabled.contains("attachments"), !busy else { return nil } + if mime.hasPrefix("image/"), !enabled.contains("image_input") { error = "Image input is unavailable for the current model. Choose a text file or PDF."; return nil } guard data.count <= 8 * 1024 * 1024 else { error = "Choose a file smaller than 8 MiB."; return nil } busy = true; defer { busy = false } do { @@ -68,6 +140,7 @@ final class ChatFeatureStore: ObservableObject { } catch { report(error); return nil } } func preview(_ attachment: ChatAttachment) async { + guard !closed else { return } previewAttachment = attachment previewLoading = true previewError = nil @@ -121,10 +194,13 @@ final class ChatFeatureStore: ObservableObject { do { struct Result: Decodable { let artifact: ChatArtifact }; let result: Result = try await call("api/artifacts/\(item.id)", query: [URLQueryItem(name: "revision", value: String(revision))]); return result.artifact } catch { report(error); return nil } } func deleteArtifact(_ item: ChatArtifact) async { await remove("api/artifacts/\(item.id)"); if error == nil { artifacts.removeAll { $0.id == item.id } } } - func saveTask(_ item: ScheduledChatTask?, name: String, schedule: String, prompt: String) async { + func saveTask(_ item: ScheduledChatTask?, name: String, schedule: String, prompt: String, repeatCount: Int? = nil, paused: Bool = false) async { do { struct Result: Decodable { let task: ScheduledChatTask } - let result: Result = try await call(item.map { "api/tasks/\($0.id)" } ?? "api/tasks", method: item == nil ? "POST" : "PATCH", body: ["name": name, "schedule": schedule, "prompt": prompt]) + var body: [String: Any] = ["name": name, "schedule": schedule, "prompt": prompt] + if let repeatCount { guard repeatCount > 0 else { error = "Repeat count must be a positive whole number."; return }; body["repeat"] = repeatCount } + if item == nil { body["paused"] = paused } + let result: Result = try await call(item.map { "api/tasks/\($0.id)" } ?? "api/tasks", method: item == nil ? "POST" : "PATCH", body: body) if let i = tasks.firstIndex(where: { $0.id == result.task.id }) { tasks[i] = result.task } else { tasks.insert(result.task, at: 0) }; error = nil } catch { report(error) } } @@ -132,15 +208,37 @@ final class ChatFeatureStore: ObservableObject { do { struct Result: Decodable { let task: ScheduledChatTask }; let result: Result = try await call("api/tasks/\(item.id)/\(action)", method: "POST"); if let i = tasks.firstIndex(where: { $0.id == item.id }) { tasks[i] = result.task }; error = nil } catch { report(error) } } func deleteTask(_ item: ScheduledChatTask) async { await remove("api/tasks/\(item.id)"); if error == nil { tasks.removeAll { $0.id == item.id } } } - func saveMemory(_ item: ChatMemory, content: String) async { - do { let result: ChatMemory = try await call("api/memory/\(item.section)", method: "PUT", body: ["revision": item.revision, "content": content]); if let i = memory.firstIndex(where: { $0.id == item.id }) { memory[i] = result }; error = nil } catch { report(error) } + func latestMemory(_ item: ChatMemory) async -> ChatMemory? { + do { + struct Page: Decodable { let sections: [ChatMemory] } + let page: Page = try await call("api/memory") + guard let latest = page.sections.first(where: { $0.section == item.section }) else { throw ChatFailure.invalidResponse } + memory = page.sections; error = nil; return latest + } catch { report(error); return nil } } - func close() { closed = true; epoch += 1; enabled = []; attachments = []; artifacts = []; tasks = []; memory = []; clearPreview(); error = nil } + @discardableResult + func saveMemory(_ item: ChatMemory, content: String) async -> Bool { + do { + let result: ChatMemory = try await call("api/memory/\(item.section)", method: "PUT", body: ["revision": item.revision, "content": content]) + if let i = memory.firstIndex(where: { $0.id == item.id }) { memory[i] = result } + error = nil; return true + } catch { + if case ChatFailure.status(409) = error { self.error = "Memory changed on the server. Compare the latest version, then explicitly accept its revision before saving your draft." } + else { report(error) } + return false + } + } + func close() { closed = true; epoch += 1; capabilityGeneration += 1; loadGeneration += 1; enabled = []; attachments = []; artifacts = []; tasks = []; memory = []; clearPreview(); error = nil } } +enum ChatLibraryDestination { case files, tasks, memory } + struct ChatLibraryView: View { @ObservedObject var store: ChatFeatureStore - @Binding var selectedAttachmentIds: Set + var initialDestination: ChatLibraryDestination = .files + var selectedAttachmentIds: Set = [] + var onUseAttachment: ((ChatAttachment) async -> Bool)? + var onRemoveAttachment: ((String) -> Void)? @Environment(\.dismiss) private var dismiss @State private var importing = false @State private var photo: PhotosPickerItem? @@ -156,6 +254,15 @@ struct ChatLibraryView: View { @State private var name = "" @State private var schedule = "" @State private var prompt = "" + @State private var repeatCount = "" + @State private var createPaused = true + @State private var usingAttachment = false + @State private var feedback: String? + @State private var latestMemory: ChatMemory? + @State private var acceptedMemory: ChatMemory? + @State private var memoryWorking = false + @State private var memoryRequiresReview = false + @State private var confirmingClear = false @State private var memory: ChatMemory? @State private var memoryContent = "" @State private var deleting: (() async -> Void)? @@ -165,57 +272,75 @@ struct ChatLibraryView: View { if let error = store.error { Section { Text(error).foregroundStyle(.red); Button("Retry") { Task { await store.load() } } } } if loadingCapabilities { ContentUnavailableView("Checking server capabilities", systemImage: "server.rack", description: Text("Only features your account supports are shown.")) - } else if store.enabled.isEmpty { - ContentUnavailableView("Library features aren’t available", systemImage: "lock", description: Text("Your server has not enabled private files, documents, schedules, or memory controls for this account.")) + } else if (initialDestination == .files && !store.enabled.contains("attachments") && !store.enabled.contains("artifacts")) || (initialDestination == .tasks && !store.enabled.contains("scheduled_tasks")) || (initialDestination == .memory && !store.enabled.contains("memory_controls")) { + ContentUnavailableView("Not available on this server", systemImage: "lock", description: Text("These features must be enabled for your account by the Hope server.")) } - if store.enabled.contains("attachments") { - Section("Files") { - Button("Import from Files", systemImage: "doc.badge.plus") { importing = true }.disabled(store.busy) - if store.enabled.contains("image_input") { PhotosPicker(selection: $photo, matching: .images) { Label("Import photo", systemImage: "photo") }.disabled(store.busy) } + if initialDestination == .files && store.enabled.contains("attachments") { + Section("Private files") { + Text("Upload private files, then use them in your next message. Nothing is sent automatically.").font(.footnote).foregroundStyle(.secondary) + if let feedback { Text(feedback).font(.footnote) } + if store.busy { ProgressView("Uploading private file…") } + Button("Upload file", systemImage: "doc.badge.plus") { importing = true }.disabled(store.busy) + if store.enabled.contains("image_input") { PhotosPicker(selection: $photo, matching: .images) { Label("Upload photo", systemImage: "photo") }.disabled(store.busy) } ForEach(store.attachments) { item in VStack(alignment: .leading) { HStack { - Button { - if selectedAttachmentIds.contains(item.id) { selectedAttachmentIds.remove(item.id) } - else if selectedAttachmentIds.count < 8 { selectedAttachmentIds.insert(item.id) } - else { store.error = "Select up to 8 files for one message." } - } label: { Label(item.filename, systemImage: selectedAttachmentIds.contains(item.id) ? "checkmark.circle.fill" : "circle").frame(minHeight: 44) } - .disabled(item.extractionStatus == "failed" || item.extractionStatus == "encrypted" || (item.mime.hasPrefix("image/") && !store.enabled.contains("image_input"))) + Text(item.filename).font(.body) + Spacer() Button("Preview") { showingPreview = true; Task { await store.preview(item) } }.frame(minHeight: 44) } Text("\(ByteCountFormatter.string(fromByteCount: Int64(item.size), countStyle: .file)) · \(item.extractionStatus)").font(.caption).foregroundStyle(.secondary) - }.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteAttachment(item); selectedAttachmentIds.remove(item.id) } } } + if let reason = ChatFeatureStore.blockedReason(item, imageInput: store.enabled.contains("image_input")) { + Text(reason).font(.footnote).foregroundStyle(.secondary) + } + if selectedAttachmentIds.contains(item.id) { + Label("Queued for your next message", systemImage: "checkmark.circle.fill").font(.footnote) + Button("Remove from message") { onRemoveAttachment?(item.id) } + } else { + Button("Use in conversation", systemImage: "arrow.up.message") { + Task { + usingAttachment = true; defer { usingAttachment = false } + guard let validated = await store.validatedAttachment(item) else { return } + guard let onUseAttachment else { store.error = "Open Library from a conversation to use this file."; return } + if await onUseAttachment(validated) { dismiss() } + } + }.disabled(usingAttachment || store.busy || onUseAttachment == nil || ChatFeatureStore.blockedReason(item, imageInput: store.enabled.contains("image_input")) != nil) + .accessibilityHint("Queues this private file in your draft. Does not send a message.") + } + }.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteAttachment(item); if store.error == nil { onRemoveAttachment?(item.id) } } } } } Text("Select up to 8 files for the next message. Uploading alone does not send them.").font(.footnote).foregroundStyle(.secondary) } } - if store.enabled.contains("artifacts") { - Section("Artifacts") { - Button("New artifact", systemImage: "doc.text") { artifact = nil; title = ""; content = ""; kind = "markdown"; artifactEditor = true } + if initialDestination == .files && store.enabled.contains("artifacts") { + Section("Saved drafts") { + Text("Documents you save here are separate from conversation history.").font(.footnote).foregroundStyle(.secondary) + Button("New saved draft", systemImage: "doc.text") { artifact = nil; title = ""; content = ""; kind = "markdown"; artifactEditor = true } ForEach(store.artifacts) { item in Button { Task { if let detail = await store.detail(item) { artifact = detail; title = detail.title; kind = detail.kind; content = detail.content ?? ""; artifactEditor = true } } } label: { VStack(alignment: .leading) { Text(item.title); Text("Revision \(item.revision) · \(item.kind)").font(.caption).foregroundStyle(.secondary) } }.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteArtifact(item) } } } } } } - if store.enabled.contains("scheduled_tasks") { + if initialDestination == .tasks && store.enabled.contains("scheduled_tasks") { Section("Scheduled tasks") { Text("Tasks run on your Hope server. Push notifications and task output history are not available in this app.").font(.footnote).foregroundStyle(.secondary) - Button("New scheduled task", systemImage: "calendar.badge.plus") { task = nil; name = ""; schedule = ""; prompt = ""; taskEditor = true } + Button("New scheduled task", systemImage: "calendar.badge.plus") { task = nil; name = ""; schedule = ""; prompt = ""; repeatCount = ""; createPaused = true; taskEditor = true } ForEach(store.tasks) { item in VStack(alignment: .leading) { - Button(item.name ?? item.id) { task = item; name = item.name ?? ""; schedule = item.schedule ?? ""; prompt = item.prompt ?? ""; taskEditor = true }.frame(minHeight: 44) - Text(item.schedule ?? "Schedule not provided").font(.caption).foregroundStyle(.secondary) + Button(item.name ?? item.id) { task = item; name = item.name ?? ""; schedule = item.schedule ?? ""; prompt = item.prompt ?? ""; repeatCount = item.repeat.map(String.init) ?? ""; taskEditor = true }.frame(minHeight: 44) + Text("\(item.enabled == true ? "Active" : item.enabled == false ? "Paused" : "Status not provided") · \(item.schedule ?? "Schedule not provided")").font(.caption).foregroundStyle(.secondary) HStack { Button(item.enabled == false ? "Resume" : "Pause") { Task { await store.taskAction(item, action: item.enabled == false ? "resume" : "pause") } }; Button("Run now") { Task { await store.taskAction(item, action: "run") } } } }.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteTask(item) } } } } } } - if store.enabled.contains("memory_controls") { - Section("Account memory") { - ForEach(store.memory) { item in Button(item.section.capitalized) { memory = item; memoryContent = item.content } } + if initialDestination == .memory && store.enabled.contains("memory_controls") { + Section("Durable account memory") { + Text("Guidance shapes future replies. About you stores lasting preferences and personal context. Neither is your conversation history.").font(.footnote).foregroundStyle(.secondary) + ForEach(store.memory) { item in Button(item.section == "user" ? "About you" : "Guidance") { memory = item; memoryContent = item.content; latestMemory = nil; acceptedMemory = nil; memoryRequiresReview = false } } Text("Changes may take effect on the next Hermes run or session. Clearing memory does not delete chats or all account data.").font(.footnote).foregroundStyle(.secondary) } } } - .navigationTitle("Library and tools") + .navigationTitle(initialDestination == .files ? "Library" : initialDestination == .tasks ? "Scheduled tasks" : "Memory") .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } .task { await store.load(); loadingCapabilities = false } .fileImporter(isPresented: $importing, allowedContentTypes: [.plainText, .pdf, .png, .jpeg, UTType(filenameExtension: "md") ?? .plainText, UTType(filenameExtension: "webp") ?? .image]) { result in @@ -226,11 +351,11 @@ struct ChatLibraryView: View { guard size <= 8 * 1024 * 1024 else { store.error = "Choose a file smaller than 8 MiB."; return } let mime: String switch url.pathExtension.lowercased() { case "md": mime = "text/markdown"; case "pdf": mime = "application/pdf"; case "png": mime = "image/png"; case "jpg", "jpeg": mime = "image/jpeg"; case "webp": mime = "image/webp"; default: mime = "text/plain" } - _ = await store.upload(try Data(contentsOf: url), filename: url.lastPathComponent, mime: mime) + if let uploaded = await store.upload(try Data(contentsOf: url), filename: url.lastPathComponent, mime: mime) { feedback = "\(uploaded.filename) uploaded privately. Tap Use in conversation to queue it." } } catch { store.error = "Could not read this file. Select it again to retry." } } } } - .onChange(of: photo) { _, value in Task { if let data = try? await value?.loadTransferable(type: Data.self), data.count <= 8 * 1024 * 1024, let image = UIImage(data: data), let jpeg = image.jpegData(compressionQuality: 0.85) { _ = await store.upload(jpeg, filename: "photo.jpg", mime: "image/jpeg") } else if value != nil { store.error = "This photo could not be imported or exceeds 8 MiB. Choose a smaller JPEG or PNG." }; photo = nil } } + .onChange(of: photo) { _, value in Task { if let data = try? await value?.loadTransferable(type: Data.self), data.count <= 8 * 1024 * 1024, let image = UIImage(data: data), let jpeg = image.jpegData(compressionQuality: 0.85) { if await store.upload(jpeg, filename: "photo.jpg", mime: "image/jpeg") != nil { feedback = "Photo uploaded privately. Tap Use in conversation to queue it." } } else if value != nil { store.error = "This photo could not be imported or exceeds 8 MiB. Choose a smaller JPEG or PNG." }; photo = nil } } .sheet(isPresented: $showingPreview, onDismiss: { store.clearPreview() }) { NavigationStack { ScrollView { @@ -248,20 +373,7 @@ struct ChatLibraryView: View { } .sheet(isPresented: $artifactEditor) { artifactForm } .sheet(isPresented: $taskEditor) { taskForm } -.sheet(item: $memory) { item in - NavigationStack { - Form { - Section("Memory") { TextEditor(text: $memoryContent).frame(minHeight: 260).accessibilityLabel("Memory content") } - Text("An empty value clears this section. Existing sessions may cache earlier memory.").font(.footnote) - if let error = store.error { Text(error).foregroundStyle(.red) } - } - .navigationTitle(item.section.capitalized) - .toolbar { - ToolbarItem(placement: .cancellationAction) { Button("Cancel") { memory = nil } } - ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveMemory(item, content: memoryContent); if store.error == nil { memory = nil } } } } - } - } - } + .sheet(item: $memory) { item in memoryForm(item) } .confirmationDialog("Delete this item?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) { Button("Delete", role: .destructive) { if let deleting { Task { await deleting() } }; deleting = nil } } } } @@ -278,17 +390,94 @@ struct ChatLibraryView: View { Text("Historical content uses the current title and kind. Saving creates a new revision.").font(.footnote) } if let error = store.error { Text(error).foregroundStyle(.red) } - }.navigationTitle(artifact == nil ? "New artifact" : "Edit artifact") + }.navigationTitle(artifact == nil ? "New saved draft" : "Edit saved draft") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { artifactEditor = false } }; ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveArtifact(artifact, title: title, kind: kind, content: content); if store.error == nil { artifactEditor = false } } }.disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } } } - private var taskForm: some View { + private func memoryForm(_ item: ChatMemory) -> some View { NavigationStack { - Form { TextField("Name", text: $name); TextField("Hermes schedule", text: $schedule); Text("Use a schedule accepted by your Hermes server. Hope does not invent a separate scheduling format.").font(.footnote); TextEditor(text: $prompt).frame(minHeight: 180); if let error = store.error { Text(error).foregroundStyle(.red) } } - .navigationTitle(task == nil ? "New task" : "Edit task") - .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { taskEditor = false } }; ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveTask(task, name: name, schedule: schedule, prompt: prompt); if store.error == nil { taskEditor = false } } }.disabled(name.isEmpty || schedule.isEmpty || prompt.isEmpty) } } + Form { + Section("Your local draft") { + TextEditor(text: $memoryContent).frame(minHeight: 220).accessibilityLabel("Memory draft").disabled(memoryWorking) + Text("Your draft stays here while you compare server changes. Saving affects future runs; existing sessions may retain earlier memory.").font(.footnote).foregroundStyle(.secondary) + } + Section("Revision review") { + Button("Compare latest server version") { + Task { + memoryWorking = true; defer { memoryWorking = false } + if let value = await store.latestMemory(item) { latestMemory = value; acceptedMemory = nil; memoryRequiresReview = true } + } + }.disabled(memoryWorking) + if let latestMemory { + Text(latestMemory.revision == item.revision ? "The server revision matches the version you opened." : "The server has a newer revision. Review it before overwriting with your draft.").font(.footnote) + Text(latestMemory.content.isEmpty ? "This section is empty on the server." : latestMemory.content).textSelection(.enabled) + Button("Accept this revision for my next save") { acceptedMemory = latestMemory; memoryRequiresReview = false } + .disabled(acceptedMemory?.revision == latestMemory.revision) + if acceptedMemory != nil { Text("Revision accepted. Saving will write your local draft, not the server text.").font(.footnote) } + } + } + Section { + Button("Clear this memory section", role: .destructive) { confirmingClear = true }.disabled(memoryWorking || memoryRequiresReview) + Text("Clearing removes only this durable section, not chats or all account data.").font(.footnote).foregroundStyle(.secondary) + } + if let error = store.error { Text(error).foregroundStyle(.red) } + } + .navigationTitle(item.section == "user" ? "About you" : "Guidance") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { memory = nil } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + if memoryContent.isEmpty { confirmingClear = true } + else { Task { await writeMemory(acceptedMemory ?? item, content: memoryContent) } } + }.disabled(memoryWorking || memoryRequiresReview) + } + } + .confirmationDialog("Clear this memory section?", isPresented: $confirmingClear, titleVisibility: .visible) { + Button("Clear section", role: .destructive) { Task { await writeMemory(acceptedMemory ?? item, content: "") } } + } message: { Text("This removes durable memory only. Conversation history is unchanged.") } } } + private func writeMemory(_ base: ChatMemory, content: String) async { + memoryWorking = true; defer { memoryWorking = false } + if await store.saveMemory(base, content: content) { memory = nil } + else if store.error?.contains("Memory changed") == true { memoryRequiresReview = true; acceptedMemory = nil } + } + private var taskForm: some View { + NavigationStack { + Form { + Section("Task") { + TextField("Task name", text: $name) + TextField("Schedule", text: $schedule).autocorrectionDisabled().textInputAutocapitalization(.never) + Text("Enter a schedule accepted by your server, for example cron: 0 9 * * *. The server validates it; timezone and next run are not inferred here.").font(.footnote).foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 8) { + Text("Instructions").font(.subheadline.weight(.semibold)) + TextEditor(text: $prompt).frame(minHeight: 150).accessibilityLabel("Task instructions") + Text("Tell Hope what to do when this task runs. For example: review my plans and suggest one practical next step.").font(.footnote).foregroundStyle(.secondary) + } + } + Section("Options") { + TextField("Repeat count (optional)", text: $repeatCount).keyboardType(.numberPad) + Text("Leave blank for the server default, or enter a positive number of executions.").font(.footnote).foregroundStyle(.secondary) + if task == nil { Toggle("Create paused", isOn: $createPaused) } + Text("Tasks execute on your Hope server. This app does not deliver push notifications or show task output history.").font(.footnote).foregroundStyle(.secondary) + } + if let error = store.error { Text(error).foregroundStyle(.red) } + } + .navigationTitle(task == nil ? "New scheduled task" : "Edit task") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { taskEditor = false } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { Task { + let trimmed = repeatCount.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty && (Int(trimmed) == nil || (Int(trimmed) ?? 0) <= 0) { store.error = "Repeat count must be a positive whole number."; return } + await store.saveTask(task, name: name, schedule: schedule, prompt: prompt, repeatCount: Int(trimmed), paused: createPaused) + if store.error == nil { taskEditor = false } + } }.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || schedule.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } + } struct PrivatePDFPreview: UIViewRepresentable { diff --git a/ios/Sources/Chat/ChatModels.swift b/ios/Sources/Chat/ChatModels.swift index 76d037e..d30e259 100644 --- a/ios/Sources/Chat/ChatModels.swift +++ b/ios/Sources/Chat/ChatModels.swift @@ -20,7 +20,22 @@ struct HopeMessage: Codable, Identifiable, Equatable { var status: String var runId: String? var createdAt: Double - // Attachments are deliberately unavailable until their contract is qualified. + var attachments: [String] = [] + enum CodingKeys: String, CodingKey { case id, conversationId, role, content, status, runId, createdAt, attachments } + init(id: String, conversationId: String, role: String, content: String, status: String, runId: String?, createdAt: Double, attachments: [String] = []) { + self.id = id; self.conversationId = conversationId; self.role = role; self.content = content; self.status = status; self.runId = runId; self.createdAt = createdAt; self.attachments = attachments + } + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + conversationId = try values.decode(String.self, forKey: .conversationId) + role = try values.decode(String.self, forKey: .role) + content = try values.decode(String.self, forKey: .content) + status = try values.decode(String.self, forKey: .status) + runId = try values.decodeIfPresent(String.self, forKey: .runId) + createdAt = try values.decode(Double.self, forKey: .createdAt) + attachments = try values.decodeIfPresent([String].self, forKey: .attachments) ?? [] + } } struct HopeRun: Codable, Identifiable, Equatable { var id: String @@ -188,7 +203,7 @@ final class OfflineChatAPI: ChatTransport { if method == "GET" { return try JSONSerialization.data(withJSONObject: [kind: featureObjects[kind] ?? [], "next_cursor": NSNull()]) } var item = body; item["id"] = UUID().uuidString; item["created_at"] = now; item["updated_at"] = now if kind == "artifacts" { item["revision"] = 1 } - if kind == "tasks" { item["enabled"] = true } + if kind == "tasks" { item["enabled"] = !(body["paused"] as? Bool ?? false) } featureObjects[kind, default: []].insert(item, at: 0) return try JSONSerialization.data(withJSONObject: [singular: item]) } @@ -223,7 +238,8 @@ final class OfflineChatAPI: ChatTransport { if method == "POST" { let c = HopeConversation(id: UUID().uuidString, title: body["title"] as? String ?? "New chat", workspaceId: body["workspace_id"] as? String, pinned: false, pinRank: 0, archived: false, temporary: false, revision: 1, createdAt: now, updatedAt: now); conversations.insert(c, at: 0); return try wrap("conversation", c) } let q = query.first(where: { $0.name == "q" })?.value ?? "" let archived = query.first(where: { $0.name == "archived" })?.value == "true" - let filtered = conversations.filter { $0.archived == archived && (q.isEmpty || $0.title.localizedCaseInsensitiveContains(q) || (messages[$0.id] ?? []).contains { $0.content.localizedCaseInsensitiveContains(q) }) } + let workspace = query.first(where: { $0.name == "workspace_id" })?.value + let filtered = conversations.filter { $0.archived == archived && (workspace == nil || $0.workspaceId == workspace) && (q.isEmpty || $0.title.localizedCaseInsensitiveContains(q) || (messages[$0.id] ?? []).contains { $0.content.localizedCaseInsensitiveContains(q) }) } return try JSONSerialization.data(withJSONObject: ["conversations": JSONSerialization.jsonObject(with: encode(filtered)), "next_cursor": NSNull()]) } if parts.count >= 3, parts[1] == "conversations", let index = conversations.firstIndex(where: { $0.id == parts[2] }) { diff --git a/ios/Sources/Chat/ChatStore.swift b/ios/Sources/Chat/ChatStore.swift index 5f4c059..4ba2f9b 100644 --- a/ios/Sources/Chat/ChatStore.swift +++ b/ios/Sources/Chat/ChatStore.swift @@ -17,6 +17,9 @@ final class ChatStore: ObservableObject { @Published var olderMessagesCursor: String? private var currentQuery = "" private var currentArchived = false + private var currentWorkspace: String? + @Published private(set) var mutatingConversationIds: Set = [] + private(set) var draftGeneration = 0 private var searchGeneration = 0 @Published var approval: (id: String, prompt: String)? @Published var researchAvailable = false @@ -31,6 +34,22 @@ final class ChatStore: ObservableObject { @Published var modelNote: String? let features: ChatFeatureStore @Published var selectedAttachmentIds: Set = [] + private var queuedAttachments: [String: ChatAttachment] = [:] + var canSendAttachments: Bool { + guard !closed else { return false } + if selectedAttachmentIds.isEmpty { return true } + guard features.enabled.contains("attachments"), selectedAttachmentIds.count <= 8 else { return false } + return selectedAttachmentIds.allSatisfy { id in + guard let attachment = queuedAttachments[id] ?? features.attachments.first(where: { $0.id == id }) else { return false } + return attachmentEligible(attachment) + } + } + private func attachmentEligible(_ attachment: ChatAttachment) -> Bool { + let image = ["image/png", "image/jpeg", "image/webp"].contains(attachment.mime) + let document = ["text/plain", "text/markdown", "application/pdf"].contains(attachment.mime) + return (image && attachment.extractionStatus == "image" && features.enabled.contains("image_input")) || + (document && ["ready", "truncated"].contains(attachment.extractionStatus)) + } private let api: ChatTransport private var generation = 0 private var stream: Task? @@ -43,11 +62,11 @@ final class ChatStore: ObservableObject { init(api: ChatTransport) { self.api = api; self.features = ChatFeatureStore(api: api) } deinit { stream?.cancel(); syncTask?.cancel() } - private func call(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T { + private func call(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = [], selectionBound: Bool = true) async throws -> T { let epoch = generation let data = try await api.request(path, method: method, body: body, query: query) try Task.checkCancellation() - guard !closed, generation == epoch else { throw CancellationError() } + guard !closed, !selectionBound || generation == epoch else { throw CancellationError() } return try ChatCoding.decoder.decode(T.self, from: data) } private func failure(_ value: Error) async { @@ -55,27 +74,33 @@ final class ChatStore: ObservableObject { if case ChatFailure.status(let code) = value, code == 401 || code == 403 { await onUnauthorized?(); return } error = value.localizedDescription } - func load(query: String = "", archived: Bool = false, more: Bool = false) async { + func load(query: String = "", archived: Bool = false, workspace: String? = nil, more: Bool = false) async { guard !closed else { return } - currentQuery = query; currentArchived = archived + let sameScope = currentQuery == query && currentArchived == archived && currentWorkspace == workspace + currentQuery = query; currentArchived = archived; currentWorkspace = workspace searchGeneration += 1 let search = searchGeneration searching = true defer { if search == searchGeneration { searching = false } } do { var q = [URLQueryItem(name: "q", value: query), URLQueryItem(name: "archived", value: String(archived)), URLQueryItem(name: "limit", value: "50")] - if more, let nextCursor { q.append(URLQueryItem(name: "cursor", value: nextCursor)) } - let page: ConversationPage = try await call("api/conversations", query: q) + if let workspace { q.append(URLQueryItem(name: "workspace_id", value: workspace)) } + if more, sameScope, let nextCursor { q.append(URLQueryItem(name: "cursor", value: nextCursor)) } + let page: ConversationPage = try await call("api/conversations", query: q, selectionBound: false) guard search == searchGeneration else { return } - if more { let existing = Set(conversations.map(\.id)); conversations += page.conversations.filter { !existing.contains($0.id) } } + if more && sameScope { let existing = Set(conversations.map(\.id)); conversations += page.conversations.filter { !existing.contains($0.id) } } else { conversations = page.conversations } nextCursor = page.nextCursor - let spaces: WorkspacePage = try await call("api/workspaces"); workspaces = spaces.workspaces - let prefs: PreferencesEnvelope = try await call("api/preferences"); preferences = prefs.preferences + let spaces: WorkspacePage = try await call("api/workspaces", selectionBound: false) + guard search == searchGeneration else { return }; workspaces = spaces.workspaces + let prefs: PreferencesEnvelope = try await call("api/preferences", selectionBound: false) + guard search == searchGeneration else { return }; preferences = prefs.preferences error = nil } catch { await failure(error) } } func open(_ conversation: HopeConversation) async { + guard !closed else { return } + draftGeneration += 1 if let selected { drafts[selected.id] = draft } generation += 1; stream?.cancel(); stream = nil selected = conversation; messages = []; runs = []; progress = nil; approval = nil; exportText = nil; exportFile = nil @@ -100,18 +125,26 @@ final class ChatStore: ObservableObject { do { var body: [String: Any] = [:]; if let workspace { body["workspace_id"] = workspace } let response: ConversationEnvelope = try await call("api/conversations", method: "POST", body: body) - conversations.insert(response.conversation, at: 0); await open(response.conversation) + let selectionEpoch = generation + await refreshList() + guard !closed, generation == selectionEpoch else { busy = false; return } + await open(response.conversation) } catch { await failure(error) } } func send() async { - guard let selected, !busy, activeRun == nil else { return } + guard let selected, !busy, activeRun == nil, !mutatingConversationIds.contains(selected.id) else { return } let content = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard !content.isEmpty else { return } + guard canSendAttachments else { + error = "Some attachments are unavailable for the current model. Refresh Files, choose a supported model, or remove those files before sending." + return + } busy = true; error = nil; defer { busy = false } let payloadIdentity = content + "\n" + selectedAttachmentIds.sorted().joined(separator: ",") + String(research && researchAvailable) if pendingKey?.content != payloadIdentity { pendingKey = (payloadIdentity, UUID().uuidString) } do { let response: SendEnvelope = try await call("api/conversations/\(selected.id)/messages", method: "POST", body: ["content": content, "idempotency_key": pendingKey!.key, "attachment_ids": Array(selectedAttachmentIds).sorted(), "mode": research && researchAvailable ? "research" : "normal"]) + draftGeneration += 1 upsert(response.message); upsert(response.run); draft = ""; drafts[selected.id] = ""; pendingKey = nil; selectedAttachmentIds = [] if response.run.active { replay(response.run.id) } else { let detail: ConversationDetail = try await call("api/conversations/\(selected.id)"); messages = detail.messages } @@ -173,24 +206,78 @@ final class ChatStore: ObservableObject { do { let result: RunEnvelope = try await call("api/runs/\(activeRun.id)/approval", method: "POST", body: ["approval_id": approval.id, "decision": decision]); upsert(result.run); self.approval = nil; replay(result.run.id) } catch { await failure(error) } } + func canMutate(_ conversation: HopeConversation) -> Bool { + !closed && !mutatingConversationIds.contains(conversation.id) && + (selected?.id != conversation.id || (!busy && activeRun == nil)) + } + private func refreshList() async { + await load(query: currentQuery, archived: currentArchived, workspace: currentWorkspace) + } + private func updateMetadata(_ value: HopeConversation) { + if selected?.id == value.id { selected = value } + if value.archived != currentArchived || (currentWorkspace != nil && value.workspaceId != currentWorkspace) { + conversations.removeAll { $0.id == value.id } + } else if let index = conversations.firstIndex(where: { $0.id == value.id }) { + conversations[index] = value + } + } func patch(_ conversation: HopeConversation, changes: [String: Any]) async { + guard canMutate(conversation) else { return } + mutatingConversationIds.insert(conversation.id) + defer { mutatingConversationIds.remove(conversation.id) } var body = changes; body["revision"] = conversation.revision do { - let response: ConversationEnvelope = try await call("api/conversations/\(conversation.id)", method: "PATCH", body: body) - if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { conversations[index] = response.conversation } - if selected?.id == conversation.id { selected = response.conversation } + let response: ConversationEnvelope = try await call("api/conversations/\(conversation.id)", method: "PATCH", body: body, selectionBound: false) + updateMetadata(response.conversation) + await refreshList() + } catch ChatFailure.status(409) { + do { + let detail: ConversationDetail = try await call("api/conversations/\(conversation.id)", selectionBound: false) + updateMetadata(detail.conversation) + await refreshList() + guard !closed else { return } + error = "This chat changed elsewhere. Its latest details are loaded. Review your change and try again." + } catch { await failure(error) } } catch { await failure(error) } } func delete(_ conversation: HopeConversation) async { + guard canMutate(conversation) else { return } + mutatingConversationIds.insert(conversation.id) + defer { mutatingConversationIds.remove(conversation.id) } do { struct Result: Decodable { let ok: Bool } - let _: Result = try await call("api/conversations/\(conversation.id)", method: "DELETE") + let _: Result = try await call("api/conversations/\(conversation.id)", method: "DELETE", selectionBound: false) conversations.removeAll { $0.id == conversation.id }; drafts[conversation.id] = nil - if selected?.id == conversation.id { generation += 1; stream?.cancel(); selected = nil; messages = []; runs = []; draft = "" } + if selected?.id == conversation.id { clearSelection() } + await refreshList() } catch { await failure(error) } } + private func clearSelection() { + generation += 1; draftGeneration += 1; stream?.cancel(); stream = nil + selected = nil; messages = []; runs = []; draft = ""; selectedAttachmentIds = [] + pendingKey = nil; approval = nil; progress = nil; exportText = nil; exportFile = nil; olderMessagesCursor = nil + } + func queuedAttachment(_ id: String) -> ChatAttachment? { + queuedAttachments[id] ?? features.attachments.first(where: { $0.id == id }) + } + /// Bind asynchronous library/upload results to the composer that opened them. + @discardableResult + func queueAttachment(_ attachment: ChatAttachment, expectedDraftGeneration: Int? = nil) async -> Bool { + guard !closed, let selected, !busy, activeRun == nil, !mutatingConversationIds.contains(selected.id), + expectedDraftGeneration == nil || expectedDraftGeneration == draftGeneration else { return false } + guard features.enabled.contains("attachments") else { error = "Attachments are unavailable. Refresh Files and try again."; return false } + guard attachmentEligible(attachment) else { + error = "This file cannot be used with the current model. Choose an extracted document or a supported image."; return false + } + guard selectedAttachmentIds.contains(attachment.id) || selectedAttachmentIds.count < 8 else { + error = "Use at most eight attachments per message."; return false + } + queuedAttachments[attachment.id] = attachment + selectedAttachmentIds.insert(attachment.id); pendingKey = nil; error = nil + return true + } func branch(_ message: HopeMessage, edited: String? = nil, regenerate: Bool = false) async { - guard let selected, !busy, activeRun == nil else { return } + guard let selected, !busy, activeRun == nil, !mutatingConversationIds.contains(selected.id) else { return } busy = true do { let target: HopeMessage @@ -202,10 +289,26 @@ final class ChatStore: ObservableObject { if let edited { body["content"] = edited } else if regenerate { body["content"] = target.content } let response: ConversationEnvelope = try await call("api/conversations/\(selected.id)/branch", method: "POST", body: body) - conversations.insert(response.conversation, at: 0); await open(response.conversation) + let selectionEpoch = generation + await refreshList() + guard !closed, generation == selectionEpoch else { busy = false; return } + await open(response.conversation) + guard !closed, self.selected?.id == response.conversation.id else { busy = false; return } + if let edited { draft = edited; selectedAttachmentIds = Set(target.attachments) } + else if regenerate { draft = target.content; selectedAttachmentIds = Set(target.attachments) } + if edited != nil || regenerate, !target.attachments.isEmpty { + let attachmentEpoch = generation + _ = await features.refreshCapabilities() + guard !closed, generation == attachmentEpoch, self.selected?.id == response.conversation.id else { busy = false; return } + for id in target.attachments { + struct AttachmentEnvelope: Decodable { let attachment: ChatAttachment } + let metadata: AttachmentEnvelope = try await call("api/attachments/\(id)") + queuedAttachments[id] = metadata.attachment + } + } busy = false - if let edited { draft = edited; await send() } - else if regenerate { draft = target.content; await send() } + if let edited { draft = edited; selectedAttachmentIds = Set(target.attachments); await send() } + else if regenerate { draft = target.content; selectedAttachmentIds = Set(target.attachments); await send() } } catch { busy = false; await failure(error) } } @discardableResult @@ -233,6 +336,8 @@ final class ChatStore: ObservableObject { let _: Result = try await call("api/workspaces/\(workspace.id)", method: "DELETE") workspaces.removeAll { $0.id == workspace.id } for i in conversations.indices where conversations[i].workspaceId == workspace.id { conversations[i].workspaceId = nil } + if selected?.workspaceId == workspace.id { selected?.workspaceId = nil } + await refreshList() } catch { await failure(error) } } func savePreferences(palette: String, appearance: String) async { @@ -246,7 +351,7 @@ final class ChatStore: ObservableObject { } catch { modelNote = "Server model settings are not available." } } func saveModel() async { - do { let result: ChatSettingsSaved = try await call("api/settings/model", method: "PUT", body: ["model": model, "thinking_level": thinkingLevel]); model = result.settings.model; thinkingLevel = result.settings.thinkingLevel; modelNote = result.message } + do { let result: ChatSettingsSaved = try await call("api/settings/model", method: "PUT", body: ["model": model, "thinking_level": thinkingLevel], selectionBound: false); model = result.settings.model; thinkingLevel = result.settings.thinkingLevel; modelNote = result.message; _ = await features.refreshCapabilities(); await loadResearch() } catch { await failure(error) } } func loadResearch() async { @@ -278,20 +383,20 @@ final class ChatStore: ObservableObject { } } } - private func synchronize() async { + func synchronize() async { do { struct Change: Decodable { let id: String; let kind: String; let deleted: Bool; let data: HopeSyncData? } struct Result: Decodable { let changes: [Change]; let cursor: Int } - let result: Result = try await call("api/sync", query: [URLQueryItem(name: "after", value: String(syncCursor))]) + let result: Result = try await call("api/sync", query: [URLQueryItem(name: "after", value: String(syncCursor))], selectionBound: false) syncCursor = result.cursor guard !result.changes.isEmpty else { return } for change in result.changes { if change.kind == "conversation" { if change.deleted { conversations.removeAll { $0.id == change.id } - if selected?.id == change.id { stream?.cancel(); self.selected = nil; messages = []; runs = []; draft = "" } + if selected?.id == change.id { clearSelection() } } else if let value = change.data?.conversation { - if value.archived != currentArchived { conversations.removeAll { $0.id == value.id } } + if value.archived != currentArchived || (currentWorkspace != nil && value.workspaceId != currentWorkspace) { conversations.removeAll { $0.id == value.id } } else if let i = conversations.firstIndex(where: { $0.id == change.id }) { conversations[i] = value } else if currentQuery.isEmpty { conversations.insert(value, at: 0) } } @@ -302,6 +407,10 @@ final class ChatStore: ObservableObject { else if let value = change.data?.workspace { if let i = workspaces.firstIndex(where: { $0.id == value.id }) { workspaces[i] = value } else { workspaces.append(value) } } } } + // Reconcile only loaded metadata. Re-fetching page one here discards + // older loaded pages and their continuation cursor every sync tick. + // Search membership depends on message content, so newly matching rows + // are discovered only by an explicit search refresh, not guessed here. if let selected, result.changes.contains(where: { $0.id == selected.id && !$0.deleted }) { let detail: ConversationDetail = try await call("api/conversations/\(selected.id)") self.selected = detail.conversation @@ -311,7 +420,7 @@ final class ChatStore: ObservableObject { } catch { if case ChatFailure.status(let status) = error, status == 401 || status == 403 { await failure(error) } } } /// Invalidate every outstanding callback before clearing account-visible content. - func close() { syncTask?.cancel(); syncTask = nil; exportText = nil; exportFile = nil; research = false; features.close(); selectedAttachmentIds = []; closed = true; generation += 1; stream?.cancel(); stream = nil; api.cancel(); conversations = []; workspaces = []; selected = nil; messages = []; runs = []; draft = ""; drafts = [:]; cursors = [:]; pendingKey = nil; approval = nil; progress = nil; error = nil } + func close() { syncTask?.cancel(); syncTask = nil; exportText = nil; exportFile = nil; research = false; features.close(); selectedAttachmentIds = []; queuedAttachments = [:]; closed = true; generation += 1; draftGeneration += 1; searchGeneration += 1; mutatingConversationIds = []; stream?.cancel(); stream = nil; api.cancel(); conversations = []; workspaces = []; selected = nil; messages = []; runs = []; draft = ""; drafts = [:]; cursors = [:]; pendingKey = nil; approval = nil; progress = nil; error = nil } } struct ChatModelChoice: Decodable, Identifiable { let id: String; let thinkingLevels: [String]? } struct ChatSettingsValue: Decodable { let model: String; let thinkingLevel: String } diff --git a/ios/Sources/Chat/ChatViews.swift b/ios/Sources/Chat/ChatViews.swift index 732155a..3175fba 100644 --- a/ios/Sources/Chat/ChatViews.swift +++ b/ios/Sources/Chat/ChatViews.swift @@ -5,6 +5,12 @@ struct NativeChatView: View { @ObservedObject var store: ChatStore let talk: (String) async -> Void let logout: () async -> Void + let serverURL: String + let accountName: String + let voiceConversationLocked: (String) -> Bool + @State private var libraryDestination: ChatLibraryDestination = .files + @State private var attachmentGeneration = 0 + @State private var showSecurity = false @State private var query = "" @State private var archived = false @State private var workspace: String? @@ -27,13 +33,13 @@ struct NativeChatView: View { if workspace != nil || archived { Section { Label(scopeLabel, systemImage: archived ? "archivebox" : "folder") } } if !pinned.isEmpty { Section("Pinned") { rows(pinned) } } if !recent.isEmpty { Section(archived ? "Archived" : "Recent chats") { rows(recent) } } - if store.nextCursor != nil { Button("Load more") { Task { await store.load(query: query, archived: archived, more: true) } } } + if store.nextCursor != nil { Button("Load more") { Task { await store.load(query: query, archived: archived, workspace: workspace, more: true) } } } } .scrollContentBackground(.hidden) .background(HopeTheme.color(.ground, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal)) .navigationTitle("Chats") .searchable(text: $query, prompt: "Search chats and messages") - .task(id: query + String(archived)) { + .task(id: query + String(archived) + (workspace ?? "")) { do { try await Task.sleep(for: .milliseconds(250)); try Task.checkCancellation(); await refresh() } catch {} } .refreshable { await refresh() } @@ -42,15 +48,22 @@ struct NativeChatView: View { .toolbar { ToolbarItem(placement: .topBarLeading) { Menu { - Toggle("Show archived", isOn: $archived) - Picker("Workspace", selection: $workspace) { - Text("All chats").tag(nil as String?) - ForEach(store.workspaces) { Text($0.name).tag(Optional($0.id)) } + Menu("Settings", systemImage: "gearshape") { + Button("Appearance and model") { showPreferences = true } + Button("Security", systemImage: "lock.shield") { showSecurity = true } + Button("Manage workspaces") { showWorkspaces = true } + Button("Sign out", role: .destructive) { Task { await logout() } } } - Button("Library and tools") { showLibrary = true } - Button("Manage workspaces") { showWorkspaces = true } - Button("Appearance and model") { showPreferences = true } - Button("Sign out", role: .destructive) { Task { await logout() } } + Menu("Filter chats", systemImage: "line.3.horizontal.decrease") { + Toggle("Show archived", isOn: $archived) + Picker("Workspace", selection: $workspace) { + Text("All chats").tag(nil as String?) + ForEach(store.workspaces) { Text($0.name).tag(Optional($0.id)) } + } + } + Button("Library", systemImage: "books.vertical") { openLibrary(.files) } + Button("Scheduled tasks", systemImage: "calendar") { openLibrary(.tasks) } + Button("Memory & data", systemImage: "brain") { openLibrary(.memory) } } label: { Image(systemName: "line.3.horizontal.decrease.circle").frame(minWidth: 44, minHeight: 44) } .accessibilityLabel("Chat options") } @@ -60,22 +73,32 @@ struct NativeChatView: View { } } .navigationDestination(for: String.self) { _ in ChatDetailView(store: store, talk: talk) } - .sheet(isPresented: $showLibrary) { ChatLibraryView(store: store.features, selectedAttachmentIds: $store.selectedAttachmentIds) } + .sheet(isPresented: $showLibrary) { + ChatLibraryView(store: store.features, initialDestination: libraryDestination, selectedAttachmentIds: store.selectedAttachmentIds, onUseAttachment: { item in + guard await store.queueAttachment(item, expectedDraftGeneration: attachmentGeneration) else { return false } + if let id = store.selected?.id { path = [id] } + return true + }, onRemoveAttachment: { store.selectedAttachmentIds.remove($0) }) + } + .sheet(isPresented: $showSecurity) { ChatSecurityView(serverURL: serverURL, accountName: accountName, logout: logout) } .sheet(isPresented: $showPreferences) { ChatPreferencesView(store: store) } .sheet(isPresented: $showWorkspaces) { WorkspaceManagerView(store: store) } .alert("Rename chat", isPresented: Binding(get: { rename != nil }, set: { if !$0 { rename = nil } })) { TextField("Title", text: $title) Button("Cancel", role: .cancel) { rename = nil } - Button("Save") { if let rename { Task { await store.patch(rename, changes: ["title": title]); if store.error == nil { self.rename = nil } } } } + Button("Save") { if let rename, !voiceConversationLocked(rename.id) { Task { await store.patch(rename, changes: ["title": title]); if store.error == nil { self.rename = nil } } } } .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } .confirmationDialog("Delete this chat?", isPresented: Binding(get: { delete != nil }, set: { if !$0 { delete = nil } }), titleVisibility: .visible) { - Button("Delete chat", role: .destructive) { if let delete { Task { await store.delete(delete) } }; delete = nil } + Button("Delete chat", role: .destructive) { if let delete, !voiceConversationLocked(delete.id) { Task { await store.delete(delete) } }; delete = nil } Button("Cancel", role: .cancel) { delete = nil } } message: { Text("This removes the conversation, not your account memory.") } } } - private var visible: [HopeConversation] { store.conversations.filter { workspace == nil || $0.workspaceId == workspace } } + private var visible: [HopeConversation] { store.conversations } + private func openLibrary(_ destination: ChatLibraryDestination) { + libraryDestination = destination; attachmentGeneration = store.draftGeneration; showLibrary = true + } private var scopeLabel: String { archived ? "Archived chats" : (workspace.flatMap { id in store.workspaces.first(where: { $0.id == id })?.name } ?? "All chats") } private var emptyTitle: String { if !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "No matching chats" } @@ -90,9 +113,10 @@ struct NativeChatView: View { } private var pinned: [HopeConversation] { visible.filter(\.pinned).sorted { $0.pinRank < $1.pinRank } } private var recent: [HopeConversation] { visible.filter { !$0.pinned } } - private func refresh() async { await store.load(query: query, archived: archived) } + private func refresh() async { await store.load(query: query, archived: archived, workspace: workspace) } @ViewBuilder private func rows(_ items: [HopeConversation]) -> some View { ForEach(items) { conversation in + HStack(alignment: .center, spacing: 8) { Button { Task { await store.open(conversation); path = [conversation.id] } } label: { @@ -104,16 +128,61 @@ struct NativeChatView: View { }.font(.caption).foregroundStyle(.secondary) }.frame(minHeight: 44, alignment: .leading) } - .contextMenu { - Button(conversation.pinned ? "Unpin" : "Pin", systemImage: conversation.pinned ? "pin.slash" : "pin") { Task { await store.patch(conversation, changes: ["pinned": !conversation.pinned]) } } - Button("Rename", systemImage: "pencil") { title = conversation.title; rename = conversation } - Menu("Move to workspace") { - Button("Outside workspaces") { Task { await store.patch(conversation, changes: ["workspace_id": NSNull()]) } } - ForEach(store.workspaces) { space in Button(space.name) { Task { await store.patch(conversation, changes: ["workspace_id": space.id]) } } } - } - Button(conversation.archived ? "Unarchive" : "Archive", systemImage: "archivebox") { Task { await store.patch(conversation, changes: ["archived": !conversation.archived]) } } - Button("Delete", systemImage: "trash", role: .destructive) { delete = conversation } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: .leading) + .contextMenu { conversationActions(conversation) } + Menu { conversationActions(conversation) } label: { + Image(systemName: "ellipsis").frame(minWidth: 44, minHeight: 44).contentShape(Rectangle()) } + .accessibilityLabel("Actions for \(conversation.title)") + .disabled(!store.canMutate(conversation) || voiceConversationLocked(conversation.id)) + } + } + } + @ViewBuilder private func conversationActions(_ conversation: HopeConversation) -> some View { + Group { + Button(conversation.pinned ? "Unpin" : "Pin", systemImage: conversation.pinned ? "pin.slash" : "pin") { Task { guard !voiceConversationLocked(conversation.id) else { return }; await store.patch(conversation, changes: ["pinned": !conversation.pinned]) } } + Button("Rename", systemImage: "pencil") { title = conversation.title; rename = conversation } + Menu("Move to workspace") { + Button("Outside workspaces") { Task { guard !voiceConversationLocked(conversation.id) else { return }; await store.patch(conversation, changes: ["workspace_id": NSNull()]) } } + ForEach(store.workspaces) { space in Button(space.name) { Task { guard !voiceConversationLocked(conversation.id) else { return }; await store.patch(conversation, changes: ["workspace_id": space.id]) } } } + } + Button(conversation.archived ? "Unarchive" : "Archive", systemImage: "archivebox") { Task { guard !voiceConversationLocked(conversation.id) else { return }; await store.patch(conversation, changes: ["archived": !conversation.archived]) } } + Button("Delete", systemImage: "trash", role: .destructive) { delete = conversation } + }.disabled(!store.canMutate(conversation) || voiceConversationLocked(conversation.id)) + } +} + +struct ChatSecurityView: View { + let serverURL: String + let accountName: String + let logout: () async -> Void + @Environment(\.dismiss) private var dismiss + @State private var confirmSignOut = false + var body: some View { + NavigationStack { + Form { + Section { + LabeledContent("Connected server", value: URL(string: serverURL)?.host ?? serverURL) + LabeledContent("Connection", value: "HTTPS · Encrypted in transit") + LabeledContent("Signed-in account", value: accountName) + } header: { Text("Your connection") } footer: { + Text("Hope uses this server for your private account. The app checks the configured HTTPS certificate; changing servers signs you out.") + } + Section { + Text("Your sign-in is stored securely in this iPhone’s Keychain. Signing out removes local account access and ends the current session; it does not delete conversations, files, or saved memory on your server.") + Button("Sign out of this iPhone", role: .destructive) { confirmSignOut = true } + } header: { Text("Session access") } + Section("Password and account access") { + Text("Password resets are managed by your Hope administrator. Device-session lists and two-factor settings are not available in this app.") + } + } + .navigationTitle("Security") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } + .confirmationDialog("Sign out of Hope?", isPresented: $confirmSignOut, titleVisibility: .visible) { + Button("Sign out", role: .destructive) { Task { dismiss(); await logout() } } + } message: { Text("Your account data stays on the server. You’ll need to sign in again on this iPhone.") } } } } @@ -125,6 +194,8 @@ struct ChatDetailView: View { @State private var editedText = "" @State private var follow = false @State private var library = false + @State private var attachmentGeneration = 0 + @FocusState private var composerFocused: Bool @Environment(\.scenePhase) private var scenePhase var body: some View { ScrollViewReader { proxy in @@ -182,16 +253,19 @@ struct ChatDetailView: View { if store.researchAvailable { Toggle("Research with cited sources", isOn: $store.research).font(.footnote) } if !store.selectedAttachmentIds.isEmpty { AttachmentSelectionSummary(store: store) + if !store.canSendAttachments { Text("A queued file is no longer supported. Remove it or retry file support in Library.").font(.footnote).foregroundStyle(.secondary) } } HStack(alignment: .bottom) { - Button { library = true } label: { Image(systemName: "paperclip").frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Attach files") - TextField("Message Hope", text: $store.draft, axis: .vertical).lineLimit(1...7).textFieldStyle(.roundedBorder).accessibilityIdentifier("chat-draft") + Button { attachmentGeneration = store.draftGeneration; library = true } label: { Image(systemName: "paperclip").frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Attach files") + TextField("Message Hope", text: $store.draft, axis: .vertical).lineLimit(1...7).textFieldStyle(.roundedBorder).accessibilityIdentifier("chat-draft").focused($composerFocused) if store.activeRun != nil { Button("Stop", systemImage: "stop.fill") { Task { await store.stop() } }.frame(minWidth: 44, minHeight: 44) } - else { Button { follow = true; Task { await store.send() } } label: { Image(systemName: "arrow.up.circle.fill").font(.title).frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Send message").disabled(store.busy || store.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } + else { Button { follow = true; Task { await store.send() } } label: { Image(systemName: "arrow.up.circle.fill").font(.title).frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Send message").disabled(store.busy || !store.canSendAttachments || store.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } }.padding(16).background(.regularMaterial) } - .sheet(isPresented: $library) { ChatLibraryView(store: store.features, selectedAttachmentIds: $store.selectedAttachmentIds) } + .sheet(isPresented: $library, onDismiss: { if !store.selectedAttachmentIds.isEmpty { composerFocused = true } }) { + ChatLibraryView(store: store.features, selectedAttachmentIds: store.selectedAttachmentIds, onUseAttachment: { await store.queueAttachment($0, expectedDraftGeneration: attachmentGeneration) }, onRemoveAttachment: { store.selectedAttachmentIds.remove($0) }) + } .sheet(item: $editing) { message in NavigationStack { Form { Section("Edit message") { TextEditor(text: $editedText).frame(minHeight: 200) }; Text("A new branch preserves the original conversation.").font(.footnote).foregroundStyle(.secondary) } @@ -205,15 +279,15 @@ struct ChatDetailView: View { private struct AttachmentSelectionSummary: View { @ObservedObject var store: ChatStore - var selectedAttachments: [ChatAttachment] { store.features.attachments.filter { store.selectedAttachmentIds.contains($0.id) } } var body: some View { VStack(alignment: .leading, spacing: 4) { Text("Attached to next message (\(store.selectedAttachmentIds.count)/8)").font(.caption.weight(.semibold)) - ForEach(selectedAttachments) { item in + ForEach(store.selectedAttachmentIds.sorted(), id: \.self) { id in HStack { - Label(item.filename, systemImage: "doc").font(.caption).lineLimit(1) + Label(store.queuedAttachment(id)?.filename ?? "Unavailable attachment", systemImage: "doc").font(.caption).lineLimit(1) Spacer() - Button("Remove") { store.selectedAttachmentIds.remove(item.id) }.font(.caption).frame(minHeight: 36) + Button("Remove") { store.selectedAttachmentIds.remove(id) }.font(.caption).frame(minWidth: 44, minHeight: 44) + .accessibilityLabel("Remove \(store.queuedAttachment(id)?.filename ?? "unavailable attachment")") } } } diff --git a/ios/Sources/Features/Conversation/ConversationView.swift b/ios/Sources/Features/Conversation/ConversationView.swift index a7c536a..2643fe6 100644 --- a/ios/Sources/Features/Conversation/ConversationView.swift +++ b/ios/Sources/Features/Conversation/ConversationView.swift @@ -60,6 +60,8 @@ struct ConversationView: View { selectedTab = 0 }, logout: { await auth.logout { chat.close(); await session.disconnect() } + }, serverURL: activeServerURL, accountName: user.displayName ?? user.username, voiceConversationLocked: { id in + id == session.boundConversationID && session.state != .idle && !session.state.isFailed }) .tabItem { Label("Chats", systemImage: "bubble.left.and.text.bubble.right") } .tag(2) @@ -83,7 +85,7 @@ struct ConversationView: View { .tabItem { Label("Talk", systemImage: "bubble.left.and.bubble.right") } .tag(0) - ActivityTab(taskStore: session.taskStore, settings: settings, session: session) + ActivityTab(taskStore: session.taskStore, settings: settings, session: session, features: chat.features) .tag(1) } .tint(HopeTheme.color(.accent, palette: HopePalette(rawValue: chat.preferences.palette) ?? .opal)) @@ -141,10 +143,17 @@ private struct ActivityTab: View { @ObservedObject var taskStore: TaskStore @ObservedObject var settings: SettingsStore @ObservedObject var session: VoiceSession + @ObservedObject var features: ChatFeatureStore + @State private var showSchedules = false var body: some View { NavigationStack { TaskPanelView(taskStore: taskStore) + .safeAreaInset(edge: .top) { + Button("Scheduled tasks", systemImage: "calendar") { showSchedules = true } + .frame(maxWidth: .infinity, minHeight: 44).padding(.horizontal).background(.regularMaterial) + } + .sheet(isPresented: $showSchedules) { ChatLibraryView(store: features, initialDestination: .tasks) } .toolbar { ToolbarItem(placement: .topBarTrailing) { NavigationLink { diff --git a/ios/Sources/LiveKit/VoiceSession.swift b/ios/Sources/LiveKit/VoiceSession.swift index 7880901..a612a7e 100644 --- a/ios/Sources/LiveKit/VoiceSession.swift +++ b/ios/Sources/LiveKit/VoiceSession.swift @@ -112,6 +112,9 @@ final class VoiceSession: ObservableObject { NotificationCenter.default.removeObserver(self) } + /// Identity bound to Talk, independent of the current Chats selection. + var boundConversationID: String? { tokenClient.conversationID } + /// Explicit chat-to-Talk navigation tears down the prior room first. func bindConversation(_ id: String?) async { await disconnect() diff --git a/ios/Tests/ChatFeatureParityTests.swift b/ios/Tests/ChatFeatureParityTests.swift new file mode 100644 index 0000000..10a6c41 --- /dev/null +++ b/ios/Tests/ChatFeatureParityTests.swift @@ -0,0 +1,156 @@ +import XCTest +@testable import Voice + +private final class FeatureParityURLProtocol: URLProtocol { + static var handler: ((URLRequest) throws -> (Int, Data))? + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + do { + let (status, data) = try Self.handler!(request) + client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { client?.urlProtocol(self, didFailWithError: error) } + } + override func stopLoading() {} +} + +private final class SuspendedFeatureTransport: ChatTransport { + var handler: (@MainActor (String) async throws -> Data)? + func request(_ path: String, method: String, body: [String: Any]?, query: [URLQueryItem]) async throws -> Data { try await handler!(path) } + func events(run: String, after: Int, receive: @escaping @MainActor (ChatEvent) throws -> Void) async throws {} + func cancel() {} +} + +@MainActor +final class ChatFeatureParityTests: XCTestCase { + private func makeStore() throws -> (ChatFeatureStore, ChatAPI) { + guard case .success(let config) = ServerConfig.parse("https://hope.example") else { throw ChatFailure.invalidResponse } + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FeatureParityURLProtocol.self] + let api = ChatAPI(config: config, token: "offline-fixture", session: URLSession(configuration: configuration)) + return (ChatFeatureStore(api: api), api) + } + private func attachment(status: String = "ready", mime: String = "text/plain") -> ChatAttachment { + ChatAttachment(id: String(repeating: "a", count: 32), filename: "notes.txt", mime: mime, size: 5, extractionStatus: status, createdAt: 1) + } + private func envelope(_ item: ChatAttachment) throws -> Data { + try JSONSerialization.data(withJSONObject: ["attachment": ["id": item.id, "filename": item.filename, "mime": item.mime, "size": item.size, "extraction_status": item.extractionStatus, "created_at": item.createdAt]]) + } + func testMetadataRevalidationRejectsUnavailableAndDeletedFiles() async throws { + let (store, api) = try makeStore(); defer { api.cancel(); FeatureParityURLProtocol.handler = nil } + let old = attachment() + let unavailable = try envelope(attachment(status: "unavailable")) + var metadataStatus = 200 + FeatureParityURLProtocol.handler = { request in + if request.url?.path == "/api/capabilities" { return (200, Data("{\"features\":{\"attachments\":true}}".utf8)) } + XCTAssertEqual(request.url?.path, "/api/attachments/\(old.id)") + return (metadataStatus, unavailable) + } + let blocked = await store.validatedAttachment(old) + XCTAssertNil(blocked) + XCTAssertTrue(store.error?.contains("unavailable") == true) + metadataStatus = 404 + let deleted = await store.validatedAttachment(old) + XCTAssertNil(deleted) + XCTAssertTrue(store.error?.contains("no longer available") == true) + } + func testCapabilityRefreshFailsClosedAndImagesCannotUpload() async throws { + let (store, api) = try makeStore(); defer { api.cancel(); FeatureParityURLProtocol.handler = nil } + store.enabled = ["attachments", "image_input"] + FeatureParityURLProtocol.handler = { _ in (503, Data()) } + let refreshed = await store.refreshCapabilities() + XCTAssertFalse(refreshed) + XCTAssertTrue(store.enabled.isEmpty) + FeatureParityURLProtocol.handler = { request in + XCTAssertEqual(request.url?.path, "/api/capabilities", "Unqualified images must never reach upload") + return (200, Data("{\"features\":{\"attachments\":true,\"image_input\":false}}".utf8)) + } + let uploaded = await store.upload(Data([1, 2, 3]), filename: "photo.jpg", mime: "image/jpeg") + XCTAssertNil(uploaded) + XCTAssertTrue(store.error?.contains("Image input") == true) + } + func testLateCapabilityResponseCannotRestoreOldImageQualification() async throws { + let api = SuspendedFeatureTransport() + let store = ChatFeatureStore(api: api) + let firstStarted = expectation(description: "First capability request suspended") + var firstResponse: CheckedContinuation? + var requests = 0 + api.handler = { _ in + requests += 1 + if requests == 1 { + return try await withCheckedThrowingContinuation { continuation in + firstResponse = continuation + firstStarted.fulfill() + } + } + return Data("{\"features\":{\"attachments\":true,\"image_input\":false}}".utf8) + } + let first = Task { await store.refreshCapabilities() } + await fulfillment(of: [firstStarted], timeout: 2) + let latest = await store.refreshCapabilities() + XCTAssertTrue(latest) + firstResponse?.resume(returning: Data("{\"features\":{\"attachments\":true,\"image_input\":true}}".utf8)) + let stale = await first.value + XCTAssertFalse(stale) + XCTAssertFalse(store.enabled.contains("image_input")) + } + func testBlockedStatusesIncludeUnknownAndImageQualification() { + for status in ["failed", "encrypted", "unavailable", "pending"] { + XCTAssertNotNil(ChatFeatureStore.blockedReason(attachment(status: status), imageInput: true)) + } + XCTAssertNil(ChatFeatureStore.blockedReason(attachment(status: "truncated"), imageInput: false)) + XCTAssertNotNil(ChatFeatureStore.blockedReason(attachment(status: "image", mime: "image/png"), imageInput: false)) + } + func testPausedTaskAndOptionalRepeatUseContractFields() async throws { + let (store, api) = try makeStore(); defer { api.cancel(); FeatureParityURLProtocol.handler = nil } + FeatureParityURLProtocol.handler = { request in + XCTAssertEqual(request.url?.path, "/api/tasks") + XCTAssertEqual(request.httpMethod, "POST") + var bytes = request.httpBody ?? Data() + if bytes.isEmpty, let stream = request.httpBodyStream { + stream.open(); defer { stream.close() } + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; bytes.append(contentsOf: buffer.prefix(count)) } + } + let body = try XCTUnwrap(JSONSerialization.jsonObject(with: bytes) as? [String: Any]) + XCTAssertEqual(body["paused"] as? Bool, true) + XCTAssertEqual(body["repeat"] as? Int, 3) + XCTAssertEqual(body["schedule"] as? String, "0 9 * * *") + return (201, Data("{\"task\":{\"id\":\"aaaaaaaaaaaa\",\"enabled\":false,\"repeat\":3}}".utf8)) + } + await store.saveTask(nil, name: "Daily", schedule: "0 9 * * *", prompt: "Review", repeatCount: 3, paused: true) + XCTAssertNil(store.error) + XCTAssertEqual(store.tasks.first?.enabled, false) + XCTAssertEqual(store.tasks.first?.repeat, 3) + } + func testMemoryConflictRequiresExplicitLatestRevisionAndKeepsDraft() async throws { + let api = OfflineChatAPI() + let store = ChatFeatureStore(api: api) + await store.load() + let original = try XCTUnwrap(store.memory.first) + let first = await store.saveMemory(original, content: "Server update") + XCTAssertTrue(first) + let localDraft = "Keep my draft" + let stale = await store.saveMemory(original, content: localDraft) + XCTAssertFalse(stale) + XCTAssertTrue(store.error?.contains("Compare") == true) + let fetched = await store.latestMemory(original) + let latest = try XCTUnwrap(fetched) + XCTAssertEqual(latest.content, "Server update") + XCTAssertEqual(localDraft, "Keep my draft") + let accepted = await store.saveMemory(latest, content: localDraft) + XCTAssertTrue(accepted) + XCTAssertEqual(store.memory.first?.content, localDraft) + } + func testClosedStoreCannotReloadOrPreviewAccountData() async throws { + let (store, api) = try makeStore(); defer { api.cancel(); FeatureParityURLProtocol.handler = nil } + FeatureParityURLProtocol.handler = { _ in XCTFail("Closed account must not request private data"); return (200, Data()) } + store.close() + await store.load() + await store.preview(attachment()) + XCTAssertTrue(store.enabled.isEmpty) + XCTAssertNil(store.previewAttachment) + } +} diff --git a/ios/Tests/ChatParityTests.swift b/ios/Tests/ChatParityTests.swift new file mode 100644 index 0000000..6602e8c --- /dev/null +++ b/ios/Tests/ChatParityTests.swift @@ -0,0 +1,207 @@ +import XCTest +@testable import Voice + +private final class ParityChatAPI: ChatTransport { + struct Request { let path: String; let method: String; let body: [String: Any]?; let query: [URLQueryItem] } + var requests: [Request] = [] + var handler: ((Request) async throws -> Data?)? + private let fallback = OfflineChatAPI() + func request(_ path: String, method: String, body: [String: Any]?, query: [URLQueryItem]) async throws -> Data { + let request = Request(path: path, method: method, body: body, query: query) + requests.append(request) + if let data = try await handler?(request) { return data } + return try await fallback.request(path, method: method, body: body, query: query) + } + func events(run: String, after: Int, receive: @escaping @MainActor (ChatEvent) throws -> Void) async throws {} + func cancel() {} +} + +@MainActor +final class ChatParityTests: XCTestCase { + private func conversation(_ id: String, workspace: String? = nil, revision: Int = 1) -> HopeConversation { + HopeConversation(id: id, title: id, workspaceId: workspace, pinned: false, pinRank: 0, archived: false, temporary: false, revision: revision, createdAt: 1, updatedAt: 1) + } + private func encoded(_ value: [String: Any]) throws -> Data { try JSONSerialization.data(withJSONObject: value) } + private func projection(_ conversation: HopeConversation) throws -> Any { + let encoder = JSONEncoder(); encoder.keyEncodingStrategy = .convertToSnakeCase + return try JSONSerialization.jsonObject(with: encoder.encode(conversation)) + } + private func attachment(_ id: String, mime: String = "text/plain", status: String = "ready") -> ChatAttachment { + ChatAttachment(id: id, filename: "notes.txt", mime: mime, size: 12, extractionStatus: status, createdAt: 1) + } + func testWorkspaceFilterPrecedesPagingAndScopeChangeDropsCursor() async throws { + let api = ParityChatAPI(); let store = ChatStore(api: api) + api.handler = { request in + guard request.path == "api/conversations" else { return nil } + return Data("{\"conversations\":[],\"next_cursor\":\"page-two\"}".utf8) + } + await store.load(query: "search", archived: true, workspace: "writing") + await store.load(query: "search", archived: true, workspace: "writing", more: true) + await store.load(query: "search", archived: true, workspace: "different", more: true) + let lists = api.requests.filter { $0.path == "api/conversations" } + XCTAssertEqual(lists.count, 3) + XCTAssertEqual(lists[0].query.first { $0.name == "workspace_id" }?.value, "writing") + XCTAssertEqual(lists[1].query.first { $0.name == "cursor" }?.value, "page-two") + XCTAssertNil(lists[2].query.first { $0.name == "cursor" }) + } + func testSyncPreservesTwoLoadedPagesAndContinuationCursor() async throws { + let api = ParityChatAPI(); let store = ChatStore(api: api) + let first = conversation("first", workspace: "writing") + let older = conversation("older", workspace: "writing") + var changed = older; changed.title = "Renamed remotely"; changed.revision = 2 + api.handler = { [self] request in + if request.path == "api/conversations" { + let secondPage = request.query.contains { $0.name == "cursor" && $0.value == "page-two" } + return try encoded(["conversations": [projection(secondPage ? older : first)], "next_cursor": secondPage ? "page-three" : "page-two"]) + } + if request.path == "api/sync" { + return try encoded(["changes": [["id": changed.id, "kind": "conversation", "deleted": false, "data": projection(changed)]], "cursor": 1]) + } + return nil + } + await store.load(query: "message match", workspace: "writing") + await store.load(query: "message match", workspace: "writing", more: true) + XCTAssertEqual(store.conversations.map(\.id), [first.id, older.id]) + XCTAssertEqual(store.nextCursor, "page-three") + await store.synchronize() + XCTAssertEqual(store.conversations.map(\.id), [first.id, older.id]) + XCTAssertEqual(store.conversations.last?.title, "Renamed remotely") + XCTAssertEqual(store.nextCursor, "page-three") + XCTAssertEqual(api.requests.filter { $0.path == "api/conversations" }.count, 2) + } + func testOtherRowMutationPreservesComposerAndActiveRunAndScope() async throws { + let api = ParityChatAPI(); let store = ChatStore(api: api) + let selected = conversation("selected", workspace: "writing") + let other = conversation("other", workspace: "writing") + let moved = conversation("other", workspace: "elsewhere", revision: 2) + api.handler = { [self] request in + if request.method == "PATCH" { return try encoded(["conversation": projection(moved)]) } + if request.path == "api/conversations" { return Data("{\"conversations\":[],\"next_cursor\":null}".utf8) } + return nil + } + await store.load(workspace: "writing") + store.selected = selected; store.conversations = [selected, other]; store.draft = "Keep draft" + store.messages = [HopeMessage(id: "m", conversationId: selected.id, role: "assistant", content: "Streaming", status: "streaming", runId: "r", createdAt: 1)] + store.runs = [HopeRun(id: "r", conversationId: selected.id, status: "running", messageId: "m", createdAt: 1, updatedAt: 1, error: nil)] + XCTAssertFalse(store.canMutate(selected)); XCTAssertTrue(store.canMutate(other)) + await store.patch(other, changes: ["workspace_id": "elsewhere"]) + XCTAssertEqual(store.selected, selected); XCTAssertEqual(store.draft, "Keep draft") + XCTAssertEqual(store.messages.first?.content, "Streaming"); XCTAssertEqual(store.activeRun?.id, "r") + XCTAssertEqual(api.requests.last { $0.path == "api/conversations" }?.query.first { $0.name == "workspace_id" }?.value, "writing") + XCTAssertFalse(store.conversations.contains { $0.id == other.id }) + } + func testMutationCompletionSurvivesNavigationButNotAccountClose() async throws { + for close in [false, true] { + let api = ParityChatAPI(); let store = ChatStore(api: api) + await store.create(); let first = try XCTUnwrap(store.selected) + await store.create(); let second = try XCTUnwrap(store.selected) + var finish: CheckedContinuation? + var started: CheckedContinuation? + api.handler = { request in + if request.method == "PATCH" { + started?.resume(); started = nil + await withCheckedContinuation { finish = $0 } + } + return nil + } + let operation = Task { await store.patch(first, changes: ["title": "Renamed"]) } + await withCheckedContinuation { started = $0 } + if close { store.close() } else { await store.open(first); await store.open(second) } + finish?.resume(); await operation.value + if close { XCTAssertTrue(store.conversations.isEmpty); XCTAssertNil(store.selected) } + else { XCTAssertEqual(store.selected?.id, second.id); XCTAssertEqual(store.conversations.first { $0.id == first.id }?.title, "Renamed") } + } + } + func testStaleRevisionRefreshesMetadataOnlyAndRetryIsActionable() async throws { + let store = ChatStore(api: OfflineChatAPI()) + await store.create(); let stale = try XCTUnwrap(store.selected) + await store.patch(stale, changes: ["title": "Latest"]) + store.draft = "Private unsent draft" + await store.patch(stale, changes: ["title": "Stale overwrite"]) + XCTAssertEqual(store.selected?.title, "Latest"); XCTAssertEqual(store.draft, "Private unsent draft") + XCTAssertTrue(store.error?.contains("latest details are loaded") == true) + await store.patch(try XCTUnwrap(store.selected), changes: ["title": "Retry"]) + XCTAssertEqual(store.selected?.title, "Retry") + } + func testAttachmentQueueBindsComposerAndRejectsPendingAndUnsupportedFiles() async throws { + let store = ChatStore(api: OfflineChatAPI()); await store.create() + store.features.enabled = ["attachments"] + let epoch = store.draftGeneration + let accepted = await store.queueAttachment(attachment("a"), expectedDraftGeneration: epoch) + XCTAssertTrue(accepted) + let image = await store.queueAttachment(attachment("image", mime: "image/png", status: "image"), expectedDraftGeneration: epoch) + XCTAssertFalse(image) + let failed = await store.queueAttachment(attachment("failed", status: "encrypted"), expectedDraftGeneration: epoch) + XCTAssertFalse(failed) + for i in 1...7 { _ = await store.queueAttachment(attachment("extra-\(i)"), expectedDraftGeneration: epoch) } + let excess = await store.queueAttachment(attachment("ninth"), expectedDraftGeneration: epoch) + XCTAssertFalse(excess); XCTAssertEqual(store.selectedAttachmentIds.count, 8) + store.busy = true + let pending = await store.queueAttachment(attachment("pending"), expectedDraftGeneration: epoch) + XCTAssertFalse(pending); store.busy = false + await store.create() + let stale = await store.queueAttachment(attachment("stale"), expectedDraftGeneration: epoch) + XCTAssertFalse(stale); XCTAssertTrue(store.selectedAttachmentIds.isEmpty) + store.close() + let closed = await store.queueAttachment(attachment("closed")) + XCTAssertFalse(closed) + } + func testUnavailableAttachmentCanBeRemovedAndFreshQueueWins() async throws { + let store = ChatStore(api: OfflineChatAPI()); await store.create() + store.features.enabled = ["attachments"] + let current = attachment("a") + let accepted = await store.queueAttachment(current) + XCTAssertTrue(accepted) + store.features.attachments = [attachment("a", status: "failed")] + XCTAssertEqual(store.queuedAttachment("a")?.extractionStatus, "ready") + XCTAssertTrue(store.canSendAttachments) + store.selectedAttachmentIds.insert("missing") + XCTAssertNil(store.queuedAttachment("missing")) + XCTAssertFalse(store.canSendAttachments) + store.selectedAttachmentIds.remove("missing") + XCTAssertTrue(store.canSendAttachments) + store.close() + } + func testEditAndRegenerateResendExactOriginalAttachmentIDs() async throws { + for regenerate in [false, true] { + let api = ParityChatAPI(); let store = ChatStore(api: api) + await store.create(); store.draft = "Original"; await store.send() + let ids = ["00000000000000000000000000000001", "00000000000000000000000000000002"] + store.messages[0].attachments = ids + api.handler = { request in + if request.path.hasPrefix("api/attachments/") { + let id = String(request.path.split(separator: "/").last!) + return Data("{\"attachment\":{\"id\":\"\(id)\",\"filename\":\"notes.txt\",\"mime\":\"text/plain\",\"size\":12,\"extraction_status\":\"ready\",\"created_at\":1}}".utf8) + } + return nil + } + let target = try XCTUnwrap(regenerate ? store.messages.last : store.messages.first) + await store.branch(target, edited: regenerate ? nil : "Edited", regenerate: regenerate) + let resent = try XCTUnwrap(api.requests.last { $0.path.hasSuffix("/messages") }) + XCTAssertEqual(resent.body?["attachment_ids"] as? [String], ids) + } + } + func testModelSaveRefreshFailureDisablesImageCapability() async throws { + let api = ParityChatAPI(); let store = ChatStore(api: api) + await store.create() + store.features.enabled = ["attachments", "image_input"] + let image = attachment("qualified-image", mime: "image/png", status: "image") + let queued = await store.queueAttachment(image, expectedDraftGeneration: store.draftGeneration) + XCTAssertTrue(queued); XCTAssertTrue(store.canSendAttachments) + store.draft = "Keep this image draft" + api.handler = { request in + if request.path == "api/settings/model" { return Data("{\"settings\":{\"model\":\"auto\",\"thinking_level\":\"off\"},\"message\":null}".utf8) } + if request.path == "api/capabilities" { throw ChatFailure.status(503) } + return nil + } + await store.saveModel() + XCTAssertFalse(store.features.enabled.contains("image_input")) + XCTAssertTrue(api.requests.contains { $0.path == "api/capabilities" }) + XCTAssertFalse(store.canSendAttachments) + await store.send() + XCTAssertFalse(api.requests.contains { $0.path.hasSuffix("/messages") }) + XCTAssertEqual(store.draft, "Keep this image draft") + XCTAssertEqual(store.selectedAttachmentIds, Set([image.id])) + XCTAssertTrue(store.error?.contains("current model") == true) + } +} diff --git a/ios/UITests/ChatUITests.swift b/ios/UITests/ChatUITests.swift index ec34f70..1725488 100644 --- a/ios/UITests/ChatUITests.swift +++ b/ios/UITests/ChatUITests.swift @@ -18,7 +18,7 @@ final class ChatUITests: XCTestCase { XCTAssertFalse(app.buttons["End conversation"].exists) app.navigationBars.buttons.element(boundBy: 0).tap() app.buttons["Chat options"].tap() - app.buttons["Sign out"].tap() + app.buttons["Settings"].tap(); app.buttons["Sign out"].tap() XCTAssertTrue(app.secureTextFields["Password"].waitForExistence(timeout: 10)) XCTAssertFalse(app.staticTexts["Native offline text"].exists) } @@ -27,9 +27,9 @@ final class ChatUITests: XCTestCase { app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"] app.launch() XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10)) - app.buttons["Chat options"].tap(); app.buttons["Library and tools"].tap() - XCTAssertTrue(app.buttons["New artifact"].waitForExistence(timeout: 10)) - app.buttons["New artifact"].tap() + app.buttons["Chat options"].tap(); app.buttons["Library"].tap() + XCTAssertTrue(app.buttons["New saved draft"].waitForExistence(timeout: 10)) + app.buttons["New saved draft"].tap() XCTAssertTrue(app.textFields["Title"].waitForExistence(timeout: 10)) app.textFields["Title"].tap(); app.textFields["Title"].typeText("Local artifact") app.textViews.firstMatch.tap(); app.textViews.firstMatch.typeText("Native fixture content") @@ -43,6 +43,47 @@ final class ChatUITests: XCTestCase { app.buttons["Cancel"].tap() app.buttons["Done"].tap() } + func testDiscoverableConversationMenuLibraryMemorySecurityAndTasks() { + let app = XCUIApplication() + app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"] + app.launch() + XCTAssertTrue(app.buttons["New chat"].waitForExistence(timeout: 10)) + app.buttons["New chat"].tap() + let field = app.textFields["chat-draft"] + XCTAssertTrue(field.waitForExistence(timeout: 10)) + field.tap(); field.typeText("Parity fixture") + app.buttons["Send message"].tap() + XCTAssertTrue(app.staticTexts["Offline reply"].waitForExistence(timeout: 10)) + app.navigationBars.buttons.element(boundBy: 0).tap() + let actions = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Actions for '")).firstMatch + XCTAssertTrue(actions.waitForExistence(timeout: 10)); actions.tap() + XCTAssertTrue(app.buttons["Pin"].waitForExistence(timeout: 5)); app.buttons["Pin"].tap() + capture(app, "Native visible conversation actions") + app.buttons["Chat options"].tap(); app.buttons["Library"].tap() + XCTAssertTrue(app.buttons["Upload file"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["Saved drafts"].exists) + capture(app, "Native Library purpose and upload") + app.buttons["Done"].tap() + app.buttons["Chat options"].tap(); app.buttons["Memory & data"].tap() + XCTAssertTrue(app.buttons["Guidance"].waitForExistence(timeout: 10)) + capture(app, "Native Memory and data") + app.buttons["Guidance"].tap() + XCTAssertTrue(app.textViews["Memory draft"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["Compare latest server version"].exists) + capture(app, "Native memory revision editor") + app.buttons["Cancel"].tap(); app.buttons["Done"].tap() + app.buttons["Chat options"].tap(); app.buttons["Settings"].tap(); app.buttons["Security"].tap() + XCTAssertTrue(app.staticTexts["Connected server"].waitForExistence(timeout: 10)) + capture(app, "Native contextual Security") + app.buttons["Done"].tap() + app.tabBars.buttons["Activity"].tap(); app.buttons["Scheduled tasks"].tap() + XCTAssertTrue(app.buttons["New scheduled task"].waitForExistence(timeout: 10)) + app.buttons["New scheduled task"].tap() + XCTAssertTrue(app.textFields["Task name"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.switches["Create paused"].exists) + capture(app, "Native scheduled task fields") + } + private func capture(_ app: XCUIApplication, _ name: String) { let attachment = XCTAttachment(screenshot: app.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) } @@ -51,7 +92,7 @@ final class ChatUITests: XCTestCase { app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"] app.launch() XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10)) - app.buttons["Chat options"].tap(); app.buttons["Manage workspaces"].tap() + app.buttons["Chat options"].tap(); app.buttons["Settings"].tap(); app.buttons["Manage workspaces"].tap() let field = app.textFields["Workspace name"] XCTAssertTrue(field.waitForExistence(timeout: 10)); field.tap(); field.typeText("Writing") app.buttons["Save workspace"].tap() @@ -63,7 +104,7 @@ final class ChatUITests: XCTestCase { app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"] app.launch() XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10)) - app.buttons["Chat options"].tap(); app.buttons["Appearance and model"].tap() + app.buttons["Chat options"].tap(); app.buttons["Settings"].tap(); app.buttons["Appearance and model"].tap() app.buttons.matching(NSPredicate(format: "label CONTAINS 'Appearance' AND label CONTAINS 'System'")).firstMatch.tap(); app.buttons["Dark"].tap() app.buttons["Save appearance"].tap(); app.buttons["Done"].tap() capture(app, "Native chat list dark standard") @@ -81,7 +122,7 @@ final class ChatUITests: XCTestCase { app.launch() XCTAssertTrue(app.buttons["New chat"].waitForExistence(timeout: 10)) app.buttons["Chat options"].tap() - app.buttons["Appearance and model"].tap() + app.buttons["Settings"].tap(); app.buttons["Appearance and model"].tap() XCTAssertTrue(app.navigationBars["Chat preferences"].waitForExistence(timeout: 10)) XCTAssertTrue(app.buttons["Save appearance"].exists) capture(app, "Appearance accessibility size") diff --git a/ios/Voice.xcodeproj/project.pbxproj b/ios/Voice.xcodeproj/project.pbxproj index f6040f8..3ed2d7f 100644 --- a/ios/Voice.xcodeproj/project.pbxproj +++ b/ios/Voice.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262B5A712708421C35ED6AC0 /* AuthTests.swift */; }; 302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; }; 36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; }; + 3CDB3C606956A4D9906096D5 /* ChatParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FACE46B82B55BE02F40FAD2 /* ChatParityTests.swift */; }; 4AA89EFF10F261A3FCF8C366 /* ChatExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14505F4DA9DB1610B2EEB271 /* ChatExport.swift */; }; 4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC751BFB00B04C73D19C714 /* AuthClient.swift */; }; 57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */; }; @@ -25,6 +26,7 @@ 6488E54A6D93278C04D870E2 /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */; }; 70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */; }; 720C26EC163AE46713B02DD8 /* TLSPinning.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B40076934C28BA4B92D808 /* TLSPinning.swift */; }; + 784F521516838C0FFF168A1D /* ChatFeatureParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D39D32D45947915D82E963E /* ChatFeatureParityTests.swift */; }; 7A8CC686530A5920338EDF5A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 26922A8D6467BD51ED45B63B /* Assets.xcassets */; }; 89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */; }; 8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */; }; @@ -82,6 +84,7 @@ 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = ""; }; 41286AD7E2621F77B4F7CC85 /* ChatFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFeatures.swift; sourceTree = ""; }; 48F76881B4AE51DEE74511CC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 4FACE46B82B55BE02F40FAD2 /* ChatParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatParityTests.swift; sourceTree = ""; }; 536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainTokenStore.swift; sourceTree = ""; }; 5759EB570DB87BBC121D946D /* VoiceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceLiveTests.swift; sourceTree = ""; }; @@ -89,6 +92,7 @@ 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHistory.swift; sourceTree = ""; }; 6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = ""; }; 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = ""; }; + 6D39D32D45947915D82E963E /* ChatFeatureParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFeatureParityTests.swift; sourceTree = ""; }; 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceUITests.swift; sourceTree = ""; }; 87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightwellView.swift; sourceTree = ""; }; 96278769DD369C629F18E475 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; @@ -245,7 +249,9 @@ isa = PBXGroup; children = ( 262B5A712708421C35ED6AC0 /* AuthTests.swift */, + 6D39D32D45947915D82E963E /* ChatFeatureParityTests.swift */, F2623CA741424CA9B1B6BDA0 /* ChatLiveTests.swift */, + 4FACE46B82B55BE02F40FAD2 /* ChatParityTests.swift */, B5B373E4A57214C0D289536B /* ChatTests.swift */, C8F93A6F4B4354976953F69F /* ChatTransportTests.swift */, BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */, @@ -449,7 +455,9 @@ buildActionMask = 2147483647; files = ( 2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */, + 784F521516838C0FFF168A1D /* ChatFeatureParityTests.swift in Sources */, A4A97EAE381B922B213128F3 /* ChatLiveTests.swift in Sources */, + 3CDB3C606956A4D9906096D5 /* ChatParityTests.swift in Sources */, C28D28376A22BE3981908AC2 /* ChatTests.swift in Sources */, B0266A37FF81F7DF7F288B37 /* ChatTransportTests.swift in Sources */, FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */, diff --git a/ios/docs/PROJECT_STATUS.md b/ios/docs/PROJECT_STATUS.md index 138ebf1..5787206 100644 --- a/ios/docs/PROJECT_STATUS.md +++ b/ios/docs/PROJECT_STATUS.md @@ -1,6 +1,20 @@ # Project status and handoff -Last updated: 2026-10-04 (verified monorepo import; no new live chat/device qualification). +Last updated: 2026-10-04 (native parity qualified; signed update installed and launched on physical iPhone). + +## Native parity and physical update — 2026-10-04 + +Implemented visible 44pt conversation action menus (pin/rename/move/archive/delete), server-side workspace scope, independent row writes preserving other drafts/runs, and locks against the actual Talk-bound conversation. Library separates private files from saved drafts; upload/reuse explicitly queues to the original composer, never auto-sends. Missing historical files remain removable. Sending fails closed after model/capability changes; edit/regenerate preserves original attachment IDs with metadata validation. + +Scheduled tasks are discoverable from Chats and Activity, with instructions, optional repeat count, and Create paused enabled by default. Memory explains Guidance/About you versus history, retains conflict drafts, requires explicit revision review/acceptance, and confirms clearing. Security shows current server/account, Keychain/session semantics, and confirmed sign-out without fabricated 2FA/device management. The primary menu uses compact Settings/Filter submenus to remain reachable at accessibility text sizes. + +Independent source review identified and resolved actual voice-binding locks, background sync collapsing paginated lists, edits during memory writes, unsupported-image sending, and missing-file removal recovery. Added 16 focused parity tests. XcodeGen regenerated the project. + +Verification: iPhone 17 Pro simulator, iOS 26.4: 28 XCTest unit cases passed with one explicit live-chat skip, plus 60 Swift Testing cases passed; 13 UI tests passed (six chat and seven existing voice UI tests). Final task-label/paused-default polish was then rebuilt and the complete parity navigation UI case passed again. Artifacts: `/tmp/hope-ios-parity-final.xcresult`, `/tmp/hope-ios-parity-last-polish.xcresult`, exported screenshots under `/tmp/hope-ios-parity-final-screenshots` and `/tmp/hope-ios-parity-polish-screenshots`. Visually inspected Library, memory, Security and task editor screenshots; standard light/dark and accessibility sizes were exercised. + +Signed generic-iOS build succeeded using existing team/configuration; `codesign --verify --deep --strict` passed. App: `/tmp/hope-ios-parity-phone/Build/Products/Debug-iphoneos/Voice.app`. Initial install failed while phone was unavailable. After the user connected/unlocked the phone, CoreDevice installed `dev.shane.voice` on the physical iPhone 14 Pro Max and launched it successfully at 10:39. Receipts: `/tmp/hope-ios-parity-install-usb.json`, `/tmp/hope-ios-parity-phone-launch.json` and matching `.log` files. Installed as an update without uninstall, credentials reset, or UI-test launch arguments. + +Scope: this proves signed build, physical installation and app launch—not authenticated live feature API behavior or physical microphone/STT/TTS. No live test opt-in, NAS account data inspection, backend redeployment or runtime configuration changes were performed for this native task. Unrelated concurrent web/test/qualification edits and local design artifacts were excluded from native staging. ## Repository migration