Archived
feat: add authenticated durable native Hope chat
This commit is contained in:
1 parent
74d2b63614
commit
7dcd06c911
15 files changed
+1675
-9
No files matched your search
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Hope Voice</string>
|
||||
<string>Hope</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
|
||||
@@ -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<String> = []
|
||||
@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<T: Decodable>(_ 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<String>
|
||||
@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..<artifact.revision, id: \.self) { revision in Button("Revision \(revision)") { Task { if let value = await store.historical(artifact, revision: revision) { content = value.content ?? "" } } } } } }
|
||||
Text("Historical content uses the current title and kind. Saving creates a new revision.").font(.footnote)
|
||||
}
|
||||
if let error = store.error { Text(error).foregroundStyle(.red) }
|
||||
}.navigationTitle(artifact == nil ? "New artifact" : "Edit artifact")
|
||||
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { artifactEditor = false } }; ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveArtifact(artifact, title: title, kind: kind, content: content); if store.error == nil { artifactEditor = false } } }.disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } }
|
||||
}
|
||||
}
|
||||
private var taskForm: some View {
|
||||
NavigationStack {
|
||||
Form { TextField("Name", text: $name); TextField("Hermes schedule", text: $schedule); Text("Use a schedule accepted by your Hermes server. Hope does not invent a separate scheduling format.").font(.footnote); TextEditor(text: $prompt).frame(minHeight: 180); if let error = store.error { Text(error).foregroundStyle(.red) } }
|
||||
.navigationTitle(task == nil ? "New task" : "Edit task")
|
||||
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { taskEditor = false } }; ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveTask(task, name: name, schedule: schedule, prompt: prompt); if store.error == nil { taskEditor = false } } }.disabled(name.isEmpty || schedule.isEmpty || prompt.isEmpty) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PrivatePDFPreview: UIViewRepresentable {
|
||||
let data: Data
|
||||
func makeUIView(context: Context) -> 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) } }
|
||||
}
|
||||
@@ -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<T: Encodable>(_ 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<T: Encodable>(_ 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() {}
|
||||
}
|
||||
@@ -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<Void, Never>?
|
||||
@Published var model = ""
|
||||
@Published var thinkingLevel = ""
|
||||
@Published var models: [ChatModelChoice] = []
|
||||
@Published var modelNote: String?
|
||||
let features: ChatFeatureStore
|
||||
@Published var selectedAttachmentIds: Set<String> = []
|
||||
private let api: ChatTransport
|
||||
private var generation = 0
|
||||
private var stream: Task<Void, Never>?
|
||||
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<T: Decodable>(_ 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)
|
||||
}
|
||||
}
|
||||
@@ -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.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
} }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 = "<group>"; };
|
||||
1CF5F1830927F8D44C4CC89A /* ChatViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViews.swift; sourceTree = "<group>"; };
|
||||
20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerReplacementCoordinatorTests.swift; sourceTree = "<group>"; };
|
||||
262B5A712708421C35ED6AC0 /* AuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthTests.swift; sourceTree = "<group>"; };
|
||||
26922A8D6467BD51ED45B63B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
@@ -68,32 +77,39 @@
|
||||
2E5DF207EDB787F63CB3A64D /* Voices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Voices.swift; sourceTree = "<group>"; };
|
||||
2FED8976B60FBC13BC980FCB /* VoiceApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceApp.swift; sourceTree = "<group>"; };
|
||||
3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = "<group>"; };
|
||||
41286AD7E2621F77B4F7CC85 /* ChatFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFeatures.swift; sourceTree = "<group>"; };
|
||||
48F76881B4AE51DEE74511CC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainTokenStore.swift; sourceTree = "<group>"; };
|
||||
5759EB570DB87BBC121D946D /* VoiceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceLiveTests.swift; sourceTree = "<group>"; };
|
||||
61C71C48230C683D199D6F4B /* ControlChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlChannel.swift; sourceTree = "<group>"; };
|
||||
68A45228EF59186CFC219F2E /* TranscriptHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHistory.swift; sourceTree = "<group>"; };
|
||||
6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = "<group>"; };
|
||||
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; };
|
||||
8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceUITests.swift; sourceTree = "<group>"; };
|
||||
87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightwellView.swift; sourceTree = "<group>"; };
|
||||
96278769DD369C629F18E475 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||
9681E7410B684D2D38752A70 /* TokenClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenClient.swift; sourceTree = "<group>"; };
|
||||
96CFD3A353D5B5E4A97D263F /* ChatModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModels.swift; sourceTree = "<group>"; };
|
||||
A0C23305A6C55A83F8617D9D /* ConversationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationState.swift; sourceTree = "<group>"; };
|
||||
AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTestView.swift; sourceTree = "<group>"; };
|
||||
B5B373E4A57214C0D289536B /* ChatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTests.swift; sourceTree = "<group>"; };
|
||||
BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptListView.swift; sourceTree = "<group>"; };
|
||||
BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationStateTests.swift; sourceTree = "<group>"; };
|
||||
C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
CA6FA2A93116B237CEA712E6 /* Nearlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nearlight.swift; sourceTree = "<group>"; };
|
||||
CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
|
||||
D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHandler.swift; sourceTree = "<group>"; };
|
||||
D9B40076934C28BA4B92D808 /* TLSPinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSPinning.swift; sourceTree = "<group>"; };
|
||||
DAC751BFB00B04C73D19C714 /* AuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthClient.swift; sourceTree = "<group>"; };
|
||||
E6B2A30D6F45A8E79CF1C73A /* ChatUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUITests.swift; sourceTree = "<group>"; };
|
||||
E85BACA267ADD462306C9920 /* TaskPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskPanelView.swift; sourceTree = "<group>"; };
|
||||
EA2918A7B29AB6EB033E707C /* TLSLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSLiveTests.swift; sourceTree = "<group>"; };
|
||||
EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSession.swift; sourceTree = "<group>"; };
|
||||
F5683993A1DE677121FFA1F3 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
|
||||
F7C22C8FB52C3963186CC108 /* HopeTheme.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HopeTheme.generated.swift; sourceTree = "<group>"; };
|
||||
FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptDedupeTests.swift; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
94F63FF6C1F0CD224010E2F5 /* Chat */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
41286AD7E2621F77B4F7CC85 /* ChatFeatures.swift */,
|
||||
96CFD3A353D5B5E4A97D263F /* ChatModels.swift */,
|
||||
6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */,
|
||||
1CF5F1830927F8D44C4CC89A /* ChatViews.swift */,
|
||||
F7C22C8FB52C3963186CC108 /* HopeTheme.generated.swift */,
|
||||
);
|
||||
path = Chat;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
||||
+51
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user