diff --git a/Resources/Info.plist b/Resources/Info.plist
index 93b0c77..9172047 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -5,7 +5,7 @@
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
- Hope Voice
+ Hope
CFBundleExecutable
$(EXECUTABLE_NAME)
CFBundleIdentifier
diff --git a/Sources/Chat/ChatFeatures.swift b/Sources/Chat/ChatFeatures.swift
new file mode 100644
index 0000000..1ec966a
--- /dev/null
+++ b/Sources/Chat/ChatFeatures.swift
@@ -0,0 +1,236 @@
+import SwiftUI
+import UniformTypeIdentifiers
+import PhotosUI
+import PDFKit
+
+struct ChatAttachment: Codable, Identifiable { var id: String; var filename: String; var mime: String; var size: Int; var extractionStatus: String; var createdAt: Double }
+struct ChatArtifact: Codable, Identifiable { var id: String; var title: String; var kind: String; var revision: Int; var content: String?; var createdAt: Double; var updatedAt: Double }
+struct ScheduledChatTask: Codable, Identifiable { var id: String; var name: String?; var schedule: String?; var prompt: String?; var enabled: Bool?; var `repeat`: Int? }
+struct ChatMemory: Codable, Identifiable { var section: String; var content: String; var revision: String; var id: String { section } }
+struct FeatureCapabilities: Decodable { let features: [String: BoolValue] }
+/// Capabilities may carry limits/strings alongside bool flags; only explicit true enables UI.
+struct BoolValue: Decodable { let enabled: Bool; init(from decoder: Decoder) throws { enabled = (try? decoder.singleValueContainer().decode(Bool.self)) == true } }
+
+@MainActor
+final class ChatFeatureStore: ObservableObject {
+ @Published var enabled: Set = []
+ @Published var attachments: [ChatAttachment] = []
+ @Published var artifacts: [ChatArtifact] = []
+ @Published var tasks: [ScheduledChatTask] = []
+ @Published var memory: [ChatMemory] = []
+ @Published var error: String?
+ @Published var busy = false
+ @Published var previewText: String?
+ @Published var previewImage: UIImage?
+ @Published var previewPDF: Data?
+ var onUnauthorized: (() async -> Void)?
+ private let api: ChatTransport
+ private var epoch = 0
+ private var closed = false
+ init(api: ChatTransport) { self.api = api }
+ private func call(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T {
+ let current = epoch
+ let data = try await api.request(path, method: method, body: body, query: query)
+ guard !closed, current == epoch else { throw CancellationError() }
+ try Task.checkCancellation()
+ return try ChatCoding.decoder.decode(T.self, from: data)
+ }
+ func load() async {
+ do {
+ let caps: FeatureCapabilities = try await call("api/capabilities")
+ enabled = Set(caps.features.filter { $0.value.enabled }.map(\.key))
+ if enabled.contains("attachments") { struct Page: Decodable { let attachments: [ChatAttachment] }; let page: Page = try await call("api/attachments"); attachments = page.attachments }
+ if enabled.contains("artifacts") { struct Page: Decodable { let artifacts: [ChatArtifact] }; let page: Page = try await call("api/artifacts"); artifacts = page.artifacts }
+ if enabled.contains("scheduled_tasks") { struct Page: Decodable { let tasks: [ScheduledChatTask] }; let page: Page = try await call("api/tasks"); tasks = page.tasks }
+ if enabled.contains("memory_controls") { struct Page: Decodable { let sections: [ChatMemory] }; let page: Page = try await call("api/memory"); memory = page.sections }
+ error = nil
+ } catch { report(error) }
+ }
+ private func report(_ value: Error) {
+ guard !(value is CancellationError), !closed else { return }
+ if case ChatFailure.status(let code) = value, code == 401 || code == 403 { Task { await onUnauthorized?() }; return }
+ error = value.localizedDescription
+ }
+ func upload(_ data: Data, filename: String, mime: String) async -> ChatAttachment? {
+ guard !busy, enabled.contains("attachments") else { return nil }
+ guard data.count <= 8 * 1024 * 1024 else { error = "Choose a file smaller than 8 MiB."; return nil }
+ busy = true; defer { busy = false }
+ do {
+ let current = epoch
+ struct Envelope: Decodable { let attachment: ChatAttachment }
+ let bytes = try await api.upload(data: data, filename: filename, mime: mime)
+ guard current == epoch, !closed else { return nil }
+ let value = try ChatCoding.decoder.decode(Envelope.self, from: bytes).attachment
+ attachments.insert(value, at: 0); error = nil; return value
+ } catch { report(error); return nil }
+ }
+ func preview(_ attachment: ChatAttachment) async {
+ do {
+ let current = epoch
+ let data = try await api.request("api/attachments/\(attachment.id)/content", method: "GET", body: nil, query: [])
+ guard current == epoch, !closed else { return }
+ previewPDF = nil; previewText = nil; previewImage = nil
+ if attachment.mime.hasPrefix("image/") { previewImage = UIImage(data: data); previewText = nil }
+ else if attachment.mime == "application/pdf" { previewPDF = data; previewText = nil; previewImage = nil }
+ else { previewText = String(data: data, encoding: .utf8); previewImage = nil }
+ } catch { report(error) }
+ }
+ func deleteAttachment(_ item: ChatAttachment) async {
+ await remove("api/attachments/\(item.id)"); if error == nil { attachments.removeAll { $0.id == item.id } }
+ }
+ private func remove(_ path: String) async {
+ do { struct Result: Decodable { let ok: Bool }; let _: Result = try await call(path, method: "DELETE"); error = nil } catch { report(error) }
+ }
+ func saveArtifact(_ item: ChatArtifact?, title: String, kind: String, content: String) async {
+ do {
+ var body: [String: Any] = ["title": title, "kind": kind, "content": content]
+ if let item { body["revision"] = item.revision }
+ struct Result: Decodable { let artifact: ChatArtifact }
+ let result: Result = try await call(item.map { "api/artifacts/\($0.id)" } ?? "api/artifacts", method: item == nil ? "POST" : "PATCH", body: body)
+ if let i = artifacts.firstIndex(where: { $0.id == result.artifact.id }) { artifacts[i] = result.artifact } else { artifacts.insert(result.artifact, at: 0) }; error = nil
+ } catch { report(error) }
+ }
+ func detail(_ item: ChatArtifact) async -> ChatArtifact? {
+ do { struct Result: Decodable { let artifact: ChatArtifact }; let result: Result = try await call("api/artifacts/\(item.id)"); return result.artifact } catch { report(error); return nil }
+ }
+ func historical(_ item: ChatArtifact, revision: Int) async -> ChatArtifact? {
+ do { struct Result: Decodable { let artifact: ChatArtifact }; let result: Result = try await call("api/artifacts/\(item.id)", query: [URLQueryItem(name: "revision", value: String(revision))]); return result.artifact } catch { report(error); return nil }
+ }
+ func deleteArtifact(_ item: ChatArtifact) async { await remove("api/artifacts/\(item.id)"); if error == nil { artifacts.removeAll { $0.id == item.id } } }
+ func saveTask(_ item: ScheduledChatTask?, name: String, schedule: String, prompt: String) async {
+ do {
+ struct Result: Decodable { let task: ScheduledChatTask }
+ let result: Result = try await call(item.map { "api/tasks/\($0.id)" } ?? "api/tasks", method: item == nil ? "POST" : "PATCH", body: ["name": name, "schedule": schedule, "prompt": prompt])
+ 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) }
+ }
+ func taskAction(_ item: ScheduledChatTask, action: String) async {
+ do { struct Result: Decodable { let task: ScheduledChatTask }; let result: Result = try await call("api/tasks/\(item.id)/\(action)", method: "POST"); if let i = tasks.firstIndex(where: { $0.id == item.id }) { tasks[i] = result.task }; error = nil } catch { report(error) }
+ }
+ func deleteTask(_ item: ScheduledChatTask) async { await remove("api/tasks/\(item.id)"); if error == nil { tasks.removeAll { $0.id == item.id } } }
+ func saveMemory(_ item: ChatMemory, content: String) async {
+ do { let result: ChatMemory = try await call("api/memory/\(item.section)", method: "PUT", body: ["revision": item.revision, "content": content]); if let i = memory.firstIndex(where: { $0.id == item.id }) { memory[i] = result }; error = nil } catch { report(error) }
+ }
+ func close() { closed = true; epoch += 1; enabled = []; attachments = []; artifacts = []; tasks = []; memory = []; previewText = nil; previewImage = nil; previewPDF = nil; error = nil }
+}
+
+struct ChatLibraryView: View {
+ @ObservedObject var store: ChatFeatureStore
+ @Binding var selectedAttachmentIds: Set
+ @Environment(\.dismiss) private var dismiss
+ @State private var importing = false
+ @State private var photo: PhotosPickerItem?
+ @State private var showingPreview = false
+ @State private var artifact: ChatArtifact?
+ @State private var artifactEditor = false
+ @State private var title = ""
+ @State private var kind = "markdown"
+ @State private var content = ""
+ @State private var task: ScheduledChatTask?
+ @State private var taskEditor = false
+ @State private var name = ""
+ @State private var schedule = ""
+ @State private var prompt = ""
+ @State private var memory: ChatMemory?
+ @State private var memoryContent = ""
+ @State private var deleting: (() async -> Void)?
+ var body: some View {
+ NavigationStack {
+ List {
+ if let error = store.error { Section { Text(error).foregroundStyle(.red); Button("Retry") { Task { await store.load() } } } }
+ if store.enabled.isEmpty { ContentUnavailableView("Checking server capabilities", systemImage: "server.rack", description: Text("Only features your account supports are shown.")) }
+ if store.enabled.contains("attachments") {
+ Section("Files") {
+ Button("Import from Files", systemImage: "doc.badge.plus") { importing = true }.disabled(store.busy)
+ if store.enabled.contains("image_input") { PhotosPicker(selection: $photo, matching: .images) { Label("Import photo", systemImage: "photo") }.disabled(store.busy) }
+ ForEach(store.attachments) { item in
+ VStack(alignment: .leading) {
+ HStack {
+ Button { if selectedAttachmentIds.contains(item.id) { selectedAttachmentIds.remove(item.id) } else if selectedAttachmentIds.count < 8 { selectedAttachmentIds.insert(item.id) } } 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") { Task { await store.preview(item); showingPreview = true } }.frame(minHeight: 44)
+ }
+ Text("\(ByteCountFormatter.string(fromByteCount: Int64(item.size), countStyle: .file)) · \(item.extractionStatus)").font(.caption).foregroundStyle(.secondary)
+ }.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteAttachment(item); selectedAttachmentIds.remove(item.id) } } }
+ }
+ Text("Select up to 8 files for the next message. Uploading alone does not send them.").font(.footnote).foregroundStyle(.secondary)
+ }
+ }
+ if store.enabled.contains("artifacts") {
+ Section("Artifacts") {
+ Button("New artifact", systemImage: "doc.text") { artifact = nil; title = ""; content = ""; kind = "markdown"; artifactEditor = true }
+ 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") {
+ Section("Scheduled tasks") {
+ Text("Tasks run on your Hope server. Push notifications and task output history are not available in this app.").font(.footnote).foregroundStyle(.secondary)
+ Button("New scheduled task", systemImage: "calendar.badge.plus") { task = nil; name = ""; schedule = ""; prompt = ""; taskEditor = true }
+ ForEach(store.tasks) { item in
+ VStack(alignment: .leading) {
+ Button(item.name ?? item.id) { task = item; name = item.name ?? ""; schedule = item.schedule ?? ""; prompt = item.prompt ?? ""; taskEditor = true }.frame(minHeight: 44)
+ Text(item.schedule ?? "Schedule not provided").font(.caption).foregroundStyle(.secondary)
+ HStack { Button(item.enabled == false ? "Resume" : "Pause") { Task { await store.taskAction(item, action: item.enabled == false ? "resume" : "pause") } }; Button("Run now") { Task { await store.taskAction(item, action: "run") } } }
+ }.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteTask(item) } } }
+ }
+ }
+ }
+ if store.enabled.contains("memory_controls") {
+ Section("Account memory") {
+ ForEach(store.memory) { item in Button(item.section.capitalized) { memory = item; memoryContent = item.content } }
+ 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")
+ .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
+ .task { await store.load() }
+ .fileImporter(isPresented: $importing, allowedContentTypes: [.plainText, .pdf, .png, .jpeg, UTType(filenameExtension: "md") ?? .plainText, UTType(filenameExtension: "webp") ?? .image]) { result in
+ if case .success(let url) = result { Task {
+ let scoped = url.startAccessingSecurityScopedResource(); defer { if scoped { url.stopAccessingSecurityScopedResource() } }
+ do {
+ let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
+ guard size <= 8 * 1024 * 1024 else { store.error = "Choose a file smaller than 8 MiB."; return }
+ let mime: String
+ switch url.pathExtension.lowercased() { case "md": mime = "text/markdown"; case "pdf": mime = "application/pdf"; case "png": mime = "image/png"; case "jpg", "jpeg": mime = "image/jpeg"; case "webp": mime = "image/webp"; default: mime = "text/plain" }
+ _ = await store.upload(try Data(contentsOf: url), filename: url.lastPathComponent, mime: mime)
+ } 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 } }
+ .sheet(isPresented: $showingPreview) { NavigationStack { ScrollView { if let pdf = store.previewPDF { PrivatePDFPreview(data: pdf).frame(minHeight: 500) }; if let image = store.previewImage { Image(uiImage: image).resizable().scaledToFit() }; if let text = store.previewText { Text(text).textSelection(.enabled).padding() } }.navigationTitle("Private preview").toolbar { Button("Done") { showingPreview = false; store.previewImage = nil; store.previewText = nil; store.previewPDF = nil } } } }
+ .sheet(isPresented: $artifactEditor) { artifactForm }
+ .sheet(isPresented: $taskEditor) { taskForm }
+ .sheet(item: $memory) { item in NavigationStack { Form { TextEditor(text: $memoryContent).frame(minHeight: 260); Text("An empty value clears this section. Existing sessions may cache earlier memory.").font(.footnote) }.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 } }
+ }
+ }
+ private var artifactForm: some View {
+ NavigationStack {
+ Form {
+ TextField("Title", text: $title)
+ Picker("Kind", selection: $kind) { ForEach(["markdown", "text", "code"], id: \.self) { Text($0.capitalized).tag($0) } }
+ TextEditor(text: $content).frame(minHeight: 220)
+ if let artifact {
+ ShareLink("Export text", item: content)
+ if artifact.revision > 1 { Menu("View earlier revision") { ForEach(1.. PDFView { let view = PDFView(); view.autoScales = true; view.document = PDFDocument(data: data); return view }
+ func updateUIView(_ view: PDFView, context: Context) { if view.document?.dataRepresentation() != data { view.document = PDFDocument(data: data) } }
+}
diff --git a/Sources/Chat/ChatModels.swift b/Sources/Chat/ChatModels.swift
new file mode 100644
index 0000000..76d037e
--- /dev/null
+++ b/Sources/Chat/ChatModels.swift
@@ -0,0 +1,269 @@
+import Foundation
+
+struct HopeConversation: Codable, Identifiable, Equatable {
+ var id: String
+ var title: String
+ var workspaceId: String?
+ var pinned: Bool
+ var pinRank: Int
+ var archived: Bool
+ var temporary: Bool
+ var revision: Int
+ var createdAt: Double
+ var updatedAt: Double
+}
+struct HopeMessage: Codable, Identifiable, Equatable {
+ var id: String
+ var conversationId: String
+ var role: String
+ var content: String
+ var status: String
+ var runId: String?
+ var createdAt: Double
+ // Attachments are deliberately unavailable until their contract is qualified.
+}
+struct HopeRun: Codable, Identifiable, Equatable {
+ var id: String
+ var conversationId: String
+ var status: String
+ var messageId: String
+ var createdAt: Double
+ var updatedAt: Double
+ var error: String?
+ var active: Bool { ["queued", "running", "awaiting_approval"].contains(status) }
+}
+struct HopeWorkspace: Codable, Identifiable, Equatable {
+ var id: String
+ var name: String
+ var instructions: String
+ var revision: Int
+ var createdAt: Double
+ var updatedAt: Double
+}
+struct HopePreferences: Codable, Equatable {
+ var palette: String = "opal"
+ var appearance: String = "system"
+ var revision: Int = 0
+}
+struct ConversationPage: Decodable { let conversations: [HopeConversation]; let nextCursor: String? }
+struct ConversationDetail: Decodable { let conversation: HopeConversation; let messages: [HopeMessage]; let runs: [HopeRun]; let nextCursor: String? }
+struct ConversationEnvelope: Decodable { let conversation: HopeConversation }
+struct WorkspaceEnvelope: Decodable { let workspace: HopeWorkspace }
+struct WorkspacePage: Decodable { let workspaces: [HopeWorkspace] }
+struct PreferencesEnvelope: Decodable { let preferences: HopePreferences }
+struct SendEnvelope: Decodable { let message: HopeMessage; let run: HopeRun }
+struct RunEnvelope: Decodable { let run: HopeRun }
+struct RunDetail: Decodable { let run: HopeRun; let messages: [HopeMessage] }
+struct ChatEvent: Equatable { let id: Int; let name: String; let data: Data }
+
+/// Incremental SSE framing. A cursor advances only after a complete event is applied.
+struct SSEParser {
+ private var id = 0
+ private var name = "message"
+ private var lines: [String] = []
+ mutating func consume(_ line: String) -> ChatEvent? {
+ if line.isEmpty {
+ defer { id = 0; name = "message"; lines = [] }
+ guard id > 0, !lines.isEmpty else { return nil }
+ return ChatEvent(id: id, name: name, data: Data(lines.joined(separator: "\n").utf8))
+ }
+ if line.hasPrefix(":") { return nil }
+ let pair = line.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
+ let value = pair.count > 1 ? String(pair[1]).replacingOccurrences(of: "^ ", with: "", options: .regularExpression) : ""
+ switch pair.first { case "id": id = Int(value) ?? 0; case "event": name = value; case "data": lines.append(value); default: break }
+ return nil
+ }
+}
+
+enum ChatCoding {
+ static var decoder: JSONDecoder { let d = JSONDecoder(); d.keyDecodingStrategy = .convertFromSnakeCase; return d }
+ static func body(_ value: [String: Any]) throws -> Data { try JSONSerialization.data(withJSONObject: value) }
+}
+
+enum ChatFailure: LocalizedError {
+ case status(Int), invalidResponse, unavailable
+ var errorDescription: String? {
+ switch self {
+ case .status(409): return "This chat changed elsewhere. Refresh and try again."
+ case .status(401), .status(403): return "Your session is no longer authorized. Please sign in again."
+ case .status(404): return "This chat is no longer available."
+ case .status(503), .unavailable: return "Chat is not available on this server yet. Talk remains available."
+ default: return "Hope couldn’t complete that request. Your draft is still here. Try again."
+ }
+ }
+}
+
+protocol ChatTransport {
+ func request(_ path: String, method: String, body: [String: Any]?, query: [URLQueryItem]) async throws -> Data
+ func events(run: String, after: Int, receive: @escaping @MainActor (ChatEvent) throws -> Void) async throws
+ func upload(data: Data, filename: String, mime: String) async throws -> Data
+ func cancel()
+}
+
+extension ChatTransport {
+ func upload(data: Data, filename: String, mime: String) async throws -> Data { throw ChatFailure.unavailable }
+}
+
+final class ChatAPI: ChatTransport {
+ let config: ServerConfig
+ private let token: String
+ private let session: URLSession
+ init(config: ServerConfig, token: String, session: URLSession? = nil) {
+ self.config = config; self.token = token; self.session = session ?? TLSPinning.session()
+ }
+ private func makeRequest(_ path: String, method: String, body: [String: Any]?, query: [URLQueryItem]) throws -> URLRequest {
+ guard !token.isEmpty else { throw ChatFailure.status(401) }
+ var components = URLComponents(url: config.baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
+ components.queryItems = query.isEmpty ? nil : query
+ var request = URLRequest(url: components.url!)
+ request.httpMethod = method; request.timeoutInterval = 60
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ if let body { request.httpBody = try ChatCoding.body(body); request.setValue("application/json", forHTTPHeaderField: "Content-Type") }
+ return request
+ }
+ func request(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> Data {
+ let (data, response) = try await session.data(for: makeRequest(path, method: method, body: body, query: query))
+ try validate(response)
+ return data
+ }
+ private func validate(_ response: URLResponse) throws {
+ guard let response = response as? HTTPURLResponse else { throw ChatFailure.invalidResponse }
+ guard (200..<300).contains(response.statusCode) else { throw ChatFailure.status(response.statusCode) }
+ }
+ func events(run: String, after: Int, receive: @escaping @MainActor (ChatEvent) throws -> Void) async throws {
+ var request = try makeRequest("api/runs/\(run)/events", method: "GET", body: nil, query: [URLQueryItem(name: "after", value: String(after))])
+ request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
+ let (bytes, response) = try await session.bytes(for: request)
+ try validate(response)
+ var parser = SSEParser()
+ for try await line in bytes.lines {
+ try Task.checkCancellation()
+ if let event = parser.consume(line) { try await receive(event); if event.name == "done" { return } }
+ }
+ }
+ func upload(data: Data, filename: String, mime: String) async throws -> Data {
+ var request = try makeRequest("api/attachments", method: "POST", body: nil, query: [])
+ request.httpBody = data
+ request.setValue(mime, forHTTPHeaderField: "Content-Type")
+ request.setValue(filename, forHTTPHeaderField: "X-Filename")
+ let (result, response) = try await session.data(for: request)
+ try validate(response)
+ return result
+ }
+ func cancel() { session.invalidateAndCancel() }
+}
+
+/// Offline tests never hit any server. Uses the same contract envelopes as HTTP.
+final class OfflineChatAPI: ChatTransport {
+ private var conversations: [HopeConversation] = []
+ private var messages: [String: [HopeMessage]] = [:]
+ private var workspaces: [HopeWorkspace] = []
+ private var preferences = HopePreferences()
+ private var operations: [String: Data] = [:]
+ private var featureObjects: [String: [[String: Any]]] = ["attachments": [], "artifacts": [], "tasks": []]
+ private var fileData: [String: Data] = [:]
+ private var memories: [[String: Any]] = [["section": "memory", "content": "Offline memory", "revision": "1"], ["section": "user", "content": "", "revision": "1"]]
+ private func encode(_ value: T) throws -> Data { let e = JSONEncoder(); e.keyEncodingStrategy = .convertToSnakeCase; return try e.encode(value) }
+ func request(_ path: String, method: String, body: [String: Any]?, query: [URLQueryItem]) async throws -> Data {
+ try Task.checkCancellation()
+ let now = Date().timeIntervalSince1970
+ let parts = path.split(separator: "/").map(String.init)
+ let body = body ?? [:]
+ if path == "api/capabilities" { return Data("{\"features\":{\"attachments\":true,\"artifacts\":true,\"scheduled_tasks\":true,\"memory_controls\":true,\"image_input\":false}}".utf8) }
+ if path == "api/research" { return Data("{\"available\":false,\"reason\":\"Offline fixture\"}".utf8) }
+ if path == "api/sync" { return Data("{\"changes\":[],\"cursor\":0}".utf8) }
+ if parts.count == 4, parts[1] == "conversations", parts[3] == "export" {
+ guard let conversation = conversations.first(where: { $0.id == parts[2] }) else { throw ChatFailure.status(404) }
+ return Data(("# " + conversation.title + "\n\n" + (messages[conversation.id] ?? []).map { "## " + $0.role + "\n\n" + $0.content }.joined(separator: "\n\n")).utf8)
+ }
+ if path == "api/memory" { return try JSONSerialization.data(withJSONObject: ["sections": memories]) }
+ if parts.count == 3, parts[1] == "memory", let i = memories.firstIndex(where: { $0["section"] as? String == parts[2] }) {
+ guard body["revision"] as? String == memories[i]["revision"] as? String else { throw ChatFailure.status(409) }
+ memories[i]["content"] = body["content"] as? String ?? ""; memories[i]["revision"] = UUID().uuidString
+ return try JSONSerialization.data(withJSONObject: memories[i])
+ }
+ if parts.count >= 2, ["attachments", "artifacts", "tasks"].contains(parts[1]) {
+ let kind = parts[1]; let singular = kind == "tasks" ? "task" : String(kind.dropLast())
+ if parts.count == 2 {
+ if method == "GET" { return try JSONSerialization.data(withJSONObject: [kind: featureObjects[kind] ?? [], "next_cursor": NSNull()]) }
+ var item = body; item["id"] = UUID().uuidString; item["created_at"] = now; item["updated_at"] = now
+ if kind == "artifacts" { item["revision"] = 1 }
+ if kind == "tasks" { item["enabled"] = true }
+ featureObjects[kind, default: []].insert(item, at: 0)
+ return try JSONSerialization.data(withJSONObject: [singular: item])
+ }
+ guard let i = featureObjects[kind]?.firstIndex(where: { $0["id"] as? String == parts[2] }) else { throw ChatFailure.status(404) }
+ if parts.count == 4, parts[3] == "content" { return fileData[parts[2]] ?? Data() }
+ if method == "DELETE" { featureObjects[kind]?.remove(at: i); fileData[parts[2]] = nil; return Data("{\"ok\":true}".utf8) }
+ var item = featureObjects[kind]![i]
+ if method == "PATCH" {
+ if kind == "artifacts" { guard body["revision"] as? Int == item["revision"] as? Int else { throw ChatFailure.status(409) }; item["revision"] = (item["revision"] as? Int ?? 1) + 1 }
+ for (key, value) in body where key != "revision" { item[key] = value }
+ }
+ if parts.count == 4 { if parts[3] == "pause" { item["enabled"] = false }; if parts[3] == "resume" { item["enabled"] = true } }
+ featureObjects[kind]![i] = item
+ return try JSONSerialization.data(withJSONObject: [singular: item])
+ }
+ if path == "api/preferences" {
+ if method == "PUT" { preferences.palette = body["palette"] as? String ?? "opal"; preferences.appearance = body["appearance"] as? String ?? "system"; preferences.revision += 1 }
+ return try JSONSerialization.data(withJSONObject: ["preferences": JSONSerialization.jsonObject(with: encode(preferences))])
+ }
+ if path == "api/workspaces" {
+ if method == "POST" { workspaces.append(HopeWorkspace(id: UUID().uuidString, name: body["name"] as? String ?? "Workspace", instructions: body["instructions"] as? String ?? "", revision: 1, createdAt: now, updatedAt: now)); return try wrap("workspace", workspaces.last!) }
+ return try JSONSerialization.data(withJSONObject: ["workspaces": JSONSerialization.jsonObject(with: encode(workspaces))])
+ }
+ if parts.count == 3, parts[1] == "workspaces", let index = workspaces.firstIndex(where: { $0.id == parts[2] }) {
+ if method == "DELETE" { workspaces.remove(at: index); for i in conversations.indices where conversations[i].workspaceId == parts[2] { conversations[i].workspaceId = nil }; return Data("{\"ok\":true}".utf8) }
+ workspaces[index].name = body["name"] as? String ?? workspaces[index].name
+ workspaces[index].instructions = body["instructions"] as? String ?? workspaces[index].instructions
+ workspaces[index].revision += 1
+ return try wrap("workspace", workspaces[index])
+ }
+ if path == "api/conversations" {
+ if method == "POST" { let c = HopeConversation(id: UUID().uuidString, title: body["title"] as? String ?? "New chat", workspaceId: body["workspace_id"] as? String, pinned: false, pinRank: 0, archived: false, temporary: false, revision: 1, createdAt: now, updatedAt: now); conversations.insert(c, at: 0); return try wrap("conversation", c) }
+ let q = query.first(where: { $0.name == "q" })?.value ?? ""
+ let archived = query.first(where: { $0.name == "archived" })?.value == "true"
+ let filtered = conversations.filter { $0.archived == archived && (q.isEmpty || $0.title.localizedCaseInsensitiveContains(q) || (messages[$0.id] ?? []).contains { $0.content.localizedCaseInsensitiveContains(q) }) }
+ 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] }) {
+ let id = parts[2]
+ if parts.count == 4, parts[3] == "messages" {
+ let key = body["idempotency_key"] as? String ?? UUID().uuidString
+ if let prior = operations[key] { return prior }
+ let runId = UUID().uuidString
+ let user = HopeMessage(id: UUID().uuidString, conversationId: id, role: "user", content: body["content"] as? String ?? "", status: "completed", runId: runId, createdAt: now)
+ let answer = HopeMessage(id: UUID().uuidString, conversationId: id, role: "assistant", content: "## Offline reply\n\nThis is a **local fixture**, not a server response.\n\n```swift\nlet hope = true\n```", status: "completed", runId: runId, createdAt: now)
+ messages[id, default: []] += [user, answer]
+ let run = HopeRun(id: runId, conversationId: id, status: "completed", messageId: answer.id, createdAt: now, updatedAt: now, error: nil)
+ let data = try JSONSerialization.data(withJSONObject: ["message": JSONSerialization.jsonObject(with: encode(user)), "run": JSONSerialization.jsonObject(with: encode(run))]); operations[key] = data; return data
+ }
+ if parts.count == 4, parts[3] == "branch" {
+ var c = conversations[index]; c.id = UUID().uuidString; c.title += " · Branch"; c.revision = 1
+ let source = messages[id] ?? []; let cutoff = source.firstIndex { $0.id == body["message_id"] as? String } ?? source.count
+ messages[c.id] = Array(source.prefix(cutoff)).map { var m = $0; m.conversationId = c.id; return m }
+ conversations.insert(c, at: 0); return try wrap("conversation", c)
+ }
+ if method == "DELETE" { conversations.remove(at: index); messages[id] = nil; return Data("{\"ok\":true}".utf8) }
+ if method == "PATCH" {
+ guard body["revision"] as? Int == conversations[index].revision else { throw ChatFailure.status(409) }
+ conversations[index].title = body["title"] as? String ?? conversations[index].title
+ conversations[index].pinned = body["pinned"] as? Bool ?? conversations[index].pinned
+ conversations[index].archived = body["archived"] as? Bool ?? conversations[index].archived
+ if body.keys.contains("workspace_id") { conversations[index].workspaceId = body["workspace_id"] as? String }
+ conversations[index].revision += 1; return try wrap("conversation", conversations[index])
+ }
+ return try JSONSerialization.data(withJSONObject: ["conversation": JSONSerialization.jsonObject(with: encode(conversations[index])), "messages": JSONSerialization.jsonObject(with: encode(messages[id] ?? [])), "runs": [], "next_cursor": NSNull()])
+ }
+ throw ChatFailure.status(404)
+ }
+ private func wrap(_ key: String, _ value: T) throws -> Data { try JSONSerialization.data(withJSONObject: [key: JSONSerialization.jsonObject(with: encode(value))]) }
+ func events(run: String, after: Int, receive: @escaping @MainActor (ChatEvent) throws -> Void) async throws {}
+ func upload(data: Data, filename: String, mime: String) async throws -> Data {
+ let id = UUID().uuidString
+ let item: [String: Any] = ["id": id, "filename": filename, "mime": mime, "size": data.count, "extraction_status": mime.hasPrefix("image/") ? "image" : "ready", "created_at": Date().timeIntervalSince1970]
+ fileData[id] = data; featureObjects["attachments", default: []].insert(item, at: 0)
+ return try JSONSerialization.data(withJSONObject: ["attachment": item])
+ }
+ func cancel() {}
+}
diff --git a/Sources/Chat/ChatStore.swift b/Sources/Chat/ChatStore.swift
new file mode 100644
index 0000000..0ff0f37
--- /dev/null
+++ b/Sources/Chat/ChatStore.swift
@@ -0,0 +1,316 @@
+import Foundation
+
+@MainActor
+final class ChatStore: ObservableObject {
+ @Published var conversations: [HopeConversation] = []
+ @Published var workspaces: [HopeWorkspace] = []
+ @Published var preferences = HopePreferences()
+ @Published var selected: HopeConversation?
+ @Published var messages: [HopeMessage] = []
+ @Published var runs: [HopeRun] = []
+ @Published var draft = ""
+ @Published var error: String?
+ @Published var progress: String?
+ @Published var busy = false
+ @Published var searching = false
+ @Published var nextCursor: String?
+ @Published var olderMessagesCursor: String?
+ private var currentQuery = ""
+ private var currentArchived = false
+ private var searchGeneration = 0
+ @Published var approval: (id: String, prompt: String)?
+ @Published var researchAvailable = false
+ @Published var research = false
+ @Published var exportText: String?
+ private var syncCursor = 0
+ private var syncTask: Task?
+ @Published var model = ""
+ @Published var thinkingLevel = ""
+ @Published var models: [ChatModelChoice] = []
+ @Published var modelNote: String?
+ let features: ChatFeatureStore
+ @Published var selectedAttachmentIds: Set = []
+ private let api: ChatTransport
+ private var generation = 0
+ private var stream: Task?
+ private var cursors: [String: Int] = [:]
+ private var drafts: [String: String] = [:]
+ private var pendingKey: (content: String, key: String)?
+ private var closed = false
+ var onUnauthorized: (() async -> Void)?
+ var activeRun: HopeRun? { runs.first(where: \.active) }
+
+ init(api: ChatTransport) { self.api = api; self.features = ChatFeatureStore(api: api) }
+ deinit { stream?.cancel() }
+ private func call(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T {
+ let epoch = generation
+ let data = try await api.request(path, method: method, body: body, query: query)
+ try Task.checkCancellation()
+ guard !closed, generation == epoch else { throw CancellationError() }
+ return try ChatCoding.decoder.decode(T.self, from: data)
+ }
+ private func failure(_ value: Error) async {
+ guard !closed, !(value is CancellationError) else { return }
+ if case ChatFailure.status(let code) = value, code == 401 || code == 403 { await onUnauthorized?(); return }
+ error = value.localizedDescription
+ }
+ func load(query: String = "", archived: Bool = false, more: Bool = false) async {
+ guard !closed else { return }
+ currentQuery = query; currentArchived = archived
+ searchGeneration += 1
+ let search = searchGeneration
+ searching = true
+ defer { if search == searchGeneration { searching = false } }
+ do {
+ var q = [URLQueryItem(name: "q", value: query), URLQueryItem(name: "archived", value: String(archived)), URLQueryItem(name: "limit", value: "50")]
+ if more, let nextCursor { q.append(URLQueryItem(name: "cursor", value: nextCursor)) }
+ let page: ConversationPage = try await call("api/conversations", query: q)
+ guard search == searchGeneration else { return }
+ if more { let existing = Set(conversations.map(\.id)); conversations += page.conversations.filter { !existing.contains($0.id) } }
+ else { conversations = page.conversations }
+ nextCursor = page.nextCursor
+ let spaces: WorkspacePage = try await call("api/workspaces"); workspaces = spaces.workspaces
+ let prefs: PreferencesEnvelope = try await call("api/preferences"); preferences = prefs.preferences
+ error = nil
+ } catch { await failure(error) }
+ }
+ func open(_ conversation: HopeConversation) async {
+ if let selected { drafts[selected.id] = draft }
+ generation += 1; stream?.cancel(); stream = nil
+ selected = conversation; messages = []; runs = []; progress = nil; approval = nil
+ draft = drafts[conversation.id] ?? ""; pendingKey = nil; selectedAttachmentIds = []; olderMessagesCursor = nil; error = nil
+ do {
+ let detail: ConversationDetail = try await call("api/conversations/\(conversation.id)")
+ selected = detail.conversation; messages = detail.messages; runs = detail.runs; olderMessagesCursor = detail.nextCursor
+ if let activeRun { replay(activeRun.id) }
+ } catch { await failure(error) }
+ }
+ func loadOlder() async {
+ guard let selected, let cursor = olderMessagesCursor else { return }
+ do {
+ let detail: ConversationDetail = try await call("api/conversations/\(selected.id)", query: [URLQueryItem(name: "cursor", value: cursor)])
+ let existing = Set(messages.map(\.id))
+ messages = detail.messages.filter { !existing.contains($0.id) } + messages
+ olderMessagesCursor = detail.nextCursor
+ } catch { await failure(error) }
+ }
+ func create(workspace: String? = nil) async {
+ guard !busy else { return }; busy = true; defer { busy = false }
+ do {
+ var body: [String: Any] = [:]; if let workspace { body["workspace_id"] = workspace }
+ let response: ConversationEnvelope = try await call("api/conversations", method: "POST", body: body)
+ conversations.insert(response.conversation, at: 0); await open(response.conversation)
+ } catch { await failure(error) }
+ }
+ func send() async {
+ guard let selected, !busy, activeRun == nil else { return }
+ let content = draft.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !content.isEmpty else { return }
+ busy = true; error = nil; defer { busy = false }
+ let payloadIdentity = content + "\n" + selectedAttachmentIds.sorted().joined(separator: ",") + String(research && researchAvailable)
+ if pendingKey?.content != payloadIdentity { pendingKey = (payloadIdentity, UUID().uuidString) }
+ do {
+ let response: SendEnvelope = try await call("api/conversations/\(selected.id)/messages", method: "POST", body: ["content": content, "idempotency_key": pendingKey!.key, "attachment_ids": Array(selectedAttachmentIds).sorted(), "mode": research && researchAvailable ? "research" : "normal"])
+ upsert(response.message); upsert(response.run); draft = ""; drafts[selected.id] = ""; pendingKey = nil; selectedAttachmentIds = []
+ if response.run.active { replay(response.run.id) }
+ else { let detail: ConversationDetail = try await call("api/conversations/\(selected.id)"); messages = detail.messages }
+ } catch { await failure(error) }
+ }
+ private func upsert(_ message: HopeMessage) {
+ guard message.conversationId == selected?.id else { return }
+ if let index = messages.firstIndex(where: { $0.id == message.id }) { messages[index] = message } else { messages.append(message) }
+ }
+ private func upsert(_ run: HopeRun) {
+ guard run.conversationId == selected?.id else { return }
+ if let index = runs.firstIndex(where: { $0.id == run.id }) { runs[index] = run } else { runs.append(run) }
+ }
+ /// Full-message replacement, never concatenate replayed content.
+ func apply(_ event: ChatEvent, run: String) throws {
+ guard event.id > cursors[run, default: 0] else { return }
+ switch event.name {
+ case "message":
+ struct Envelope: Decodable { let message: HopeMessage }
+ upsert(try ChatCoding.decoder.decode(Envelope.self, from: event.data).message)
+ case "run", "done": upsert(try ChatCoding.decoder.decode(RunEnvelope.self, from: event.data).run)
+ case "progress":
+ struct Envelope: Decodable { let message: String }
+ progress = try ChatCoding.decoder.decode(Envelope.self, from: event.data).message
+ case "approval":
+ struct Envelope: Decodable { let approvalId: String; let prompt: String }
+ let value = try ChatCoding.decoder.decode(Envelope.self, from: event.data)
+ approval = (value.approvalId, value.prompt)
+ default: break
+ }
+ cursors[run] = event.id
+ if activeRun == nil { progress = nil; approval = nil }
+ }
+ func resume() {
+ guard !closed, let activeRun else { return }; replay(activeRun.id)
+ }
+ private func replay(_ run: String) {
+ stream?.cancel()
+ let epoch = generation
+ stream = Task { [weak self] in
+ guard let self else { return }
+ do {
+ try await api.events(run: run, after: cursors[run, default: 0]) { [weak self] event in
+ guard let self, self.generation == epoch, !self.closed else { throw CancellationError() }
+ try self.apply(event, run: run)
+ }
+ guard generation == epoch, !closed else { return }
+ if activeRun != nil { error = "Connection interrupted. Resume to replay the latest response." }
+ } catch { guard generation == epoch else { return }; await failure(error) }
+ }
+ }
+ func stop() async {
+ guard let activeRun else { return }
+ do { let response: RunEnvelope = try await call("api/runs/\(activeRun.id)/cancel", method: "POST"); upsert(response.run); stream?.cancel(); progress = nil; approval = nil }
+ catch { await failure(error) }
+ }
+ func decide(_ decision: String) async {
+ guard let activeRun, let approval else { return }
+ do { let result: RunEnvelope = try await call("api/runs/\(activeRun.id)/approval", method: "POST", body: ["approval_id": approval.id, "decision": decision]); upsert(result.run); self.approval = nil; replay(result.run.id) }
+ catch { await failure(error) }
+ }
+ func patch(_ conversation: HopeConversation, changes: [String: Any]) async {
+ var body = changes; body["revision"] = conversation.revision
+ do {
+ let response: ConversationEnvelope = try await call("api/conversations/\(conversation.id)", method: "PATCH", body: body)
+ if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { conversations[index] = response.conversation }
+ if selected?.id == conversation.id { selected = response.conversation }
+ } catch { await failure(error) }
+ }
+ func delete(_ conversation: HopeConversation) async {
+ do {
+ struct Result: Decodable { let ok: Bool }
+ let _: Result = try await call("api/conversations/\(conversation.id)", method: "DELETE")
+ conversations.removeAll { $0.id == conversation.id }; drafts[conversation.id] = nil
+ if selected?.id == conversation.id { generation += 1; stream?.cancel(); selected = nil; messages = []; runs = []; draft = "" }
+ } catch { await failure(error) }
+ }
+ func branch(_ message: HopeMessage, edited: String? = nil, regenerate: Bool = false) async {
+ guard let selected, !busy, activeRun == nil else { return }
+ busy = true
+ do {
+ let target: HopeMessage
+ if regenerate {
+ guard let index = messages.firstIndex(where: { $0.id == message.id }), let prior = messages.prefix(index).last(where: { $0.role == "user" }) else { busy = false; return }
+ target = prior
+ } else { target = message }
+ var body: [String: Any] = ["message_id": target.id]
+ if let edited { body["content"] = edited }
+ else if regenerate { body["content"] = target.content }
+ let response: ConversationEnvelope = try await call("api/conversations/\(selected.id)/branch", method: "POST", body: body)
+ conversations.insert(response.conversation, at: 0); await open(response.conversation)
+ busy = false
+ if let edited { draft = edited; await send() }
+ else if regenerate { draft = target.content; await send() }
+ } catch { busy = false; await failure(error) }
+ }
+ func saveWorkspace(_ workspace: HopeWorkspace?, name: String, instructions: String) async {
+ guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
+ do {
+ var body: [String: Any] = ["name": name, "instructions": instructions]
+ if let workspace { body["revision"] = workspace.revision }
+ let _: WorkspaceEnvelope = try await call(workspace.map { "api/workspaces/\($0.id)" } ?? "api/workspaces", method: workspace == nil ? "POST" : "PATCH", body: body)
+ let result: WorkspacePage = try await call("api/workspaces"); workspaces = result.workspaces
+ } catch { await failure(error) }
+ }
+ func deleteWorkspace(_ workspace: HopeWorkspace) async {
+ do {
+ struct Result: Decodable { let ok: Bool }
+ let _: Result = try await call("api/workspaces/\(workspace.id)", method: "DELETE")
+ workspaces.removeAll { $0.id == workspace.id }
+ for i in conversations.indices where conversations[i].workspaceId == workspace.id { conversations[i].workspaceId = nil }
+ } catch { await failure(error) }
+ }
+ func savePreferences(palette: String, appearance: String) async {
+ do { let result: PreferencesEnvelope = try await call("api/preferences", method: "PUT", body: ["revision": preferences.revision, "palette": palette, "appearance": appearance]); preferences = result.preferences }
+ catch { await failure(error) }
+ }
+ func loadModels() async {
+ do {
+ let result: ChatSettingsResponse = try await call("api/settings/model")
+ model = result.settings.model; thinkingLevel = result.settings.thinkingLevel; models = result.models; modelNote = result.thinkingNote ?? result.message
+ } catch { modelNote = "Server model settings are not available." }
+ }
+ func saveModel() async {
+ do { let result: ChatSettingsSaved = try await call("api/settings/model", method: "PUT", body: ["model": model, "thinking_level": thinkingLevel]); model = result.settings.model; thinkingLevel = result.settings.thinkingLevel; modelNote = result.message }
+ catch { await failure(error) }
+ }
+ func loadResearch() async {
+ do { struct Result: Decodable { let available: Bool }; let result: Result = try await call("api/research"); researchAvailable = result.available }
+ catch { researchAvailable = false; research = false }
+ }
+ func exportConversation(format: String) async {
+ guard let selected else { return }
+ do {
+ let epoch = generation
+ let bytes = try await api.request("api/conversations/\(selected.id)/export", method: "GET", body: nil, query: [URLQueryItem(name: "format", value: format)])
+ guard epoch == generation, !closed else { return }
+ exportText = String(data: bytes, encoding: .utf8)
+ } catch { await failure(error) }
+ }
+ func foreground(_ active: Bool) {
+ syncTask?.cancel(); syncTask = nil
+ guard active, !closed else { return }
+ syncTask = Task { [weak self] in
+ guard let self else { return }
+ await loadResearch()
+ while !Task.isCancelled, !closed {
+ await synchronize()
+ do { try await Task.sleep(for: .seconds(5)) } catch { return }
+ }
+ }
+ }
+ private func synchronize() async {
+ do {
+ struct Change: Decodable { let id: String; let kind: String; let deleted: Bool; let data: HopeSyncData? }
+ struct Result: Decodable { let changes: [Change]; let cursor: Int }
+ let result: Result = try await call("api/sync", query: [URLQueryItem(name: "after", value: String(syncCursor))])
+ syncCursor = result.cursor
+ guard !result.changes.isEmpty else { return }
+ for change in result.changes {
+ if change.kind == "conversation" {
+ if change.deleted {
+ conversations.removeAll { $0.id == change.id }
+ if selected?.id == change.id { stream?.cancel(); self.selected = nil; messages = []; runs = []; draft = "" }
+ } else if let value = change.data?.conversation {
+ if value.archived != currentArchived { conversations.removeAll { $0.id == value.id } }
+ else if let i = conversations.firstIndex(where: { $0.id == change.id }) { conversations[i] = value }
+ else if currentQuery.isEmpty { conversations.insert(value, at: 0) }
+ }
+ }
+ if change.kind == "preferences", let value = change.data?.preferences { preferences = value }
+ if change.kind == "workspace" {
+ if change.deleted { workspaces.removeAll { $0.id == change.id } }
+ else if let value = change.data?.workspace { if let i = workspaces.firstIndex(where: { $0.id == value.id }) { workspaces[i] = value } else { workspaces.append(value) } }
+ }
+ }
+ if let selected, result.changes.contains(where: { $0.id == selected.id && !$0.deleted }) {
+ let detail: ConversationDetail = try await call("api/conversations/\(selected.id)")
+ self.selected = detail.conversation
+ for message in detail.messages { upsert(message) }
+ for run in detail.runs { upsert(run) }
+ }
+ } catch { if case ChatFailure.status(let status) = error, status == 401 || status == 403 { await failure(error) } }
+ }
+ /// Invalidate every outstanding callback before clearing account-visible content.
+ func close() { syncTask?.cancel(); syncTask = nil; exportText = nil; 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 }
+}
+struct ChatModelChoice: Decodable, Identifiable { let id: String; let thinkingLevels: [String]? }
+struct ChatSettingsValue: Decodable { let model: String; let thinkingLevel: String }
+struct ChatSettingsResponse: Decodable { let settings: ChatSettingsValue; let models: [ChatModelChoice]; let message: String?; let thinkingNote: String? }
+struct ChatSettingsSaved: Decodable { let settings: ChatSettingsValue; let message: String? }
+
+struct HopeSyncData: Decodable {
+ let conversation: HopeConversation?
+ let workspace: HopeWorkspace?
+ let preferences: HopePreferences?
+ init(from decoder: Decoder) throws {
+ let value = try decoder.singleValueContainer()
+ conversation = try? value.decode(HopeConversation.self)
+ workspace = try? value.decode(HopeWorkspace.self)
+ preferences = try? value.decode(HopePreferences.self)
+ }
+}
diff --git a/Sources/Chat/ChatViews.swift b/Sources/Chat/ChatViews.swift
new file mode 100644
index 0000000..cb1bb95
--- /dev/null
+++ b/Sources/Chat/ChatViews.swift
@@ -0,0 +1,282 @@
+import SwiftUI
+import UIKit
+
+struct NativeChatView: View {
+ @ObservedObject var store: ChatStore
+ let talk: (String) async -> Void
+ let logout: () async -> Void
+ @State private var query = ""
+ @State private var archived = false
+ @State private var workspace: String?
+ @State private var path: [String] = []
+ @State private var rename: HopeConversation?
+ @State private var delete: HopeConversation?
+ @State private var title = ""
+ @State private var showPreferences = false
+ @State private var showWorkspaces = false
+ @State private var showLibrary = false
+ @Environment(\.scenePhase) private var scenePhase
+ var body: some View {
+ NavigationStack(path: $path) {
+ List {
+ if let error = store.error { Section { Label(error, systemImage: "exclamationmark.circle").foregroundStyle(.red); Button("Retry") { Task { await refresh() } } } }
+ if store.searching && store.conversations.isEmpty { ProgressView("Loading chats") }
+ if store.conversations.isEmpty && !store.searching {
+ ContentUnavailableView(archived ? "No archived chats" : "A place to think", systemImage: "bubble.left.and.bubble.right", description: Text(archived ? "Archived chats appear here." : "Start a chat. Your conversations stay with your account, independently of Talk."))
+ }
+ if !pinned.isEmpty { Section("Pinned") { rows(pinned) } }
+ Section(archived ? "Archived" : "Recent chats") { rows(recent) }
+ if store.nextCursor != nil { Button("Load more") { Task { await store.load(query: query, archived: archived, more: true) } } }
+ }
+ .scrollContentBackground(.hidden)
+ .background(HopeTheme.color(.ground, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
+ .navigationTitle("Chats")
+ .searchable(text: $query, prompt: "Search chats and messages")
+ .task(id: query + String(archived)) {
+ do { try await Task.sleep(for: .milliseconds(250)); try Task.checkCancellation(); await refresh() } catch {}
+ }
+ .refreshable { await refresh() }
+ .onAppear { store.foreground(scenePhase == .active) }
+ .onChange(of: scenePhase) { _, phase in store.foreground(phase == .active) }
+ .toolbar {
+ ToolbarItem(placement: .topBarLeading) {
+ Menu {
+ Toggle("Show archived", isOn: $archived)
+ Picker("Workspace", selection: $workspace) {
+ Text("All chats").tag(nil as String?)
+ ForEach(store.workspaces) { Text($0.name).tag(Optional($0.id)) }
+ }
+ Button("Library and tools") { showLibrary = true }
+ Button("Manage workspaces") { showWorkspaces = true }
+ Button("Appearance and model") { showPreferences = true }
+ Button("Sign out", role: .destructive) { Task { await logout() } }
+ } label: { Image(systemName: "line.3.horizontal.decrease.circle").frame(minWidth: 44, minHeight: 44) }
+ .accessibilityLabel("Chat options")
+ }
+ ToolbarItem(placement: .topBarTrailing) {
+ Button { Task { await store.create(workspace: workspace); if let id = store.selected?.id { path = [id] } } } label: { Image(systemName: "square.and.pencil").frame(minWidth: 44, minHeight: 44) }
+ .accessibilityLabel("New chat").disabled(store.busy)
+ }
+ }
+ .navigationDestination(for: String.self) { _ in ChatDetailView(store: store, talk: talk) }
+ .sheet(isPresented: $showLibrary) { ChatLibraryView(store: store.features, selectedAttachmentIds: $store.selectedAttachmentIds) }
+ .sheet(isPresented: $showPreferences) { ChatPreferencesView(store: store) }
+ .sheet(isPresented: $showWorkspaces) { WorkspaceManagerView(store: store) }
+ .alert("Rename chat", isPresented: Binding(get: { rename != nil }, set: { if !$0 { rename = nil } })) {
+ TextField("Title", text: $title)
+ Button("Cancel", role: .cancel) { rename = nil }
+ Button("Save") { if let rename { Task { await store.patch(rename, changes: ["title": title]) } }; rename = nil }
+ }
+ .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("Cancel", role: .cancel) { delete = nil }
+ } message: { Text("This removes the conversation, not your account memory.") }
+ }
+ }
+ private var visible: [HopeConversation] { store.conversations.filter { workspace == nil || $0.workspaceId == workspace } }
+ private var pinned: [HopeConversation] { visible.filter(\.pinned).sorted { $0.pinRank < $1.pinRank } }
+ private var recent: [HopeConversation] { visible.filter { !$0.pinned } }
+ private func refresh() async { await store.load(query: query, archived: archived) }
+ @ViewBuilder private func rows(_ items: [HopeConversation]) -> some View {
+ ForEach(items) { conversation in
+ Button {
+ Task { await store.open(conversation); path = [conversation.id] }
+ } label: {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(conversation.title).foregroundStyle(.primary).font(.body)
+ if let id = conversation.workspaceId, let space = store.workspaces.first(where: { $0.id == id }) { Text(space.name).font(.caption).foregroundStyle(.secondary) }
+ Text(Date(timeIntervalSince1970: conversation.updatedAt), style: .date).font(.caption).foregroundStyle(.secondary)
+ }.frame(minHeight: 44, alignment: .leading)
+ }
+ .contextMenu {
+ Button(conversation.pinned ? "Unpin" : "Pin", systemImage: conversation.pinned ? "pin.slash" : "pin") { Task { await store.patch(conversation, changes: ["pinned": !conversation.pinned]) } }
+ Button("Rename", systemImage: "pencil") { title = conversation.title; rename = conversation }
+ Menu("Move to workspace") {
+ Button("Outside workspaces") { Task { await store.patch(conversation, changes: ["workspace_id": NSNull()]) } }
+ ForEach(store.workspaces) { space in Button(space.name) { Task { await store.patch(conversation, changes: ["workspace_id": space.id]) } } }
+ }
+ Button(conversation.archived ? "Unarchive" : "Archive", systemImage: "archivebox") { Task { await store.patch(conversation, changes: ["archived": !conversation.archived]) } }
+ Button("Delete", systemImage: "trash", role: .destructive) { delete = conversation }
+ }
+ }
+ }
+}
+
+struct ChatDetailView: View {
+ @ObservedObject var store: ChatStore
+ let talk: (String) async -> Void
+ @State private var editing: HopeMessage?
+ @State private var editedText = ""
+ @State private var follow = false
+ @State private var library = false
+ @Environment(\.scenePhase) private var scenePhase
+ var body: some View {
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 24) {
+ if store.messages.isEmpty { ContentUnavailableView("What’s on your mind?", systemImage: "bubble.left", description: Text("Write below to begin. Text chat does not connect your microphone.")) }
+ if store.olderMessagesCursor != nil { Button("Load earlier messages") { follow = false; Task { await store.loadOlder() } } }
+ ForEach(store.messages) { message in
+ VStack(alignment: .leading, spacing: 8) {
+ HStack { Text(message.role == "user" ? "You" : "Hope").font(.subheadline.weight(.semibold)); Spacer(); if message.status != "completed" { Text(message.status.capitalized).font(.caption).foregroundStyle(.secondary) } }
+ RichChatMarkdown(content: message.content)
+ Menu {
+ Button("Copy", systemImage: "doc.on.doc") { UIPasteboard.general.string = message.content }
+ Button("Branch from here", systemImage: "arrow.triangle.branch") { Task { await store.branch(message) } }
+ if message.role == "user" { Button("Edit in new branch", systemImage: "pencil") { editing = message; editedText = message.content } }
+ else { Button("Regenerate in new branch", systemImage: "arrow.clockwise") { Task { await store.branch(message, regenerate: true) } } }
+ } label: { Label("Message actions", systemImage: "ellipsis").font(.caption).frame(minHeight: 44) }
+ .disabled(store.busy || store.activeRun != nil)
+ }.id(message.id)
+ }
+ if let progress = store.progress { Label(progress, systemImage: "ellipsis").foregroundStyle(.secondary) }
+ Color.clear.frame(height: 1).id("chat-bottom")
+ }.padding(.horizontal, 20).padding(.vertical, 16).frame(maxWidth: 760)
+ }
+ .simultaneousGesture(DragGesture().onChanged { _ in follow = false })
+ .onChange(of: store.messages) { _, _ in if follow { proxy.scrollTo("chat-bottom", anchor: .bottom) } }
+ .overlay(alignment: .bottomTrailing) {
+ if !follow && !store.messages.isEmpty { Button("Latest", systemImage: "arrow.down") { follow = true; proxy.scrollTo("chat-bottom", anchor: .bottom) }.buttonStyle(.bordered).padding() }
+ }
+ }
+ .background(HopeTheme.color(.ground, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
+ .foregroundStyle(HopeTheme.color(.ink, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
+ .navigationTitle(store.selected?.title ?? "Chat")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar { ToolbarItem(placement: .topBarTrailing) {
+ Menu {
+ Button("Talk", systemImage: "waveform") { if let id = store.selected?.id { Task { await talk(id) } } }.disabled(store.activeRun != nil)
+ Button("Export Markdown") { Task { await store.exportConversation(format: "markdown") } }
+ Button("Export JSON") { Task { await store.exportConversation(format: "json") } }
+ if let text = store.exportText { ShareLink("Share export", item: text) }
+ } label: { Image(systemName: "ellipsis.circle").frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Chat actions")
+ } }
+ .safeAreaInset(edge: .bottom) {
+ VStack(alignment: .leading, spacing: 8) {
+ if let error = store.error { Text(error).font(.footnote).foregroundStyle(.red); if store.activeRun != nil { Button("Resume response") { store.resume() } } }
+ if let approval = store.approval {
+ Text(approval.prompt).font(.body)
+ HStack { Button("Deny", role: .destructive) { Task { await store.decide("deny") } }; Button("Approve") { Task { await store.decide("approve") } } }
+ }
+ if store.researchAvailable { Toggle("Research with cited sources", isOn: $store.research).font(.footnote) }
+ if !store.selectedAttachmentIds.isEmpty { Text("\(store.selectedAttachmentIds.count) attached files").font(.caption); Button("Remove attachments") { store.selectedAttachmentIds = [] } }
+ HStack(alignment: .bottom) {
+ Button { library = true } label: { Image(systemName: "paperclip").frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Attach files")
+ TextField("Message Hope", text: $store.draft, axis: .vertical).lineLimit(1...7).textFieldStyle(.roundedBorder).accessibilityIdentifier("chat-draft")
+ 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) }
+ }
+ }.padding(16).background(.regularMaterial)
+ }
+ .sheet(isPresented: $library) { ChatLibraryView(store: store.features, selectedAttachmentIds: $store.selectedAttachmentIds) }
+ .sheet(item: $editing) { message in
+ NavigationStack {
+ Form { Section("Edit message") { TextEditor(text: $editedText).frame(minHeight: 200) }; Text("A new branch preserves the original conversation.").font(.footnote).foregroundStyle(.secondary) }
+ .navigationTitle("Edit in branch")
+ .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { editing = nil } }; ToolbarItem(placement: .confirmationAction) { Button("Send") { editing = nil; Task { await store.branch(message, edited: editedText) } }.disabled(editedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } }
+ }
+ }
+ .onChange(of: scenePhase) { _, phase in if phase == .active { store.resume() } }
+ }
+}
+
+/// Native selectable prose and fenced code; no WebView or remote rendering.
+struct RichChatMarkdown: View {
+ let content: String
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
+ if block.code {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack { Text(block.language.isEmpty ? "Code" : block.language).font(.caption).foregroundStyle(.secondary); Spacer(); Button("Copy code") { UIPasteboard.general.string = block.text }.font(.caption).frame(minHeight: 44) }
+ ScrollView(.horizontal) { Text(block.text).font(.system(.body, design: .monospaced)).textSelection(.enabled).fixedSize(horizontal: true, vertical: false) }
+ }.padding(12).background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
+ } else {
+ ForEach(Array(block.text.components(separatedBy: "\n").enumerated()), id: \.offset) { _, line in
+ if line.hasPrefix("# ") { markdown(String(line.dropFirst(2))).font(.title2.weight(.semibold)) }
+ else if line.hasPrefix("## ") { markdown(String(line.dropFirst(3))).font(.title3.weight(.semibold)) }
+ else if line.hasPrefix("### ") { markdown(String(line.dropFirst(4))).font(.headline) }
+ else if line.hasPrefix("- ") || line.hasPrefix("* ") { HStack(alignment: .top) { Text("•"); markdown(String(line.dropFirst(2))) } }
+ else if line.hasPrefix("> ") { markdown(String(line.dropFirst(2))).foregroundStyle(.secondary) }
+ else { markdown(line) }
+ }
+ }
+ }
+ }.textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
+ }
+ private func markdown(_ text: String) -> Text {
+ Text((try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text))
+ }
+ private var blocks: [(text: String, code: Bool, language: String)] {
+ let pieces = content.components(separatedBy: "```")
+ return pieces.enumerated().map { i, piece in
+ if i.isMultiple(of: 2) { return (piece, false, "") }
+ let lines = piece.components(separatedBy: "\n")
+ return (lines.dropFirst().joined(separator: "\n").trimmingCharacters(in: .newlines), true, lines.first ?? "")
+ }
+ }
+}
+
+struct ChatPreferencesView: View {
+ @ObservedObject var store: ChatStore
+ @Environment(\.dismiss) private var dismiss
+ @State private var palette = "opal"
+ @State private var appearance = "system"
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Appearance") {
+ Picker("Palette", selection: $palette) { ForEach(["opal", "graphite", "ocean", "sage", "lavender"], id: \.self) { Text($0.capitalized).tag($0) } }
+ Picker("Appearance", selection: $appearance) { ForEach(["system", "light", "dark"], id: \.self) { Text($0.capitalized).tag($0) } }
+ Button("Save appearance") { Task { await store.savePreferences(palette: palette, appearance: appearance) } }
+ }
+ Section("Server model") {
+ if !store.models.isEmpty {
+ Picker("Model", selection: $store.model) { ForEach(store.models) { Text($0.id).tag($0.id) } }
+ if let levels = store.models.first(where: { $0.id == store.model })?.thinkingLevels, !levels.isEmpty { Picker("Thinking", selection: $store.thinkingLevel) { ForEach(levels, id: \.self) { Text($0.capitalized).tag($0) } } }
+ Button("Save model settings") { Task { await store.saveModel() } }
+ }
+ if let note = store.modelNote { Text(note).font(.footnote).foregroundStyle(.secondary) }
+ Text("Models are provided by your authenticated Hope server. No direct provider connection.").font(.footnote).foregroundStyle(.secondary)
+ }
+ if let error = store.error { Text(error).foregroundStyle(.red) }
+ }
+ .navigationTitle("Chat preferences")
+ .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
+ .task { palette = store.preferences.palette; appearance = store.preferences.appearance; await store.loadModels() }
+ }
+ }
+}
+
+struct WorkspaceManagerView: View {
+ @ObservedObject var store: ChatStore
+ @Environment(\.dismiss) private var dismiss
+ @State private var editing: HopeWorkspace?
+ @State private var name = ""
+ @State private var instructions = ""
+ @State private var deleting: HopeWorkspace?
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Workspaces") {
+ ForEach(store.workspaces) { space in
+ Button(space.name) { editing = space; name = space.name; instructions = space.instructions }
+ .swipeActions { Button("Delete", role: .destructive) { deleting = space } }
+ }
+ }
+ Section(editing == nil ? "New workspace" : "Edit workspace") {
+ TextField("Workspace name", text: $name)
+ TextField("Instructions", text: $instructions, axis: .vertical).lineLimit(3...8)
+ Text("Instructions guide chats. Workspaces do not isolate tool access.").font(.footnote).foregroundStyle(.secondary)
+ Button("Save workspace") { Task { await store.saveWorkspace(editing, name: name, instructions: instructions); editing = nil; name = ""; instructions = "" } }.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ if editing != nil { Button("Cancel edit") { editing = nil; name = ""; instructions = "" } }
+ }
+ }
+ .navigationTitle("Workspaces")
+ .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
+ .confirmationDialog("Delete workspace?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) {
+ Button("Delete workspace", role: .destructive) { if let deleting { Task { await store.deleteWorkspace(deleting) } }; deleting = nil }
+ } message: { Text("Its chats are retained outside the workspace.") }
+ }
+ }
+}
diff --git a/Sources/Chat/HopeTheme.generated.swift b/Sources/Chat/HopeTheme.generated.swift
new file mode 100644
index 0000000..965ff2f
--- /dev/null
+++ b/Sources/Chat/HopeTheme.generated.swift
@@ -0,0 +1,160 @@
+// Generated from design/hope-palette.v1.json v1.0.0; SHA256 bb76134be477916d776cda4259f45096fc2dcfcf873918b8703a0b716391a350.
+// DO NOT EDIT. python3 scripts/generate_hope_themes.py
+import SwiftUI
+import UIKit
+
+struct HopeRGB: Sendable {
+ let red: Double
+ let green: Double
+ let blue: Double
+ init(_ red: Double, _ green: Double, _ blue: Double) {
+ self.red = red; self.green = green; self.blue = blue
+ }
+ var uiColor: UIColor { UIColor(red: CGFloat(red), green: CGFloat(green), blue: CGFloat(blue), alpha: 1) }
+}
+
+enum HopeAppearance: String, CaseIterable, Sendable {
+ case system, light, dark
+}
+
+enum HopePalette: String, CaseIterable, Sendable {
+ case opal
+ case graphite
+ case ocean
+ case sage
+ case lavender
+ static let defaultPalette: HopePalette = .opal
+ var label: String {
+ switch self {
+ case .opal: return "Opal Pink"
+ case .graphite: return "Graphite"
+ case .ocean: return "Ocean"
+ case .sage: return "Sage"
+ case .lavender: return "Lavender"
+ }
+ }
+}
+
+enum HopeThemeRole: String, CaseIterable, Sendable {
+ case ground
+ case surface
+ case personaSurface
+ case ink
+ case quiet
+ case accent
+ case onAccent
+ case alert
+ case onAlert
+ case focus
+ case controlBorder
+ case separator
+ case selectedWash
+ case errorWash
+}
+
+enum HopeTheme {
+ static let version = "1.0.0"
+ static func rgb(_ role: HopeThemeRole, palette: HopePalette = .defaultPalette, dark: Bool) -> HopeRGB {
+ switch palette {
+ case .opal:
+ switch role {
+ case .ground: return dark ? HopeRGB(0.12549019607843137, 0.09411764705882353, 0.12549019607843137) : HopeRGB(1, 0.9921568627450981, 0.9921568627450981)
+ case .surface: return dark ? HopeRGB(0.18823529411764706, 0.13725490196078433, 0.18823529411764706) : HopeRGB(1, 0.9686274509803922, 0.9803921568627451)
+ case .personaSurface: return dark ? HopeRGB(0.23137254901960785, 0.1568627450980392, 0.21176470588235294) : HopeRGB(1, 0.9490196078431372, 0.9686274509803922)
+ case .ink: return dark ? HopeRGB(1, 0.9490196078431372, 0.9686274509803922) : HopeRGB(0.18823529411764706, 0.16862745098039217, 0.25098039215686274)
+ case .quiet: return dark ? HopeRGB(0.796078431372549, 0.7137254901960784, 0.7803921568627451) : HopeRGB(0.4392156862745098, 0.3764705882352941, 0.4549019607843137)
+ case .accent: return dark ? HopeRGB(0.9411764705882353, 0.6588235294117647, 0.7843137254901961) : HopeRGB(0.6509803921568628, 0.25882352941176473, 0.4392156862745098)
+ case .onAccent: return dark ? HopeRGB(0.12549019607843137, 0.09411764705882353, 0.12549019607843137) : HopeRGB(1, 1, 1)
+ case .focus: return dark ? HopeRGB(0.9411764705882353, 0.6588235294117647, 0.7843137254901961) : HopeRGB(0.6509803921568628, 0.25882352941176473, 0.4392156862745098)
+ case .controlBorder: return dark ? HopeRGB(0.796078431372549, 0.7137254901960784, 0.7803921568627451) : HopeRGB(0.4392156862745098, 0.3764705882352941, 0.4549019607843137)
+ case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
+ case .onAlert: return dark ? HopeRGB(0.12549019607843137, 0.09411764705882353, 0.12549019607843137) : HopeRGB(1, 1, 1)
+ case .separator: return dark ? HopeRGB(0.3018823529411765, 0.2509019607843137, 0.2974901960784314) : HopeRGB(0.8863529411764706, 0.8566274509803922, 0.8782745098039215)
+ case .selectedWash: return dark ? HopeRGB(0.2635294117647059, 0.1894117647058824, 0.24784313725490195) : HopeRGB(0.9650980392156863, 0.8976470588235295, 0.9262745098039216)
+ case .errorWash: return dark ? HopeRGB(0.2531764705882353, 0.17459450980392158, 0.2271764705882353) : HopeRGB(0.9740000000000001, 0.9077772549019608, 0.9245207843137255)
+ }
+ case .graphite:
+ switch role {
+ case .ground: return dark ? HopeRGB(0.09803921568627451, 0.10588235294117647, 0.12549019607843137) : HopeRGB(0.9921568627450981, 0.9921568627450981, 0.996078431372549)
+ case .surface: return dark ? HopeRGB(0.1450980392156863, 0.1568627450980392, 0.1843137254901961) : HopeRGB(0.9529411764705882, 0.9568627450980393, 0.9647058823529412)
+ case .personaSurface: return dark ? HopeRGB(0.18823529411764706, 0.20392156862745098, 0.23921568627450981) : HopeRGB(0.9294117647058824, 0.9333333333333333, 0.9490196078431372)
+ case .ink: return dark ? HopeRGB(0.9529411764705882, 0.9568627450980393, 0.9686274509803922) : HopeRGB(0.15294117647058825, 0.16470588235294117, 0.2)
+ case .quiet: return dark ? HopeRGB(0.7372549019607844, 0.7568627450980392, 0.803921568627451) : HopeRGB(0.3686274509803922, 0.3843137254901961, 0.43137254901960786)
+ case .accent: return dark ? HopeRGB(0.7490196078431373, 0.788235294117647, 0.8627450980392157) : HopeRGB(0.3137254901960784, 0.34901960784313724, 0.4117647058823529)
+ case .onAccent: return dark ? HopeRGB(0.09803921568627451, 0.10588235294117647, 0.12549019607843137) : HopeRGB(1, 1, 1)
+ case .focus: return dark ? HopeRGB(0.7490196078431373, 0.788235294117647, 0.8627450980392157) : HopeRGB(0.3137254901960784, 0.34901960784313724, 0.4117647058823529)
+ case .controlBorder: return dark ? HopeRGB(0.7372549019607844, 0.7568627450980392, 0.803921568627451) : HopeRGB(0.3686274509803922, 0.3843137254901961, 0.43137254901960786)
+ case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
+ case .onAlert: return dark ? HopeRGB(0.09803921568627451, 0.10588235294117647, 0.12549019607843137) : HopeRGB(1, 1, 1)
+ case .separator: return dark ? HopeRGB(0.2581960784313726, 0.2688627450980392, 0.29411764705882354) : HopeRGB(0.8409411764705882, 0.8459607843137256, 0.8576470588235294)
+ case .selectedWash: return dark ? HopeRGB(0.2054901960784314, 0.22, 0.2521568627450981) : HopeRGB(0.8890196078431373, 0.8960784313725491, 0.9094117647058824)
+ case .errorWash: return dark ? HopeRGB(0.2134901960784314, 0.19263372549019608, 0.22356862745098044) : HopeRGB(0.9307058823529412, 0.8969537254901961, 0.9100894117647059)
+ }
+ case .ocean:
+ switch role {
+ case .ground: return dark ? HopeRGB(0.08235294117647059, 0.12941176470588237, 0.16862745098039217) : HopeRGB(0.9882352941176471, 0.996078431372549, 1)
+ case .surface: return dark ? HopeRGB(0.12549019607843137, 0.19215686274509805, 0.24705882352941178) : HopeRGB(0.9411764705882353, 0.9686274509803922, 0.984313725490196)
+ case .personaSurface: return dark ? HopeRGB(0.1568627450980392, 0.23921568627450981, 0.30196078431372547) : HopeRGB(0.9058823529411765, 0.9490196078431372, 0.9764705882352941)
+ case .ink: return dark ? HopeRGB(0.9294117647058824, 0.9686274509803922, 1) : HopeRGB(0.13725490196078433, 0.20392156862745098, 0.25882352941176473)
+ case .quiet: return dark ? HopeRGB(0.7019607843137254, 0.796078431372549, 0.8705882352941177) : HopeRGB(0.3254901960784314, 0.40784313725490196, 0.47058823529411764)
+ case .accent: return dark ? HopeRGB(0.592156862745098, 0.807843137254902, 0.9333333333333333) : HopeRGB(0.15294117647058825, 0.396078431372549, 0.5490196078431373)
+ case .onAccent: return dark ? HopeRGB(0.08235294117647059, 0.12941176470588237, 0.16862745098039217) : HopeRGB(1, 1, 1)
+ case .focus: return dark ? HopeRGB(0.592156862745098, 0.807843137254902, 0.9333333333333333) : HopeRGB(0.15294117647058825, 0.396078431372549, 0.5490196078431373)
+ case .controlBorder: return dark ? HopeRGB(0.7019607843137254, 0.796078431372549, 0.8705882352941177) : HopeRGB(0.3254901960784314, 0.40784313725490196, 0.47058823529411764)
+ case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
+ case .onAlert: return dark ? HopeRGB(0.08235294117647059, 0.12941176470588237, 0.16862745098039217) : HopeRGB(1, 1, 1)
+ case .separator: return dark ? HopeRGB(0.2380392156862745, 0.30086274509803923, 0.35247058823529415) : HopeRGB(0.828627450980392, 0.8615686274509804, 0.8827450980392156)
+ case .selectedWash: return dark ? HopeRGB(0.17215686274509806, 0.25372549019607843, 0.31568627450980397) : HopeRGB(0.8623529411764705, 0.9113725490196078, 0.9407843137254902)
+ case .errorWash: return dark ? HopeRGB(0.19545098039215686, 0.22510431372549022, 0.28129411764705886) : HopeRGB(0.9198823529411766, 0.9077772549019608, 0.9281286274509805)
+ }
+ case .sage:
+ switch role {
+ case .ground: return dark ? HopeRGB(0.09803921568627451, 0.13725490196078433, 0.10980392156862745) : HopeRGB(0.9882352941176471, 0.996078431372549, 0.9882352941176471)
+ case .surface: return dark ? HopeRGB(0.14901960784313725, 0.20392156862745098, 0.16470588235294117) : HopeRGB(0.9411764705882353, 0.9647058823529412, 0.9450980392156862)
+ case .personaSurface: return dark ? HopeRGB(0.18823529411764706, 0.25882352941176473, 0.21176470588235294) : HopeRGB(0.9058823529411765, 0.9411764705882353, 0.9137254901960784)
+ case .ink: return dark ? HopeRGB(0.9411764705882353, 0.9725490196078431, 0.9450980392156862) : HopeRGB(0.1607843137254902, 0.2196078431372549, 0.17647058823529413)
+ case .quiet: return dark ? HopeRGB(0.7254901960784313, 0.8117647058823529, 0.7450980392156863) : HopeRGB(0.34901960784313724, 0.4196078431372549, 0.36470588235294116)
+ case .accent: return dark ? HopeRGB(0.6470588235294118, 0.8313725490196079, 0.6862745098039216) : HopeRGB(0.23529411764705882, 0.4392156862745098, 0.30196078431372547)
+ case .onAccent: return dark ? HopeRGB(0.09803921568627451, 0.13725490196078433, 0.10980392156862745) : HopeRGB(1, 1, 1)
+ case .focus: return dark ? HopeRGB(0.6470588235294118, 0.8313725490196079, 0.6862745098039216) : HopeRGB(0.23529411764705882, 0.4392156862745098, 0.30196078431372547)
+ case .controlBorder: return dark ? HopeRGB(0.7254901960784313, 0.8117647058823529, 0.7450980392156863) : HopeRGB(0.34901960784313724, 0.4196078431372549, 0.36470588235294116)
+ case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
+ case .onAlert: return dark ? HopeRGB(0.09803921568627451, 0.13725490196078433, 0.10980392156862745) : HopeRGB(1, 1, 1)
+ case .separator: return dark ? HopeRGB(0.25992156862745097, 0.3115294117647059, 0.2739607843137255) : HopeRGB(0.8319215686274509, 0.8603921568627451, 0.8374901960784313)
+ case .selectedWash: return dark ? HopeRGB(0.19882352941176473, 0.26666666666666666, 0.2168627450980392) : HopeRGB(0.8705882352941177, 0.9121568627450981, 0.8807843137254902)
+ case .errorWash: return dark ? HopeRGB(0.21709803921568627, 0.2359278431372549, 0.2055294117647059) : HopeRGB(0.9198823529411766, 0.9041694117647059, 0.8920501960784314)
+ }
+ case .lavender:
+ switch role {
+ case .ground: return dark ? HopeRGB(0.12549019607843137, 0.10588235294117647, 0.1607843137254902) : HopeRGB(0.996078431372549, 0.9921568627450981, 1)
+ case .surface: return dark ? HopeRGB(0.1803921568627451, 0.15294117647058825, 0.23529411764705882) : HopeRGB(0.9647058823529412, 0.9490196078431372, 0.9882352941176471)
+ case .personaSurface: return dark ? HopeRGB(0.23137254901960785, 0.19607843137254902, 0.30196078431372547) : HopeRGB(0.9333333333333333, 0.9098039215686274, 0.9725490196078431)
+ case .ink: return dark ? HopeRGB(0.9686274509803922, 0.9450980392156862, 1) : HopeRGB(0.20392156862745098, 0.17647058823529413, 0.2627450980392157)
+ case .quiet: return dark ? HopeRGB(0.7843137254901961, 0.7294117647058823, 0.8745098039215686) : HopeRGB(0.40784313725490196, 0.3686274509803922, 0.4823529411764706)
+ case .accent: return dark ? HopeRGB(0.8117647058823529, 0.7058823529411765, 0.9568627450980393) : HopeRGB(0.4392156862745098, 0.3215686274509804, 0.611764705882353)
+ case .onAccent: return dark ? HopeRGB(0.12549019607843137, 0.10588235294117647, 0.1607843137254902) : HopeRGB(1, 1, 1)
+ case .focus: return dark ? HopeRGB(0.8117647058823529, 0.7058823529411765, 0.9568627450980393) : HopeRGB(0.4392156862745098, 0.3215686274509804, 0.611764705882353)
+ case .controlBorder: return dark ? HopeRGB(0.7843137254901961, 0.7294117647058823, 0.8745098039215686) : HopeRGB(0.40784313725490196, 0.3686274509803922, 0.4823529411764706)
+ case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
+ case .onAlert: return dark ? HopeRGB(0.12549019607843137, 0.10588235294117647, 0.1607843137254902) : HopeRGB(1, 1, 1)
+ case .separator: return dark ? HopeRGB(0.29074509803921567, 0.263843137254902, 0.3423529411764706) : HopeRGB(0.8581960784313726, 0.8408627450980392, 0.8866666666666667)
+ case .selectedWash: return dark ? HopeRGB(0.24352941176470588, 0.20823529411764707, 0.3074509803921569) : HopeRGB(0.9121568627450981, 0.8862745098039215, 0.9505882352941177)
+ case .errorWash: return dark ? HopeRGB(0.24596078431372548, 0.18902588235294118, 0.27047058823529413) : HopeRGB(0.941529411764706, 0.8897380392156863, 0.9317364705882354)
+ }
+ }
+ }
+
+ static func color(_ role: HopeThemeRole, palette: HopePalette = .defaultPalette,
+ appearance: HopeAppearance = .system) -> Color {
+ let light = rgb(role, palette: palette, dark: false).uiColor
+ let dark = rgb(role, palette: palette, dark: true).uiColor
+ switch appearance {
+ case .light: return Color(uiColor: light)
+ case .dark: return Color(uiColor: dark)
+ case .system:
+ return Color(uiColor: UIColor { traits in
+ traits.userInterfaceStyle == .dark ? dark : light
+ })
+ }
+ }
+}
diff --git a/Sources/Features/Conversation/ConversationView.swift b/Sources/Features/Conversation/ConversationView.swift
index ff78035..a7c536a 100644
--- a/Sources/Features/Conversation/ConversationView.swift
+++ b/Sources/Features/Conversation/ConversationView.swift
@@ -11,7 +11,8 @@ struct ConversationView: View {
@State private var activeServerURL: String
@State private var pendingServerURL: String?
@State private var isReplacingSession = false
- @State private var selectedTab = 0
+ @State private var selectedTab = 2
+ @StateObject private var chat: ChatStore
init(settings: SettingsStore, auth: AuthStore, user: AuthUser, bearerToken: String) {
self.auth = auth
@@ -21,6 +22,11 @@ struct ConversationView: View {
let url = settings.trimmedServerURL
_activeServerURL = State(initialValue: url)
_session = State(initialValue: Self.makeSession(url, settings: settings, bearerToken: bearerToken, userID: user.id))
+ let api: ChatTransport = auth.fixture ? OfflineChatAPI() : ChatAPI(config: auth.config, token: bearerToken)
+ _chat = StateObject(wrappedValue: ChatStore(api: api))
+ #if DEBUG
+ if auth.fixture && !ProcessInfo.processInfo.arguments.contains("--ui-chat") { _selectedTab = State(initialValue: 0) }
+ #endif
}
private static func makeSession(_ url: String, settings: SettingsStore, bearerToken: String, userID: String) -> VoiceSession {
@@ -49,6 +55,14 @@ struct ConversationView: View {
var body: some View {
TabView(selection: $selectedTab) {
+ NativeChatView(store: chat, talk: { id in
+ await session.bindConversation(id)
+ selectedTab = 0
+ }, logout: {
+ await auth.logout { chat.close(); await session.disconnect() }
+ })
+ .tabItem { Label("Chats", systemImage: "bubble.left.and.text.bubble.right") }
+ .tag(2)
NavigationStack {
ConversationContent(settings: settings, session: session, displayName: user.displayName ?? user.username)
.toolbar {
@@ -56,7 +70,7 @@ struct ConversationView: View {
ToolbarItem(placement: .topBarLeading) {
Menu {
Button("Sign out", role: .destructive) {
- Task { await auth.logout { await session.disconnect() } }
+ Task { await auth.logout { chat.close(); await session.disconnect() } }
}
} label: {
Image(systemName: "person.crop.circle")
@@ -72,7 +86,8 @@ struct ConversationView: View {
ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
.tag(1)
}
- .tint(Nearlight.rosewood)
+ .tint(HopeTheme.color(.accent, palette: HopePalette(rawValue: chat.preferences.palette) ?? .opal))
+ .preferredColorScheme(chat.preferences.appearance == "system" ? nil : (chat.preferences.appearance == "dark" ? .dark : .light))
.onChange(of: settings.textMode) { _, enabled in
Task { await session.applyTextMode(enabled) }
}
@@ -80,10 +95,13 @@ struct ConversationView: View {
if auth.fixture { replaceServer(value) }
}
.onAppear {
- auth.disconnectAccount = { [weak session] in await session?.disconnect() }
- session.onUnauthorized = { [weak auth, weak session] in
- await auth?.logout { await session?.disconnect() }
+ auth.disconnectAccount = { [weak session, weak chat] in chat?.close(); await session?.disconnect() }
+ let unauthorized: () async -> Void = { [weak auth, weak session, weak chat] in
+ await auth?.logout { chat?.close(); await session?.disconnect() }
}
+ session.onUnauthorized = unauthorized
+ chat.onUnauthorized = unauthorized
+ chat.features.onUnauthorized = unauthorized
}
.disabled(auth.isBusy || auth.token == nil)
}
diff --git a/Sources/LiveKit/VoiceSession.swift b/Sources/LiveKit/VoiceSession.swift
index a36a7c7..ce5ad76 100644
--- a/Sources/LiveKit/VoiceSession.swift
+++ b/Sources/LiveKit/VoiceSession.swift
@@ -112,6 +112,12 @@ final class VoiceSession: ObservableObject {
NotificationCenter.default.removeObserver(self)
}
+ /// Explicit chat-to-Talk navigation tears down the prior room first.
+ func bindConversation(_ id: String?) async {
+ await disconnect()
+ tokenClient.conversationID = id
+ }
+
private func handleControlEvent(_ event: ControlChannel.InboundEvent) {
// Restrict inbound control-event application to active lifecycle states
// so late events from a failed/tearing-down room cannot repopulate stale
diff --git a/Sources/Networking/TokenClient.swift b/Sources/Networking/TokenClient.swift
index 30e9530..1e3da94 100644
--- a/Sources/Networking/TokenClient.swift
+++ b/Sources/Networking/TokenClient.swift
@@ -4,6 +4,11 @@ struct TokenResponse: Decodable, Equatable {
let token: String
let room: String
let user: User
+ let conversationID: String?
+ enum CodingKeys: String, CodingKey {
+ case token, room, user
+ case conversationID = "conversation_id"
+ }
struct User: Decodable, Equatable {
let id: String
@@ -44,6 +49,7 @@ final class TokenClient {
private let session: URLSession
var onUnauthorized: (() async -> Void)?
let bearerToken: String
+ var conversationID: String?
init(config: ServerConfig, bearerToken: String = "", session: URLSession? = nil) {
self.config = config
@@ -63,6 +69,10 @@ final class TokenClient {
guard !bearerToken.isEmpty else { throw AuthError.status(401) }
var request = URLRequest(url: config.baseURL.appendingPathComponent("token"))
request.httpMethod = "POST"
+ if let conversationID {
+ request.httpBody = try JSONSerialization.data(withJSONObject: ["conversation_id": conversationID])
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ }
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = 10
let (data, response) = try await session.data(for: request)
diff --git a/Tests/ChatTests.swift b/Tests/ChatTests.swift
new file mode 100644
index 0000000..3cf3a76
--- /dev/null
+++ b/Tests/ChatTests.swift
@@ -0,0 +1,151 @@
+import XCTest
+@testable import Voice
+
+@MainActor
+final class ChatTests: XCTestCase {
+ func testOfflineCRUDSearchPinsArchiveMoveAndPreferences() async throws {
+ let api = OfflineChatAPI()
+ let store = ChatStore(api: api)
+ await store.load()
+ XCTAssertTrue(store.conversations.isEmpty)
+ await store.saveWorkspace(nil, name: "Writing", instructions: "Be concise")
+ let space = try XCTUnwrap(store.workspaces.first)
+ await store.create(workspace: space.id)
+ let original = try XCTUnwrap(store.selected)
+ store.draft = "A durable offline message"
+ await store.send()
+ XCTAssertEqual(store.messages.count, 2)
+ XCTAssertEqual(store.draft, "")
+ await store.patch(original, changes: ["title": "Weekend", "pinned": true])
+ XCTAssertEqual(store.selected?.title, "Weekend")
+ XCTAssertTrue(store.selected?.pinned == true)
+ await store.load(query: "durable")
+ XCTAssertEqual(store.conversations.count, 1)
+ await store.savePreferences(palette: "ocean", appearance: "dark")
+ XCTAssertEqual(store.preferences.palette, "ocean")
+ await store.deleteWorkspace(space)
+ XCTAssertNil(store.conversations.first?.workspaceId)
+ let renamed = try XCTUnwrap(store.selected)
+ await store.patch(renamed, changes: ["archived": true])
+ await store.load()
+ XCTAssertTrue(store.conversations.isEmpty)
+ await store.load(archived: true)
+ XCTAssertEqual(store.conversations.count, 1)
+ await store.delete(try XCTUnwrap(store.conversations.first))
+ XCTAssertNil(store.selected)
+ XCTAssertTrue(store.messages.isEmpty)
+ }
+ func testOfflineResearchTruthfulExportAndTeardown() async throws {
+ let store = ChatStore(api: OfflineChatAPI())
+ await store.create()
+ store.draft = "Export this"; await store.send()
+ await store.loadResearch()
+ XCTAssertFalse(store.researchAvailable)
+ await store.exportConversation(format: "markdown")
+ XCTAssertTrue(store.exportText?.contains("Export this") == true)
+ store.foreground(true)
+ store.foreground(false)
+ store.close()
+ XCTAssertNil(store.exportText)
+ XCTAssertFalse(store.research)
+ }
+ func testBranchAndRegeneratePreserveOriginal() async throws {
+ let store = ChatStore(api: OfflineChatAPI())
+ await store.create()
+ let original = try XCTUnwrap(store.selected)
+ store.draft = "Original question"; await store.send()
+ let user = try XCTUnwrap(store.messages.first)
+ await store.branch(user, edited: "Edited question")
+ XCTAssertNotEqual(store.selected?.id, original.id)
+ XCTAssertEqual(store.messages.first?.content, "Edited question")
+ await store.open(original)
+ XCTAssertEqual(store.messages.first?.content, "Original question")
+ let answer = try XCTUnwrap(store.messages.last)
+ await store.branch(answer, regenerate: true)
+ XCTAssertNotEqual(store.selected?.id, original.id)
+ XCTAssertEqual(store.messages.first?.content, "Original question")
+ }
+ func testReplayFullReplacementAndDuplicateCursor() async throws {
+ let store = ChatStore(api: OfflineChatAPI())
+ await store.create()
+ let id = try XCTUnwrap(store.selected?.id)
+ func event(_ seq: Int, _ text: String) -> ChatEvent {
+ ChatEvent(id: seq, name: "message", data: Data("{\"message\":{\"id\":\"m\",\"conversation_id\":\"\(id)\",\"role\":\"assistant\",\"content\":\"\(text)\",\"status\":\"streaming\",\"run_id\":\"r\",\"created_at\":1}}".utf8))
+ }
+ try store.apply(event(1, "A"), run: "r")
+ try store.apply(event(2, "Answer"), run: "r")
+ try store.apply(event(1, "Duplicate"), run: "r")
+ XCTAssertEqual(store.messages.count, 1)
+ XCTAssertEqual(store.messages.first?.content, "Answer")
+ store.close()
+ XCTAssertTrue(store.messages.isEmpty)
+ XCTAssertNil(store.selected)
+ XCTAssertEqual(store.draft, "")
+ }
+ func testSSECommentsMultilineAndFraming() throws {
+ var parser = SSEParser()
+ XCTAssertNil(parser.consume(": keepalive"))
+ XCTAssertNil(parser.consume("id: 12"))
+ XCTAssertNil(parser.consume("event: message"))
+ XCTAssertNil(parser.consume("data: {"))
+ XCTAssertNil(parser.consume("data: }"))
+ let result = try XCTUnwrap(parser.consume(""))
+ XCTAssertEqual(result.id, 12)
+ XCTAssertEqual(result.name, "message")
+ XCTAssertEqual(String(data: result.data, encoding: .utf8), "{\n}")
+ XCTAssertNil(parser.consume(""))
+ }
+ func testRevisionConflictRetainsDraft() async throws {
+ let store = ChatStore(api: OfflineChatAPI())
+ await store.create(); let old = try XCTUnwrap(store.selected)
+ await store.patch(old, changes: ["title": "First"])
+ store.draft = "Keep me"
+ await store.patch(old, changes: ["title": "Stale"])
+ XCTAssertEqual(store.selected?.title, "First")
+ XCTAssertEqual(store.draft, "Keep me")
+ XCTAssertNotNil(store.error)
+ }
+ func testAdvancedOfflineUploadArtifactTaskMemoryAndPrivacy() async throws {
+ let api = OfflineChatAPI()
+ let features = ChatFeatureStore(api: api)
+ await features.load()
+ XCTAssertTrue(features.enabled.contains("attachments"))
+ let uploaded = await features.upload(Data("Private notes".utf8), filename: "notes.txt", mime: "text/plain")
+ let upload = try XCTUnwrap(uploaded)
+ await features.preview(upload)
+ XCTAssertEqual(features.previewText, "Private notes")
+ await features.saveArtifact(nil, title: "Draft", kind: "markdown", content: "# Draft")
+ let item = try XCTUnwrap(features.artifacts.first)
+ await features.saveArtifact(item, title: "Revised", kind: "markdown", content: "# Revised")
+ XCTAssertEqual(features.artifacts.first?.revision, 2)
+ await features.saveTask(nil, name: "Reminder", schedule: "in 1 hour", prompt: "Check notes")
+ let task = try XCTUnwrap(features.tasks.first)
+ await features.taskAction(task, action: "pause")
+ XCTAssertEqual(features.tasks.first?.enabled, false)
+ let memory = try XCTUnwrap(features.memory.first)
+ await features.saveMemory(memory, content: "New memory")
+ XCTAssertEqual(features.memory.first?.content, "New memory")
+ await features.saveMemory(memory, content: "Stale overwrite")
+ XCTAssertNotNil(features.error)
+ XCTAssertEqual(features.memory.first?.content, "New memory")
+ features.close()
+ XCTAssertTrue(features.attachments.isEmpty)
+ XCTAssertTrue(features.artifacts.isEmpty)
+ XCTAssertTrue(features.tasks.isEmpty)
+ XCTAssertTrue(features.memory.isEmpty)
+ XCTAssertNil(features.previewText)
+ }
+ func testSharedThemeContrastAcrossPalettes() {
+ func luminance(_ rgb: HopeRGB) -> Double {
+ func channel(_ x: Double) -> Double { x <= 0.04045 ? x / 12.92 : pow((x + 0.055) / 1.055, 2.4) }
+ return 0.2126 * channel(rgb.red) + 0.7152 * channel(rgb.green) + 0.0722 * channel(rgb.blue)
+ }
+ for palette in HopePalette.allCases { for dark in [false, true] {
+ let ground = luminance(HopeTheme.rgb(.ground, palette: palette, dark: dark))
+ for role in [HopeThemeRole.ink, .quiet, .accent] {
+ let text = luminance(HopeTheme.rgb(role, palette: palette, dark: dark))
+ XCTAssertGreaterThanOrEqual((max(text, ground) + 0.05) / (min(text, ground) + 0.05), 4.5, "\(palette) \(role) \(dark)")
+ }
+ } }
+ }
+}
diff --git a/Tests/ChatTransportTests.swift b/Tests/ChatTransportTests.swift
new file mode 100644
index 0000000..8e8ee58
--- /dev/null
+++ b/Tests/ChatTransportTests.swift
@@ -0,0 +1,52 @@
+import XCTest
+@testable import Voice
+
+private final class ChatURLProtocol: 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, body) = try Self.handler!(request)
+ client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: body); client?.urlProtocolDidFinishLoading(self)
+ } catch { client?.urlProtocol(self, didFailWithError: error) }
+ }
+ override func stopLoading() {}
+}
+final class ChatTransportTests: XCTestCase {
+ func testBearerSameOriginQueryAndRawUpload() async throws {
+ guard case .success(let config) = ServerConfig.parse("https://hope.example:9178") else { XCTFail(); return }
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [ChatURLProtocol.self]
+ let api = ChatAPI(config: config, token: "offline-test-bearer", session: URLSession(configuration: configuration))
+ ChatURLProtocol.handler = { request in
+ XCTAssertEqual(request.url?.host, "hope.example")
+ XCTAssertEqual(request.url?.scheme, "https")
+ XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer offline-test-bearer")
+ XCTAssertEqual(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems?.first?.value, "a & b")
+ return (200, Data("{\"conversations\":[],\"next_cursor\":null}".utf8))
+ }
+ _ = try await api.request("api/conversations", query: [URLQueryItem(name: "q", value: "a & b")])
+ ChatURLProtocol.handler = { request in
+ XCTAssertEqual(request.httpMethod, "POST")
+ XCTAssertEqual(request.url?.path, "/api/attachments")
+ XCTAssertEqual(request.value(forHTTPHeaderField: "X-Filename"), "notes.txt")
+ XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "text/plain")
+ return (201, Data("{}".utf8))
+ }
+ _ = try await api.upload(data: Data("test".utf8), filename: "notes.txt", mime: "text/plain")
+ api.cancel(); ChatURLProtocol.handler = nil
+ }
+ func testUnauthorizedAndRedirectStatusCannotBecomeSuccess() async throws {
+ guard case .success(let config) = ServerConfig.parse("https://hope.example") else { XCTFail(); return }
+ let configuration = URLSessionConfiguration.ephemeral; configuration.protocolClasses = [ChatURLProtocol.self]
+ let api = ChatAPI(config: config, token: "offline", session: URLSession(configuration: configuration))
+ for status in [401, 403, 302, 409] {
+ ChatURLProtocol.handler = { _ in (status, Data()) }
+ do { _ = try await api.request("api/conversations"); XCTFail("Expected rejection") }
+ catch ChatFailure.status(let actual) { XCTAssertEqual(actual, status) }
+ }
+ api.cancel(); ChatURLProtocol.handler = nil
+ }
+}
diff --git a/UITests/ChatUITests.swift b/UITests/ChatUITests.swift
new file mode 100644
index 0000000..d514013
--- /dev/null
+++ b/UITests/ChatUITests.swift
@@ -0,0 +1,76 @@
+import XCTest
+
+final class ChatUITests: XCTestCase {
+ func testNativeTextChatWithoutVoiceAndAccountPrivacy() {
+ let app = XCUIApplication()
+ app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat"]
+ app.launch()
+ XCTAssertTrue(app.navigationBars["Chats"].waitForExistence(timeout: 10))
+ app.buttons["New chat"].tap()
+ let field = app.textFields["chat-draft"]
+ XCTAssertTrue(field.waitForExistence(timeout: 10))
+ field.tap(); field.typeText("Native offline text")
+ app.buttons["Send message"].tap()
+ XCTAssertTrue(app.staticTexts["Native offline text"].waitForExistence(timeout: 10))
+ XCTAssertTrue(app.staticTexts["Offline reply"].exists)
+ capture(app, "Native chat")
+ XCTAssertFalse(app.buttons["End conversation"].exists)
+ app.navigationBars.buttons.element(boundBy: 0).tap()
+ app.buttons["Chat options"].tap()
+ app.buttons["Sign out"].tap()
+ XCTAssertTrue(app.secureTextFields["Password"].waitForExistence(timeout: 10))
+ XCTAssertFalse(app.staticTexts["Native offline text"].exists)
+ }
+ func testOfflineAdvancedLibraryAndArtifactEditor() {
+ let app = XCUIApplication()
+ app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat"]
+ app.launch()
+ XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10))
+ app.buttons["Chat options"].tap(); app.buttons["Library and tools"].tap()
+ XCTAssertTrue(app.buttons["New artifact"].waitForExistence(timeout: 10))
+ app.buttons["New artifact"].tap()
+ XCTAssertTrue(app.textFields["Title"].waitForExistence(timeout: 10))
+ app.textFields["Title"].tap(); app.textFields["Title"].typeText("Local artifact")
+ app.textViews.firstMatch.tap(); app.textViews.firstMatch.typeText("Native fixture content")
+ app.buttons["Save"].tap()
+ let artifact = app.buttons.matching(NSPredicate(format: "label CONTAINS 'Local artifact'")).firstMatch
+ XCTAssertTrue(artifact.waitForExistence(timeout: 10))
+ artifact.tap()
+ XCTAssertTrue(app.textViews.firstMatch.waitForExistence(timeout: 10))
+ XCTAssertEqual(app.textViews.firstMatch.value as? String, "Native fixture content")
+ capture(app, "Native artifact")
+ app.buttons["Cancel"].tap()
+ app.buttons["Done"].tap()
+ }
+ private func capture(_ app: XCUIApplication, _ name: String) {
+ let attachment = XCTAttachment(screenshot: app.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
+ }
+ func testWorkspaceNativeFixture() {
+ let app = XCUIApplication()
+ app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat"]
+ app.launch()
+ XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10))
+ app.buttons["Chat options"].tap(); app.buttons["Manage workspaces"].tap()
+ let field = app.textFields["Workspace name"]
+ XCTAssertTrue(field.waitForExistence(timeout: 10)); field.tap(); field.typeText("Writing")
+ app.buttons["Save workspace"].tap()
+ XCTAssertTrue(app.buttons["Writing"].waitForExistence(timeout: 10))
+ capture(app, "Native workspaces")
+ }
+ func testChatLargeTextAndPreferences() {
+ let app = XCUIApplication()
+ app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]
+ app.launch()
+ XCTAssertTrue(app.buttons["New chat"].waitForExistence(timeout: 10))
+ app.buttons["Chat options"].tap()
+ app.buttons["Appearance and model"].tap()
+ XCTAssertTrue(app.navigationBars["Chat preferences"].waitForExistence(timeout: 10))
+ XCTAssertTrue(app.buttons["Save appearance"].exists)
+ capture(app, "Appearance accessibility size")
+ app.buttons["Done"].tap()
+ app.tabBars.buttons["Talk"].tap()
+ XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10))
+ app.tabBars.buttons["Activity"].tap()
+ XCTAssertTrue(app.staticTexts["No tasks yet"].waitForExistence(timeout: 15))
+ }
+}
diff --git a/Voice.xcodeproj/project.pbxproj b/Voice.xcodeproj/project.pbxproj
index a212877..6b5cd28 100644
--- a/Voice.xcodeproj/project.pbxproj
+++ b/Voice.xcodeproj/project.pbxproj
@@ -7,15 +7,19 @@
objects = {
/* Begin PBXBuildFile section */
+ 11BB49F87376061BDAFB9C5D /* ChatUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B2A30D6F45A8E79CF1C73A /* ChatUITests.swift */; };
1813A1CB0921D04FCAEC92D0 /* TranscriptDedupeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */; };
1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */; };
22EA06AD769759D2223D515F /* TLSLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2918A7B29AB6EB033E707C /* TLSLiveTests.swift */; };
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C71C48230C683D199D6F4B /* ControlChannel.swift */; };
+ 29EF2C8C93F9A4270816D22D /* ChatFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41286AD7E2621F77B4F7CC85 /* ChatFeatures.swift */; };
2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262B5A712708421C35ED6AC0 /* AuthTests.swift */; };
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; };
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; };
4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC751BFB00B04C73D19C714 /* AuthClient.swift */; };
57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */; };
+ 5D37D5933765F80A283DC47B /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */; };
+ 5E4FBAAB530CA2BEEA013A57 /* ChatModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96CFD3A353D5B5E4A97D263F /* ChatModels.swift */; };
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C23305A6C55A83F8617D9D /* ConversationState.swift */; };
6488E54A6D93278C04D870E2 /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */; };
70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */; };
@@ -29,12 +33,16 @@
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F76881B4AE51DEE74511CC /* SettingsView.swift */; };
A5868238CF4958D6D0C5908E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */; };
A66BC894BEEC870D879F991F /* LightwellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */; };
+ A6F527DF9D7F76FF5B4D71D7 /* HopeTheme.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C22C8FB52C3963186CC108 /* HopeTheme.generated.swift */; };
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */; };
+ B0266A37FF81F7DF7F288B37 /* ChatTransportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F93A6F4B4354976953F69F /* ChatTransportTests.swift */; };
BCAB602C722D74F6C96C5E36 /* ServerReplacementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */; };
C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */; };
+ C28D28376A22BE3981908AC2 /* ChatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B373E4A57214C0D289536B /* ChatTests.swift */; };
CDE38FE9679472D770C601E6 /* VoiceSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */; };
D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9681E7410B684D2D38752A70 /* TokenClient.swift */; };
D2E343C8B8657316A993698E /* TranscriptHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */; };
+ D8F6FDBA1D4C62B62A81885B /* ChatViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF5F1830927F8D44C4CC89A /* ChatViews.swift */; };
D958BE8EE0FF9568F8FABB11 /* Nearlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6FA2A93116B237CEA712E6 /* Nearlight.swift */; };
D9A0059E342955A06A021D6C /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = A6BAD55715F383C0DB5F306F /* LiveKit */; };
EE0C5762EC97B123A1880FFB /* Voices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5DF207EDB787F63CB3A64D /* Voices.swift */; };
@@ -61,6 +69,7 @@
/* Begin PBXFileReference section */
0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMonitor.swift; sourceTree = ""; };
+ 1CF5F1830927F8D44C4CC89A /* ChatViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViews.swift; sourceTree = ""; };
20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerReplacementCoordinatorTests.swift; sourceTree = ""; };
262B5A712708421C35ED6AC0 /* AuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthTests.swift; sourceTree = ""; };
26922A8D6467BD51ED45B63B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
@@ -68,32 +77,39 @@
2E5DF207EDB787F63CB3A64D /* Voices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Voices.swift; sourceTree = ""; };
2FED8976B60FBC13BC980FCB /* VoiceApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceApp.swift; sourceTree = ""; };
3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = ""; };
+ 41286AD7E2621F77B4F7CC85 /* ChatFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFeatures.swift; sourceTree = ""; };
48F76881B4AE51DEE74511CC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; };
536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainTokenStore.swift; sourceTree = ""; };
5759EB570DB87BBC121D946D /* VoiceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceLiveTests.swift; sourceTree = ""; };
61C71C48230C683D199D6F4B /* ControlChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlChannel.swift; sourceTree = ""; };
68A45228EF59186CFC219F2E /* TranscriptHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHistory.swift; sourceTree = ""; };
+ 6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = ""; };
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = ""; };
8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceUITests.swift; sourceTree = ""; };
87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightwellView.swift; sourceTree = ""; };
96278769DD369C629F18E475 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; };
9681E7410B684D2D38752A70 /* TokenClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenClient.swift; sourceTree = ""; };
+ 96CFD3A353D5B5E4A97D263F /* ChatModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModels.swift; sourceTree = ""; };
A0C23305A6C55A83F8617D9D /* ConversationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationState.swift; sourceTree = ""; };
AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTestView.swift; sourceTree = ""; };
+ B5B373E4A57214C0D289536B /* ChatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTests.swift; sourceTree = ""; };
BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptListView.swift; sourceTree = ""; };
BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationStateTests.swift; sourceTree = ""; };
C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = ""; };
C22975C69240AACD3418091F /* VoiceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ C8F93A6F4B4354976953F69F /* ChatTransportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTransportTests.swift; sourceTree = ""; };
CA6FA2A93116B237CEA712E6 /* Nearlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nearlight.swift; sourceTree = ""; };
CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = ""; };
D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHandler.swift; sourceTree = ""; };
D9B40076934C28BA4B92D808 /* TLSPinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSPinning.swift; sourceTree = ""; };
DAC751BFB00B04C73D19C714 /* AuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthClient.swift; sourceTree = ""; };
+ E6B2A30D6F45A8E79CF1C73A /* ChatUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUITests.swift; sourceTree = ""; };
E85BACA267ADD462306C9920 /* TaskPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskPanelView.swift; sourceTree = ""; };
EA2918A7B29AB6EB033E707C /* TLSLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSLiveTests.swift; sourceTree = ""; };
EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSession.swift; sourceTree = ""; };
F5683993A1DE677121FFA1F3 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = ""; };
+ F7C22C8FB52C3963186CC108 /* HopeTheme.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HopeTheme.generated.swift; sourceTree = ""; };
FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptDedupeTests.swift; sourceTree = ""; };
/* End PBXFileReference section */
@@ -135,6 +151,7 @@
6735DFBC89D9A939C44D763A /* UITests */ = {
isa = PBXGroup;
children = (
+ E6B2A30D6F45A8E79CF1C73A /* ChatUITests.swift */,
5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */,
8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */,
);
@@ -184,6 +201,18 @@
path = Features;
sourceTree = "";
};
+ 94F63FF6C1F0CD224010E2F5 /* Chat */ = {
+ isa = PBXGroup;
+ children = (
+ 41286AD7E2621F77B4F7CC85 /* ChatFeatures.swift */,
+ 96CFD3A353D5B5E4A97D263F /* ChatModels.swift */,
+ 6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */,
+ 1CF5F1830927F8D44C4CC89A /* ChatViews.swift */,
+ F7C22C8FB52C3963186CC108 /* HopeTheme.generated.swift */,
+ );
+ path = Chat;
+ sourceTree = "";
+ };
B3D0379BEBE1E5F4AA58DA2D = {
isa = PBXGroup;
children = (
@@ -211,6 +240,8 @@
isa = PBXGroup;
children = (
262B5A712708421C35ED6AC0 /* AuthTests.swift */,
+ B5B373E4A57214C0D289536B /* ChatTests.swift */,
+ C8F93A6F4B4354976953F69F /* ChatTransportTests.swift */,
BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */,
20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */,
EA2918A7B29AB6EB033E707C /* TLSLiveTests.swift */,
@@ -239,6 +270,7 @@
isa = PBXGroup;
children = (
CE49A38E40AAF659A9281B85 /* App */,
+ 94F63FF6C1F0CD224010E2F5 /* Chat */,
8DA8BEF2EE3E0F4181A94EB9 /* Features */,
2820A418F921E18A2FA7E4FB /* LiveKit */,
6B2AB34274D9B37E77178C6C /* Networking */,
@@ -366,10 +398,15 @@
C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */,
4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */,
6488E54A6D93278C04D870E2 /* AuthStore.swift in Sources */,
+ 29EF2C8C93F9A4270816D22D /* ChatFeatures.swift in Sources */,
+ 5E4FBAAB530CA2BEEA013A57 /* ChatModels.swift in Sources */,
+ 5D37D5933765F80A283DC47B /* ChatStore.swift in Sources */,
+ D8F6FDBA1D4C62B62A81885B /* ChatViews.swift in Sources */,
8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */,
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */,
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */,
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */,
+ A6F527DF9D7F76FF5B4D71D7 /* HopeTheme.generated.swift in Sources */,
F09DD789D07F49C46A659A4F /* KeychainTokenStore.swift in Sources */,
A66BC894BEEC870D879F991F /* LightwellView.swift in Sources */,
93F179C1E302E4868B9AFC71 /* LoginView.swift in Sources */,
@@ -394,6 +431,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 11BB49F87376061BDAFB9C5D /* ChatUITests.swift in Sources */,
70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */,
57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */,
);
@@ -404,6 +442,8 @@
buildActionMask = 2147483647;
files = (
2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */,
+ C28D28376A22BE3981908AC2 /* ChatTests.swift in Sources */,
+ B0266A37FF81F7DF7F288B37 /* ChatTransportTests.swift in Sources */,
FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */,
BCAB602C722D74F6C96C5E36 /* ServerReplacementCoordinatorTests.swift in Sources */,
22EA06AD769759D2223D515F /* TLSLiveTests.swift in Sources */,
diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md
index be03650..ad6899c 100644
--- a/docs/PROJECT_STATUS.md
+++ b/docs/PROJECT_STATUS.md
@@ -1,11 +1,61 @@
# Project status and handoff
-Last updated: 2026-09-19 (documentation review; no new runtime qualification).
+Last updated: 2026-10-03 (native chat offline qualification; no live chat/deployment qualification).
Reusable implementation, testing, device and deployment lessons are in
[ENGINEERING_LEARNINGS.md](ENGINEERING_LEARNINGS.md). September 12 test/install
results below are historical evidence, not a September 19 health check.
+## Unified native Hope chat — 2026-10-03
+
+The explicit unified-parity request supersedes historical no-model-picker notes.
+Native **Chats** is now the default authenticated shell, alongside unchanged Talk
+and Activity access. HTTP text chat uses the same verified account/bearer and
+pinned, ephemeral, redirect-denying networking; it requires neither LiveKit nor
+microphone capture. Durable server conversations, search, paging, workspaces,
+pins, archive, move, rename and confirmed delete are supported. Branch/edit/
+regenerate preserve original history. SSE full-message replacements deduplicate
+monotonic replay IDs, expose progress/approval/stop and explicit resume after
+interruption. Foreground metadata sync is account-scoped, five-second, cancels on
+background/logout, and preserves drafts/read position. Text selection, fenced-code
+copy, headings/lists/links use native rendering (no remote WebView).
+
+The backend-generated semantic Opal/Graphite/Ocean/Sage/Lavender artifact is
+consumed by native chat; appearance and palette persist through authenticated
+preferences. Model/thinking controls use only `/api/settings/model`, never a
+direct upstream connection. Chat → Talk binds `/token` to the selected durable
+conversation after existing room teardown; nil legacy bootstrap stays supported.
+Keychain origin scope, auth verification/logout sequencing, exact TLS pins and
+existing voice lifecycle invariants were not weakened.
+
+Advanced UI follows the new `CHAT_FEATURES_CONTRACT`: capability-gated private
+Files/Photos uploads (8 MiB client cap; up to eight selected IDs), in-memory
+text/image/PDF preview, artifacts with revision edit/history/text share, scheduled
+task create/edit/pause/resume/run/delete and revision-aware memory controls.
+Uploads do not automatically send. Task execution is server-owned; there is no
+APNs/push or task output history claim. Images/research show only after explicit
+server qualification. Memory changes may require a later Hermes session.
+Connectors and fabricated integration switches are not shown. Advanced data,
+previews/drafts, replay tasks and exports clear during account teardown.
+
+Offline verification: initial 66 unit + 2 chat UI passed; expanded 67 unit + 10 UI
+(7 existing voice + 3 chat) passed at `/tmp/hope-chat-final-tests.xcresult`.
+Extra HTTP transport coverage then passed 69 unit + 3 chat UI at
+`/tmp/hope-chat-qualified-tests.xcresult`. An earlier artifact UI run failed an
+exact row label assertion (native label includes revision/kind); predicate fixed,
+failed evidence retained at `/tmp/hope-chat-advanced-tests.xcresult`.
+Final current-source run passed **70 unit + 4 chat UI tests**, zero failures,
+`/tmp/hope-chat-final-qualified.xcresult` and `/tmp/hope-chat-final-qualified.log`.
+Retained Chat, Workspaces, Appearance (accessibility XXXL), and Artifact screenshots
+were exported to `/tmp/hope-chat-screenshots/` and visually inspected. Screens use
+native navigation/forms and readable Opal tint; appearance title truncates at XXXL
+as standard large-title behavior, controls remain reachable/scrollable.
+Signed generic iPhone build passed at `/tmp/hope-chat-phone-build.log`, artifact
+`/tmp/hope-chat-phone-build/Build/Products/Debug-iphoneos/Voice.app`. Final current
+source signed build passed again at `/tmp/hope-chat-phone-final-build.log`. No installation,
+production requests, backend deployment or physical speech qualification occurred.
+Opt-in live TLS cases return without probes in offline suite and are not TLS proof.
+
## Approved Opal pink redesign — 2026-10-02
User approved the Opal HTML direction with the existing square reactive persona.
diff --git a/project.yml b/project.yml
index ba411d7..a2c8dbc 100644
--- a/project.yml
+++ b/project.yml
@@ -33,7 +33,7 @@ targets:
info:
path: Resources/Info.plist
properties:
- CFBundleDisplayName: Hope Voice
+ CFBundleDisplayName: Hope
UILaunchScreen: {}
NSMicrophoneUsageDescription: "Hope Voice uses the microphone for live voice conversation."
NSAppTransportSecurity: