feat(ios): align chat library memory and tasks with web
This commit is contained in:
1 parent
8905dc2c2a
commit
235dd65487
12 files changed
+957
-128
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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] }) {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 */,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user