feat(ios): align chat library memory and tasks with web

This commit is contained in:
snowspeeder committed 2026-10-04 10:41:17 -04:00
1 parent 8905dc2c2a
commit 235dd65487
12 files changed
+957 -128

No files matched your search

+3
View File
@@ -34,6 +34,9 @@
## Git and deployment ## Git and deployment
- The Gitea Actions runner runs in a Docker container on Unraid. SSH to `[email protected]` 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. - `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. - 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. - Use conventional commits and explicitly stage intended files; leave unrelated local design artifacts untouched.
+247 -58
View File
@@ -30,24 +30,94 @@ final class ChatFeatureStore: ObservableObject {
private let api: ChatTransport private let api: ChatTransport
private var epoch = 0 private var epoch = 0
private var closed = false private var closed = false
private var capabilityGeneration = 0
private var loadGeneration = 0
init(api: ChatTransport) { self.api = api } init(api: ChatTransport) { self.api = api }
private func call<T: Decodable>(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T { private func call<T: Decodable>(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T {
guard !closed else { throw CancellationError() }
let current = epoch let current = epoch
let data = try await api.request(path, method: method, body: body, query: query) let data = try await api.request(path, method: method, body: body, query: query)
guard !closed, current == epoch else { throw CancellationError() } guard !closed, current == epoch else { throw CancellationError() }
try Task.checkCancellation() try Task.checkCancellation()
return try ChatCoding.decoder.decode(T.self, from: data) 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 { do {
let caps: FeatureCapabilities = try await call("api/capabilities") let caps: FeatureCapabilities = try await call("api/capabilities")
guard generation == capabilityGeneration else { return false }
enabled = Set(caps.features.filter { $0.value.enabled }.map(\.key)) 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 } return true
if enabled.contains("artifacts") { struct Page: Decodable { let artifacts: [ChatArtifact] }; let page: Page = try await call("api/artifacts"); artifacts = page.artifacts } } catch {
if enabled.contains("scheduled_tasks") { struct Page: Decodable { let tasks: [ScheduledChatTask] }; let page: Page = try await call("api/tasks"); tasks = page.tasks } guard generation == capabilityGeneration else { return false }
if enabled.contains("memory_controls") { struct Page: Decodable { let sections: [ChatMemory] }; let page: Page = try await call("api/memory"); memory = page.sections } 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 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) { private func report(_ value: Error) {
guard !(value is CancellationError), !closed else { return } guard !(value is CancellationError), !closed else { return }
@@ -55,7 +125,9 @@ final class ChatFeatureStore: ObservableObject {
error = value.localizedDescription error = value.localizedDescription
} }
func upload(_ data: Data, filename: String, mime: String) async -> ChatAttachment? { 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 } guard data.count <= 8 * 1024 * 1024 else { error = "Choose a file smaller than 8 MiB."; return nil }
busy = true; defer { busy = false } busy = true; defer { busy = false }
do { do {
@@ -68,6 +140,7 @@ final class ChatFeatureStore: ObservableObject {
} catch { report(error); return nil } } catch { report(error); return nil }
} }
func preview(_ attachment: ChatAttachment) async { func preview(_ attachment: ChatAttachment) async {
guard !closed else { return }
previewAttachment = attachment previewAttachment = attachment
previewLoading = true previewLoading = true
previewError = nil 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 } 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 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 { do {
struct Result: Decodable { let task: ScheduledChatTask } 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 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) } } 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) } 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 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 { func latestMemory(_ item: ChatMemory) async -> ChatMemory? {
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) } 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 { struct ChatLibraryView: View {
@ObservedObject var store: ChatFeatureStore @ObservedObject var store: ChatFeatureStore
@Binding var selectedAttachmentIds: Set<String> var initialDestination: ChatLibraryDestination = .files
var selectedAttachmentIds: Set<String> = []
var onUseAttachment: ((ChatAttachment) async -> Bool)?
var onRemoveAttachment: ((String) -> Void)?
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var importing = false @State private var importing = false
@State private var photo: PhotosPickerItem? @State private var photo: PhotosPickerItem?
@@ -156,6 +254,15 @@ struct ChatLibraryView: View {
@State private var name = "" @State private var name = ""
@State private var schedule = "" @State private var schedule = ""
@State private var prompt = "" @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 memory: ChatMemory?
@State private var memoryContent = "" @State private var memoryContent = ""
@State private var deleting: (() async -> Void)? @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 let error = store.error { Section { Text(error).foregroundStyle(.red); Button("Retry") { Task { await store.load() } } } }
if loadingCapabilities { if loadingCapabilities {
ContentUnavailableView("Checking server capabilities", systemImage: "server.rack", description: Text("Only features your account supports are shown.")) ContentUnavailableView("Checking server capabilities", systemImage: "server.rack", description: Text("Only features your account supports are shown."))
} else if store.enabled.isEmpty { } 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("Library features aren’t available", systemImage: "lock", description: Text("Your server has not enabled private files, documents, schedules, or memory controls for this account.")) 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") { if initialDestination == .files && store.enabled.contains("attachments") {
Section("Files") { Section("Private files") {
Button("Import from Files", systemImage: "doc.badge.plus") { importing = true }.disabled(store.busy) Text("Upload private files, then use them in your next message. Nothing is sent automatically.").font(.footnote).foregroundStyle(.secondary)
if store.enabled.contains("image_input") { PhotosPicker(selection: $photo, matching: .images) { Label("Import photo", systemImage: "photo") }.disabled(store.busy) } 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 ForEach(store.attachments) { item in
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack { HStack {
Button { Text(item.filename).font(.body)
if selectedAttachmentIds.contains(item.id) { selectedAttachmentIds.remove(item.id) } Spacer()
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")))
Button("Preview") { showingPreview = true; Task { await store.preview(item) } }.frame(minHeight: 44) 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) 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) 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") { if initialDestination == .files && store.enabled.contains("artifacts") {
Section("Artifacts") { Section("Saved drafts") {
Button("New artifact", systemImage: "doc.text") { artifact = nil; title = ""; content = ""; kind = "markdown"; artifactEditor = true } 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) } } } } 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") { 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) 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 ForEach(store.tasks) { item in
VStack(alignment: .leading) { VStack(alignment: .leading) {
Button(item.name ?? item.id) { task = item; name = item.name ?? ""; schedule = item.schedule ?? ""; prompt = item.prompt ?? ""; taskEditor = true }.frame(minHeight: 44) 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.schedule ?? "Schedule not provided").font(.caption).foregroundStyle(.secondary) 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") } } } 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) } } } }.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteTask(item) } } }
} }
} }
} }
if store.enabled.contains("memory_controls") { if initialDestination == .memory && store.enabled.contains("memory_controls") {
Section("Account memory") { Section("Durable account memory") {
ForEach(store.memory) { item in Button(item.section.capitalized) { memory = item; memoryContent = item.content } } 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) 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() } } } .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
.task { await store.load(); loadingCapabilities = false } .task { await store.load(); loadingCapabilities = false }
.fileImporter(isPresented: $importing, allowedContentTypes: [.plainText, .pdf, .png, .jpeg, UTType(filenameExtension: "md") ?? .plainText, UTType(filenameExtension: "webp") ?? .image]) { result in .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 } guard size <= 8 * 1024 * 1024 else { store.error = "Choose a file smaller than 8 MiB."; return }
let mime: String 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" } 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." } } 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() }) { .sheet(isPresented: $showingPreview, onDismiss: { store.clearPreview() }) {
NavigationStack { NavigationStack {
ScrollView { ScrollView {
@@ -248,20 +373,7 @@ struct ChatLibraryView: View {
} }
.sheet(isPresented: $artifactEditor) { artifactForm } .sheet(isPresented: $artifactEditor) { artifactForm }
.sheet(isPresented: $taskEditor) { taskForm } .sheet(isPresented: $taskEditor) { taskForm }
.sheet(item: $memory) { item in .sheet(item: $memory) { item in memoryForm(item) }
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 } } } }
}
}
}
.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 } } .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) 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) } 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) } } .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 { 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) } } Form {
.navigationTitle(task == nil ? "New task" : "Edit task") Section("Your local draft") {
.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) } } 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 { struct PrivatePDFPreview: UIViewRepresentable {
+19 -3
View File
@@ -20,7 +20,22 @@ struct HopeMessage: Codable, Identifiable, Equatable {
var status: String var status: String
var runId: String? var runId: String?
var createdAt: Double 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 { struct HopeRun: Codable, Identifiable, Equatable {
var id: String 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()]) } 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 var item = body; item["id"] = UUID().uuidString; item["created_at"] = now; item["updated_at"] = now
if kind == "artifacts" { item["revision"] = 1 } 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) featureObjects[kind, default: []].insert(item, at: 0)
return try JSONSerialization.data(withJSONObject: [singular: item]) 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) } 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 q = query.first(where: { $0.name == "q" })?.value ?? ""
let archived = query.first(where: { $0.name == "archived" })?.value == "true" 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()]) 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] }) { if parts.count >= 3, parts[1] == "conversations", let index = conversations.firstIndex(where: { $0.id == parts[2] }) {
+135 -26
View File
@@ -17,6 +17,9 @@ final class ChatStore: ObservableObject {
@Published var olderMessagesCursor: String? @Published var olderMessagesCursor: String?
private var currentQuery = "" private var currentQuery = ""
private var currentArchived = false private var currentArchived = false
private var currentWorkspace: String?
@Published private(set) var mutatingConversationIds: Set<String> = []
private(set) var draftGeneration = 0
private var searchGeneration = 0 private var searchGeneration = 0
@Published var approval: (id: String, prompt: String)? @Published var approval: (id: String, prompt: String)?
@Published var researchAvailable = false @Published var researchAvailable = false
@@ -31,6 +34,22 @@ final class ChatStore: ObservableObject {
@Published var modelNote: String? @Published var modelNote: String?
let features: ChatFeatureStore let features: ChatFeatureStore
@Published var selectedAttachmentIds: Set<String> = [] @Published var selectedAttachmentIds: Set<String> = []
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 let api: ChatTransport
private var generation = 0 private var generation = 0
private var stream: Task<Void, Never>? private var stream: Task<Void, Never>?
@@ -43,11 +62,11 @@ final class ChatStore: ObservableObject {
init(api: ChatTransport) { self.api = api; self.features = ChatFeatureStore(api: api) } init(api: ChatTransport) { self.api = api; self.features = ChatFeatureStore(api: api) }
deinit { stream?.cancel(); syncTask?.cancel() } deinit { stream?.cancel(); syncTask?.cancel() }
private func call<T: Decodable>(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T { private func call<T: Decodable>(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = [], selectionBound: Bool = true) async throws -> T {
let epoch = generation let epoch = generation
let data = try await api.request(path, method: method, body: body, query: query) let data = try await api.request(path, method: method, body: body, query: query)
try Task.checkCancellation() 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) return try ChatCoding.decoder.decode(T.self, from: data)
} }
private func failure(_ value: Error) async { 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 } if case ChatFailure.status(let code) = value, code == 401 || code == 403 { await onUnauthorized?(); return }
error = value.localizedDescription 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 } guard !closed else { return }
currentQuery = query; currentArchived = archived let sameScope = currentQuery == query && currentArchived == archived && currentWorkspace == workspace
currentQuery = query; currentArchived = archived; currentWorkspace = workspace
searchGeneration += 1 searchGeneration += 1
let search = searchGeneration let search = searchGeneration
searching = true searching = true
defer { if search == searchGeneration { searching = false } } defer { if search == searchGeneration { searching = false } }
do { do {
var q = [URLQueryItem(name: "q", value: query), URLQueryItem(name: "archived", value: String(archived)), URLQueryItem(name: "limit", value: "50")] 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)) } if let workspace { q.append(URLQueryItem(name: "workspace_id", value: workspace)) }
let page: ConversationPage = try await call("api/conversations", query: q) 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 } 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 } else { conversations = page.conversations }
nextCursor = page.nextCursor nextCursor = page.nextCursor
let spaces: WorkspacePage = try await call("api/workspaces"); workspaces = spaces.workspaces let spaces: WorkspacePage = try await call("api/workspaces", selectionBound: false)
let prefs: PreferencesEnvelope = try await call("api/preferences"); preferences = prefs.preferences 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 error = nil
} catch { await failure(error) } } catch { await failure(error) }
} }
func open(_ conversation: HopeConversation) async { func open(_ conversation: HopeConversation) async {
guard !closed else { return }
draftGeneration += 1
if let selected { drafts[selected.id] = draft } if let selected { drafts[selected.id] = draft }
generation += 1; stream?.cancel(); stream = nil generation += 1; stream?.cancel(); stream = nil
selected = conversation; messages = []; runs = []; progress = nil; approval = nil; exportText = nil; exportFile = nil selected = conversation; messages = []; runs = []; progress = nil; approval = nil; exportText = nil; exportFile = nil
@@ -100,18 +125,26 @@ final class ChatStore: ObservableObject {
do { do {
var body: [String: Any] = [:]; if let workspace { body["workspace_id"] = workspace } var body: [String: Any] = [:]; if let workspace { body["workspace_id"] = workspace }
let response: ConversationEnvelope = try await call("api/conversations", method: "POST", body: body) 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) } } catch { await failure(error) }
} }
func send() async { 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) let content = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return } 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 } busy = true; error = nil; defer { busy = false }
let payloadIdentity = content + "\n" + selectedAttachmentIds.sorted().joined(separator: ",") + String(research && researchAvailable) let payloadIdentity = content + "\n" + selectedAttachmentIds.sorted().joined(separator: ",") + String(research && researchAvailable)
if pendingKey?.content != payloadIdentity { pendingKey = (payloadIdentity, UUID().uuidString) } if pendingKey?.content != payloadIdentity { pendingKey = (payloadIdentity, UUID().uuidString) }
do { 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"]) 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 = [] upsert(response.message); upsert(response.run); draft = ""; drafts[selected.id] = ""; pendingKey = nil; selectedAttachmentIds = []
if response.run.active { replay(response.run.id) } if response.run.active { replay(response.run.id) }
else { let detail: ConversationDetail = try await call("api/conversations/\(selected.id)"); messages = detail.messages } 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) } 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) } 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 { 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 var body = changes; body["revision"] = conversation.revision
do { do {
let response: ConversationEnvelope = try await call("api/conversations/\(conversation.id)", method: "PATCH", body: body) let response: ConversationEnvelope = try await call("api/conversations/\(conversation.id)", method: "PATCH", body: body, selectionBound: false)
if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { conversations[index] = response.conversation } updateMetadata(response.conversation)
if selected?.id == conversation.id { selected = 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) } } catch { await failure(error) }
} }
func delete(_ conversation: HopeConversation) async { func delete(_ conversation: HopeConversation) async {
guard canMutate(conversation) else { return }
mutatingConversationIds.insert(conversation.id)
defer { mutatingConversationIds.remove(conversation.id) }
do { do {
struct Result: Decodable { let ok: Bool } 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 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) } } 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 { 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 busy = true
do { do {
let target: HopeMessage let target: HopeMessage
@@ -202,10 +289,26 @@ final class ChatStore: ObservableObject {
if let edited { body["content"] = edited } if let edited { body["content"] = edited }
else if regenerate { body["content"] = target.content } else if regenerate { body["content"] = target.content }
let response: ConversationEnvelope = try await call("api/conversations/\(selected.id)/branch", method: "POST", body: body) 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 busy = false
if let edited { draft = edited; await send() } if let edited { draft = edited; selectedAttachmentIds = Set(target.attachments); await send() }
else if regenerate { draft = target.content; await send() } else if regenerate { draft = target.content; selectedAttachmentIds = Set(target.attachments); await send() }
} catch { busy = false; await failure(error) } } catch { busy = false; await failure(error) }
} }
@discardableResult @discardableResult
@@ -233,6 +336,8 @@ final class ChatStore: ObservableObject {
let _: Result = try await call("api/workspaces/\(workspace.id)", method: "DELETE") let _: Result = try await call("api/workspaces/\(workspace.id)", method: "DELETE")
workspaces.removeAll { $0.id == workspace.id } workspaces.removeAll { $0.id == workspace.id }
for i in conversations.indices where conversations[i].workspaceId == workspace.id { conversations[i].workspaceId = nil } 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) } } catch { await failure(error) }
} }
func savePreferences(palette: String, appearance: String) async { func savePreferences(palette: String, appearance: String) async {
@@ -246,7 +351,7 @@ final class ChatStore: ObservableObject {
} catch { modelNote = "Server model settings are not available." } } catch { modelNote = "Server model settings are not available." }
} }
func saveModel() async { 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) } catch { await failure(error) }
} }
func loadResearch() async { func loadResearch() async {
@@ -278,20 +383,20 @@ final class ChatStore: ObservableObject {
} }
} }
} }
private func synchronize() async { func synchronize() async {
do { do {
struct Change: Decodable { let id: String; let kind: String; let deleted: Bool; let data: HopeSyncData? } struct Change: Decodable { let id: String; let kind: String; let deleted: Bool; let data: HopeSyncData? }
struct Result: Decodable { let changes: [Change]; let cursor: Int } 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 syncCursor = result.cursor
guard !result.changes.isEmpty else { return } guard !result.changes.isEmpty else { return }
for change in result.changes { for change in result.changes {
if change.kind == "conversation" { if change.kind == "conversation" {
if change.deleted { if change.deleted {
conversations.removeAll { $0.id == change.id } 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 { } 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 let i = conversations.firstIndex(where: { $0.id == change.id }) { conversations[i] = value }
else if currentQuery.isEmpty { conversations.insert(value, at: 0) } 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) } } 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 }) { if let selected, result.changes.contains(where: { $0.id == selected.id && !$0.deleted }) {
let detail: ConversationDetail = try await call("api/conversations/\(selected.id)") let detail: ConversationDetail = try await call("api/conversations/\(selected.id)")
self.selected = detail.conversation 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) } } } catch { if case ChatFailure.status(let status) = error, status == 401 || status == 403 { await failure(error) } }
} }
/// Invalidate every outstanding callback before clearing account-visible content. /// 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 ChatModelChoice: Decodable, Identifiable { let id: String; let thinkingLevels: [String]? }
struct ChatSettingsValue: Decodable { let model: String; let thinkingLevel: String } struct ChatSettingsValue: Decodable { let model: String; let thinkingLevel: String }
+106 -32
View File
@@ -5,6 +5,12 @@ struct NativeChatView: View {
@ObservedObject var store: ChatStore @ObservedObject var store: ChatStore
let talk: (String) async -> Void let talk: (String) async -> Void
let logout: () 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 query = ""
@State private var archived = false @State private var archived = false
@State private var workspace: String? @State private var workspace: String?
@@ -27,13 +33,13 @@ struct NativeChatView: View {
if workspace != nil || archived { Section { Label(scopeLabel, systemImage: archived ? "archivebox" : "folder") } } if workspace != nil || archived { Section { Label(scopeLabel, systemImage: archived ? "archivebox" : "folder") } }
if !pinned.isEmpty { Section("Pinned") { rows(pinned) } } if !pinned.isEmpty { Section("Pinned") { rows(pinned) } }
if !recent.isEmpty { Section(archived ? "Archived" : "Recent chats") { rows(recent) } } 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) .scrollContentBackground(.hidden)
.background(HopeTheme.color(.ground, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal)) .background(HopeTheme.color(.ground, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
.navigationTitle("Chats") .navigationTitle("Chats")
.searchable(text: $query, prompt: "Search chats and messages") .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 {} do { try await Task.sleep(for: .milliseconds(250)); try Task.checkCancellation(); await refresh() } catch {}
} }
.refreshable { await refresh() } .refreshable { await refresh() }
@@ -42,15 +48,22 @@ struct NativeChatView: View {
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
Menu { Menu {
Toggle("Show archived", isOn: $archived) Menu("Settings", systemImage: "gearshape") {
Picker("Workspace", selection: $workspace) { Button("Appearance and model") { showPreferences = true }
Text("All chats").tag(nil as String?) Button("Security", systemImage: "lock.shield") { showSecurity = true }
ForEach(store.workspaces) { Text($0.name).tag(Optional($0.id)) } Button("Manage workspaces") { showWorkspaces = true }
Button("Sign out", role: .destructive) { Task { await logout() } }
} }
Button("Library and tools") { showLibrary = true } Menu("Filter chats", systemImage: "line.3.horizontal.decrease") {
Button("Manage workspaces") { showWorkspaces = true } Toggle("Show archived", isOn: $archived)
Button("Appearance and model") { showPreferences = true } Picker("Workspace", selection: $workspace) {
Button("Sign out", role: .destructive) { Task { await logout() } } 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) } } label: { Image(systemName: "line.3.horizontal.decrease.circle").frame(minWidth: 44, minHeight: 44) }
.accessibilityLabel("Chat options") .accessibilityLabel("Chat options")
} }
@@ -60,22 +73,32 @@ struct NativeChatView: View {
} }
} }
.navigationDestination(for: String.self) { _ in ChatDetailView(store: store, talk: talk) } .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: $showPreferences) { ChatPreferencesView(store: store) }
.sheet(isPresented: $showWorkspaces) { WorkspaceManagerView(store: store) } .sheet(isPresented: $showWorkspaces) { WorkspaceManagerView(store: store) }
.alert("Rename chat", isPresented: Binding(get: { rename != nil }, set: { if !$0 { rename = nil } })) { .alert("Rename chat", isPresented: Binding(get: { rename != nil }, set: { if !$0 { rename = nil } })) {
TextField("Title", text: $title) TextField("Title", text: $title)
Button("Cancel", role: .cancel) { rename = nil } 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) .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} }
.confirmationDialog("Delete this chat?", isPresented: Binding(get: { delete != nil }, set: { if !$0 { delete = nil } }), titleVisibility: .visible) { .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 } Button("Cancel", role: .cancel) { delete = nil }
} message: { Text("This removes the conversation, not your account memory.") } } 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 scopeLabel: String { archived ? "Archived chats" : (workspace.flatMap { id in store.workspaces.first(where: { $0.id == id })?.name } ?? "All chats") }
private var emptyTitle: String { private var emptyTitle: String {
if !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "No matching chats" } 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 pinned: [HopeConversation] { visible.filter(\.pinned).sorted { $0.pinRank < $1.pinRank } }
private var recent: [HopeConversation] { visible.filter { !$0.pinned } } 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 { @ViewBuilder private func rows(_ items: [HopeConversation]) -> some View {
ForEach(items) { conversation in ForEach(items) { conversation in
HStack(alignment: .center, spacing: 8) {
Button { Button {
Task { await store.open(conversation); path = [conversation.id] } Task { await store.open(conversation); path = [conversation.id] }
} label: { } label: {
@@ -104,16 +128,61 @@ struct NativeChatView: View {
}.font(.caption).foregroundStyle(.secondary) }.font(.caption).foregroundStyle(.secondary)
}.frame(minHeight: 44, alignment: .leading) }.frame(minHeight: 44, alignment: .leading)
} }
.contextMenu { .buttonStyle(.plain)
Button(conversation.pinned ? "Unpin" : "Pin", systemImage: conversation.pinned ? "pin.slash" : "pin") { Task { await store.patch(conversation, changes: ["pinned": !conversation.pinned]) } } .frame(maxWidth: .infinity, alignment: .leading)
Button("Rename", systemImage: "pencil") { title = conversation.title; rename = conversation } .contextMenu { conversationActions(conversation) }
Menu("Move to workspace") { Menu { conversationActions(conversation) } label: {
Button("Outside workspaces") { Task { await store.patch(conversation, changes: ["workspace_id": NSNull()]) } } Image(systemName: "ellipsis").frame(minWidth: 44, minHeight: 44).contentShape(Rectangle())
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 }
} }
.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 editedText = ""
@State private var follow = false @State private var follow = false
@State private var library = false @State private var library = false
@State private var attachmentGeneration = 0
@FocusState private var composerFocused: Bool
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
var body: some View { var body: some View {
ScrollViewReader { proxy in 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.researchAvailable { Toggle("Research with cited sources", isOn: $store.research).font(.footnote) }
if !store.selectedAttachmentIds.isEmpty { if !store.selectedAttachmentIds.isEmpty {
AttachmentSelectionSummary(store: store) 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) { HStack(alignment: .bottom) {
Button { library = true } label: { Image(systemName: "paperclip").frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Attach files") 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") 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) } 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) }.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 .sheet(item: $editing) { message in
NavigationStack { NavigationStack {
Form { Section("Edit message") { TextEditor(text: $editedText).frame(minHeight: 200) }; Text("A new branch preserves the original conversation.").font(.footnote).foregroundStyle(.secondary) } 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 { private struct AttachmentSelectionSummary: View {
@ObservedObject var store: ChatStore @ObservedObject var store: ChatStore
var selectedAttachments: [ChatAttachment] { store.features.attachments.filter { store.selectedAttachmentIds.contains($0.id) } }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Attached to next message (\(store.selectedAttachmentIds.count)/8)").font(.caption.weight(.semibold)) 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 { HStack {
Label(item.filename, systemImage: "doc").font(.caption).lineLimit(1) Label(store.queuedAttachment(id)?.filename ?? "Unavailable attachment", systemImage: "doc").font(.caption).lineLimit(1)
Spacer() 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")")
} }
} }
} }
@@ -60,6 +60,8 @@ struct ConversationView: View {
selectedTab = 0 selectedTab = 0
}, logout: { }, logout: {
await auth.logout { chat.close(); await session.disconnect() } 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") } .tabItem { Label("Chats", systemImage: "bubble.left.and.text.bubble.right") }
.tag(2) .tag(2)
@@ -83,7 +85,7 @@ struct ConversationView: View {
.tabItem { Label("Talk", systemImage: "bubble.left.and.bubble.right") } .tabItem { Label("Talk", systemImage: "bubble.left.and.bubble.right") }
.tag(0) .tag(0)
ActivityTab(taskStore: session.taskStore, settings: settings, session: session) ActivityTab(taskStore: session.taskStore, settings: settings, session: session, features: chat.features)
.tag(1) .tag(1)
} }
.tint(HopeTheme.color(.accent, palette: HopePalette(rawValue: chat.preferences.palette) ?? .opal)) .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 taskStore: TaskStore
@ObservedObject var settings: SettingsStore @ObservedObject var settings: SettingsStore
@ObservedObject var session: VoiceSession @ObservedObject var session: VoiceSession
@ObservedObject var features: ChatFeatureStore
@State private var showSchedules = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
TaskPanelView(taskStore: taskStore) 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 { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
NavigationLink { NavigationLink {
+3
View File
@@ -112,6 +112,9 @@ final class VoiceSession: ObservableObject {
NotificationCenter.default.removeObserver(self) 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. /// Explicit chat-to-Talk navigation tears down the prior room first.
func bindConversation(_ id: String?) async { func bindConversation(_ id: String?) async {
await disconnect() await disconnect()
+156
View File
@@ -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<Data, Error>?
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)
}
}
+207
View File
@@ -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<Void, Never>?
var started: CheckedContinuation<Void, Never>?
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)
}
}
+48 -7
View File
@@ -18,7 +18,7 @@ final class ChatUITests: XCTestCase {
XCTAssertFalse(app.buttons["End conversation"].exists) XCTAssertFalse(app.buttons["End conversation"].exists)
app.navigationBars.buttons.element(boundBy: 0).tap() app.navigationBars.buttons.element(boundBy: 0).tap()
app.buttons["Chat options"].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)) XCTAssertTrue(app.secureTextFields["Password"].waitForExistence(timeout: 10))
XCTAssertFalse(app.staticTexts["Native offline text"].exists) 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.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
app.launch() app.launch()
XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10)) XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10))
app.buttons["Chat options"].tap(); app.buttons["Library and tools"].tap() app.buttons["Chat options"].tap(); app.buttons["Library"].tap()
XCTAssertTrue(app.buttons["New artifact"].waitForExistence(timeout: 10)) XCTAssertTrue(app.buttons["New saved draft"].waitForExistence(timeout: 10))
app.buttons["New artifact"].tap() app.buttons["New saved draft"].tap()
XCTAssertTrue(app.textFields["Title"].waitForExistence(timeout: 10)) XCTAssertTrue(app.textFields["Title"].waitForExistence(timeout: 10))
app.textFields["Title"].tap(); app.textFields["Title"].typeText("Local artifact") app.textFields["Title"].tap(); app.textFields["Title"].typeText("Local artifact")
app.textViews.firstMatch.tap(); app.textViews.firstMatch.typeText("Native fixture content") 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["Cancel"].tap()
app.buttons["Done"].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) { private func capture(_ app: XCUIApplication, _ name: String) {
let attachment = XCTAttachment(screenshot: app.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) 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.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
app.launch() app.launch()
XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10)) 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"] let field = app.textFields["Workspace name"]
XCTAssertTrue(field.waitForExistence(timeout: 10)); field.tap(); field.typeText("Writing") XCTAssertTrue(field.waitForExistence(timeout: 10)); field.tap(); field.typeText("Writing")
app.buttons["Save workspace"].tap() 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.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
app.launch() app.launch()
XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10)) 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.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() app.buttons["Save appearance"].tap(); app.buttons["Done"].tap()
capture(app, "Native chat list dark standard") capture(app, "Native chat list dark standard")
@@ -81,7 +122,7 @@ final class ChatUITests: XCTestCase {
app.launch() app.launch()
XCTAssertTrue(app.buttons["New chat"].waitForExistence(timeout: 10)) XCTAssertTrue(app.buttons["New chat"].waitForExistence(timeout: 10))
app.buttons["Chat options"].tap() 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.navigationBars["Chat preferences"].waitForExistence(timeout: 10))
XCTAssertTrue(app.buttons["Save appearance"].exists) XCTAssertTrue(app.buttons["Save appearance"].exists)
capture(app, "Appearance accessibility size") capture(app, "Appearance accessibility size")
+8
View File
@@ -16,6 +16,7 @@
2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262B5A712708421C35ED6AC0 /* AuthTests.swift */; }; 2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262B5A712708421C35ED6AC0 /* AuthTests.swift */; };
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; }; 302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; };
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.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 */; }; 4AA89EFF10F261A3FCF8C366 /* ChatExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14505F4DA9DB1610B2EEB271 /* ChatExport.swift */; };
4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC751BFB00B04C73D19C714 /* AuthClient.swift */; }; 4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC751BFB00B04C73D19C714 /* AuthClient.swift */; };
57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8309AC6885ED7EB0047AE211 /* VoiceUITests.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 */; }; 6488E54A6D93278C04D870E2 /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */; };
70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */; }; 70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */; };
720C26EC163AE46713B02DD8 /* TLSPinning.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B40076934C28BA4B92D808 /* TLSPinning.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 */; }; 7A8CC686530A5920338EDF5A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 26922A8D6467BD51ED45B63B /* Assets.xcassets */; };
89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */; }; 89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */; };
8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.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 = "<group>"; }; 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = "<group>"; };
41286AD7E2621F77B4F7CC85 /* ChatFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFeatures.swift; sourceTree = "<group>"; }; 41286AD7E2621F77B4F7CC85 /* ChatFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFeatures.swift; sourceTree = "<group>"; };
48F76881B4AE51DEE74511CC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 48F76881B4AE51DEE74511CC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
4FACE46B82B55BE02F40FAD2 /* ChatParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatParityTests.swift; sourceTree = "<group>"; };
536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainTokenStore.swift; sourceTree = "<group>"; }; 536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainTokenStore.swift; sourceTree = "<group>"; };
5759EB570DB87BBC121D946D /* VoiceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceLiveTests.swift; sourceTree = "<group>"; };
@@ -89,6 +92,7 @@
68A45228EF59186CFC219F2E /* TranscriptHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHistory.swift; sourceTree = "<group>"; }; 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHistory.swift; sourceTree = "<group>"; };
6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = "<group>"; }; 6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = "<group>"; };
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; }; 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; };
6D39D32D45947915D82E963E /* ChatFeatureParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFeatureParityTests.swift; sourceTree = "<group>"; };
8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceUITests.swift; sourceTree = "<group>"; }; 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceUITests.swift; sourceTree = "<group>"; };
87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightwellView.swift; sourceTree = "<group>"; }; 87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightwellView.swift; sourceTree = "<group>"; };
96278769DD369C629F18E475 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; }; 96278769DD369C629F18E475 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
@@ -245,7 +249,9 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
262B5A712708421C35ED6AC0 /* AuthTests.swift */, 262B5A712708421C35ED6AC0 /* AuthTests.swift */,
6D39D32D45947915D82E963E /* ChatFeatureParityTests.swift */,
F2623CA741424CA9B1B6BDA0 /* ChatLiveTests.swift */, F2623CA741424CA9B1B6BDA0 /* ChatLiveTests.swift */,
4FACE46B82B55BE02F40FAD2 /* ChatParityTests.swift */,
B5B373E4A57214C0D289536B /* ChatTests.swift */, B5B373E4A57214C0D289536B /* ChatTests.swift */,
C8F93A6F4B4354976953F69F /* ChatTransportTests.swift */, C8F93A6F4B4354976953F69F /* ChatTransportTests.swift */,
BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */, BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */,
@@ -449,7 +455,9 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */, 2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */,
784F521516838C0FFF168A1D /* ChatFeatureParityTests.swift in Sources */,
A4A97EAE381B922B213128F3 /* ChatLiveTests.swift in Sources */, A4A97EAE381B922B213128F3 /* ChatLiveTests.swift in Sources */,
3CDB3C606956A4D9906096D5 /* ChatParityTests.swift in Sources */,
C28D28376A22BE3981908AC2 /* ChatTests.swift in Sources */, C28D28376A22BE3981908AC2 /* ChatTests.swift in Sources */,
B0266A37FF81F7DF7F288B37 /* ChatTransportTests.swift in Sources */, B0266A37FF81F7DF7F288B37 /* ChatTransportTests.swift in Sources */,
FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */, FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */,
+15 -1
View File
@@ -1,6 +1,20 @@
# Project status and handoff # 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 ## Repository migration