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: