feat: refine native iOS chat experience

This commit is contained in:
snowspeeder committed 2026-10-03 18:55:28 -04:00
1 parent 9c5e9bce5b
commit 7bd893ae5c
14 files changed
+415 -93

No files matched your search

+2 -1
View File
@@ -36,8 +36,9 @@ final class SettingsStore: ObservableObject {
d.set(Self.defaultServerURL, forKey: Keys.serverURL) d.set(Self.defaultServerURL, forKey: Keys.serverURL)
} }
let storedVoice = d.string(forKey: Keys.voiceID) let storedVoice = d.string(forKey: Keys.voiceID)
voiceID = Voices.all.contains { $0.id == storedVoice } ? (storedVoice ?? "") : Voices.defaultID voiceID = Voices.restoredID(storedVoice)
textMode = d.bool(forKey: Keys.textMode) textMode = d.bool(forKey: Keys.textMode)
d.set(voiceID, forKey: Keys.voiceID)
} }
var trimmedServerURL: String { var trimmedServerURL: String {
+42 -12
View File
@@ -1,33 +1,63 @@
import Foundation import Foundation
struct VoiceOption: Equatable { struct VoiceOption: Codable, Equatable {
let id: String let id: String
let name: String let name: String
let tag: String? let tag: String?
var locale: String? = nil
var gender: String? = nil
var details: String {
[locale, gender, tag].compactMap { $0 }.joined(separator: " · ")
}
var displayName: String { var displayName: String {
tag.map { "\(name) (\($0))" } ?? name tag.map { "\(name) (\($0))" } ?? name
} }
} }
struct VoiceCatalogResponse: Decodable {
let defaultVoice: String
let voices: [VoiceOption]
let catalogAvailable: Bool
let message: String?
enum CodingKeys: String, CodingKey {
case voices, message
case defaultVoice = "default_voice"
case catalogAvailable = "catalog_available"
}
}
enum Voices { enum Voices {
static let all: [VoiceOption] = [ static let all: [VoiceOption] = [
VoiceOption(id: "en-US-Ava:DragonHDOmniLatestNeural", name: "Ava", tag: "Dragon HD Omni"), VoiceOption(id: "en-US-Ava:DragonHDOmniLatestNeural", name: "Ava", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-JennyNeural", name: "Jenny", tag: nil), VoiceOption(id: "en-US-Jenny:DragonHDOmniLatestNeural", name: "Jenny", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-GuyNeural", name: "Guy", tag: nil), VoiceOption(id: "en-US-Guy:DragonHDOmniLatestNeural", name: "Guy", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-AndrewNeural", name: "Andrew", tag: nil), VoiceOption(id: "en-US-Andrew:DragonHDOmniLatestNeural", name: "Andrew", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-AriaNeural", name: "Aria", tag: nil), VoiceOption(id: "en-US-Aria:DragonHDOmniLatestNeural", name: "Aria", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-EmmaNeural", name: "Emma", tag: nil), VoiceOption(id: "en-US-Emma:DragonHDOmniLatestNeural", name: "Emma", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-EricNeural", name: "Eric", tag: nil), VoiceOption(id: "en-US-Eric:DragonHDOmniLatestNeural", name: "Eric", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-BrianNeural", name: "Brian", tag: nil), VoiceOption(id: "en-US-Brian:DragonHDOmniLatestNeural", name: "Brian", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-AshleyNeural", name: "Ashley", tag: nil), VoiceOption(id: "en-US-Ashley:DragonHDOmniLatestNeural", name: "Ashley", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-RichardNeural", name: "Richard", tag: nil), VoiceOption(id: "en-US-Richard:DragonHDOmniLatestNeural", name: "Richard", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-TinaNeural", name: "Tina", tag: nil), VoiceOption(id: "en-US-Tina:DragonHDOmniLatestNeural", name: "Tina", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-SteffanNeural", name: "Steffan", tag: nil), VoiceOption(id: "en-US-Steffan:DragonHDOmniLatestNeural", name: "Steffan", tag: "Dragon HD Omni"),
] ]
static var defaultID: String { all[0].id } static var defaultID: String { all[0].id }
private static let legacyPersonaIDs: [String: String] = Dictionary(uniqueKeysWithValues: all.compactMap { option in
guard let prefix = option.id.split(separator: ":").first else { return nil }
return ("\(prefix)Neural", option.id)
})
static func restoredID(_ saved: String?) -> String {
guard let saved, saved.range(of: "^[A-Za-z0-9:_-]{1,120}$", options: .regularExpression) != nil else { return defaultID }
if let migrated = legacyPersonaIDs[saved] { return migrated }
return saved.replacingOccurrences(of: ":DragonHDLatestNeural", with: ":DragonHDOmniLatestNeural")
}
static func display(for id: String) -> String? { static func display(for id: String) -> String? {
all.first { $0.id == id }?.displayName all.first { $0.id == id }?.displayName
} }
+74 -13
View File
@@ -23,6 +23,9 @@ final class ChatFeatureStore: ObservableObject {
@Published var previewText: String? @Published var previewText: String?
@Published var previewImage: UIImage? @Published var previewImage: UIImage?
@Published var previewPDF: Data? @Published var previewPDF: Data?
@Published var previewAttachment: ChatAttachment?
@Published var previewLoading = false
@Published var previewError: String?
var onUnauthorized: (() async -> Void)? var onUnauthorized: (() async -> Void)?
private let api: ChatTransport private let api: ChatTransport
private var epoch = 0 private var epoch = 0
@@ -65,15 +68,36 @@ final class ChatFeatureStore: ObservableObject {
} catch { report(error); return nil } } catch { report(error); return nil }
} }
func preview(_ attachment: ChatAttachment) async { func preview(_ attachment: ChatAttachment) async {
previewAttachment = attachment
previewLoading = true
previewError = nil
previewPDF = nil; previewText = nil; previewImage = nil
defer { if previewAttachment?.id == attachment.id { previewLoading = false } }
do { do {
let current = epoch let current = epoch
let data = try await api.request("api/attachments/\(attachment.id)/content", method: "GET", body: nil, query: []) let data = try await api.request("api/attachments/\(attachment.id)/content", method: "GET", body: nil, query: [])
guard current == epoch, !closed else { return } guard current == epoch, !closed, previewAttachment?.id == attachment.id else { return }
previewPDF = nil; previewText = nil; previewImage = nil if attachment.mime.hasPrefix("image/") {
if attachment.mime.hasPrefix("image/") { previewImage = UIImage(data: data); previewText = nil } guard let image = UIImage(data: data) else { throw ChatFailure.invalidResponse }
else if attachment.mime == "application/pdf" { previewPDF = data; previewText = nil; previewImage = nil } previewImage = image
else { previewText = String(data: data, encoding: .utf8); previewImage = nil } } else if attachment.mime == "application/pdf" {
} catch { report(error) } guard PDFDocument(data: data) != nil else { throw ChatFailure.invalidResponse }
previewPDF = data
} else if let text = String(data: data, encoding: .utf8) {
previewText = text
} else {
throw ChatFailure.invalidResponse
}
} catch is CancellationError {
return
} catch {
guard previewAttachment?.id == attachment.id else { return }
previewError = "Couldn’t open \(attachment.filename). Try again."
}
}
func clearPreview() {
previewAttachment = nil; previewLoading = false; previewError = nil
previewText = nil; previewImage = nil; previewPDF = nil
} }
func deleteAttachment(_ item: ChatAttachment) async { func deleteAttachment(_ item: ChatAttachment) async {
await remove("api/attachments/\(item.id)"); if error == nil { attachments.removeAll { $0.id == item.id } } await remove("api/attachments/\(item.id)"); if error == nil { attachments.removeAll { $0.id == item.id } }
@@ -111,7 +135,7 @@ final class ChatFeatureStore: ObservableObject {
func saveMemory(_ item: ChatMemory, content: String) async { 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) } 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 } func close() { closed = true; epoch += 1; enabled = []; attachments = []; artifacts = []; tasks = []; memory = []; clearPreview(); error = nil }
} }
struct ChatLibraryView: View { struct ChatLibraryView: View {
@@ -121,6 +145,7 @@ struct ChatLibraryView: View {
@State private var importing = false @State private var importing = false
@State private var photo: PhotosPickerItem? @State private var photo: PhotosPickerItem?
@State private var showingPreview = false @State private var showingPreview = false
@State private var loadingCapabilities = true
@State private var artifact: ChatArtifact? @State private var artifact: ChatArtifact?
@State private var artifactEditor = false @State private var artifactEditor = false
@State private var title = "" @State private var title = ""
@@ -138,7 +163,11 @@ struct ChatLibraryView: View {
NavigationStack { NavigationStack {
List { List {
if let error = store.error { Section { Text(error).foregroundStyle(.red); Button("Retry") { Task { await store.load() } } } } if let error = store.error { Section { Text(error).foregroundStyle(.red); Button("Retry") { Task { await store.load() } } } }
if store.enabled.isEmpty { ContentUnavailableView("Checking server capabilities", systemImage: "server.rack", description: Text("Only features your account supports are shown.")) } if loadingCapabilities {
ContentUnavailableView("Checking server capabilities", systemImage: "server.rack", description: Text("Only features your account supports are shown."))
} else if store.enabled.isEmpty {
ContentUnavailableView("Library features aren’t available", systemImage: "lock", description: Text("Your server has not enabled private files, documents, schedules, or memory controls for this account."))
}
if store.enabled.contains("attachments") { if store.enabled.contains("attachments") {
Section("Files") { Section("Files") {
Button("Import from Files", systemImage: "doc.badge.plus") { importing = true }.disabled(store.busy) Button("Import from Files", systemImage: "doc.badge.plus") { importing = true }.disabled(store.busy)
@@ -146,8 +175,13 @@ struct ChatLibraryView: View {
ForEach(store.attachments) { item in ForEach(store.attachments) { item in
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack { 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 {
Button("Preview") { Task { await store.preview(item); showingPreview = true } }.frame(minHeight: 44) if selectedAttachmentIds.contains(item.id) { selectedAttachmentIds.remove(item.id) }
else if selectedAttachmentIds.count < 8 { selectedAttachmentIds.insert(item.id) }
else { store.error = "Select up to 8 files for one message." }
} label: { Label(item.filename, systemImage: selectedAttachmentIds.contains(item.id) ? "checkmark.circle.fill" : "circle").frame(minHeight: 44) }
.disabled(item.extractionStatus == "failed" || item.extractionStatus == "encrypted" || (item.mime.hasPrefix("image/") && !store.enabled.contains("image_input")))
Button("Preview") { showingPreview = true; Task { await store.preview(item) } }.frame(minHeight: 44)
} }
Text("\(ByteCountFormatter.string(fromByteCount: Int64(item.size), countStyle: .file)) · \(item.extractionStatus)").font(.caption).foregroundStyle(.secondary) Text("\(ByteCountFormatter.string(fromByteCount: Int64(item.size), countStyle: .file)) · \(item.extractionStatus)").font(.caption).foregroundStyle(.secondary)
}.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteAttachment(item); selectedAttachmentIds.remove(item.id) } } } }.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteAttachment(item); selectedAttachmentIds.remove(item.id) } } }
@@ -183,7 +217,7 @@ struct ChatLibraryView: View {
} }
.navigationTitle("Library and tools") .navigationTitle("Library and tools")
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
.task { await store.load() } .task { await store.load(); loadingCapabilities = false }
.fileImporter(isPresented: $importing, allowedContentTypes: [.plainText, .pdf, .png, .jpeg, UTType(filenameExtension: "md") ?? .plainText, UTType(filenameExtension: "webp") ?? .image]) { result in .fileImporter(isPresented: $importing, allowedContentTypes: [.plainText, .pdf, .png, .jpeg, UTType(filenameExtension: "md") ?? .plainText, UTType(filenameExtension: "webp") ?? .image]) { result in
if case .success(let url) = result { Task { if case .success(let url) = result { Task {
let scoped = url.startAccessingSecurityScopedResource(); defer { if scoped { url.stopAccessingSecurityScopedResource() } } let scoped = url.startAccessingSecurityScopedResource(); defer { if scoped { url.stopAccessingSecurityScopedResource() } }
@@ -197,10 +231,37 @@ struct ChatLibraryView: View {
} } } }
} }
.onChange(of: photo) { _, value in Task { if let data = try? await value?.loadTransferable(type: Data.self), data.count <= 8 * 1024 * 1024, let image = UIImage(data: data), let jpeg = image.jpegData(compressionQuality: 0.85) { _ = await store.upload(jpeg, filename: "photo.jpg", mime: "image/jpeg") } else if value != nil { store.error = "This photo could not be imported or exceeds 8 MiB. Choose a smaller JPEG or PNG." }; photo = nil } } .onChange(of: photo) { _, value in Task { if let data = try? await value?.loadTransferable(type: Data.self), data.count <= 8 * 1024 * 1024, let image = UIImage(data: data), let jpeg = image.jpegData(compressionQuality: 0.85) { _ = 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: $showingPreview, onDismiss: { store.clearPreview() }) {
NavigationStack {
ScrollView {
if store.previewLoading { ProgressView("Opening private file…").frame(maxWidth: .infinity, minHeight: 200) }
else if let error = store.previewError {
ContentUnavailableView("Couldn’t open file", systemImage: "exclamationmark.triangle", description: Text(error))
if let item = store.previewAttachment { Button("Try again") { Task { await store.preview(item) } } }
} else if let pdf = store.previewPDF { PrivatePDFPreview(data: pdf).frame(minHeight: 500) }
else if let image = store.previewImage { Image(uiImage: image).resizable().scaledToFit().padding() }
else if let text = store.previewText { Text(text).textSelection(.enabled).padding() }
}
.navigationTitle(store.previewAttachment?.filename ?? "Private preview")
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showingPreview = false } } }
}
}
.sheet(isPresented: $artifactEditor) { artifactForm } .sheet(isPresented: $artifactEditor) { artifactForm }
.sheet(isPresented: $taskEditor) { taskForm } .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 } } } } } } } .sheet(item: $memory) { item in
NavigationStack {
Form {
Section("Memory") { TextEditor(text: $memoryContent).frame(minHeight: 260).accessibilityLabel("Memory content") }
Text("An empty value clears this section. Existing sessions may cache earlier memory.").font(.footnote)
if let error = store.error { Text(error).foregroundStyle(.red) }
}
.navigationTitle(item.section.capitalized)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { memory = nil } }
ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveMemory(item, content: memoryContent); if store.error == nil { memory = nil } } } }
}
}
}
.confirmationDialog("Delete this item?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) { Button("Delete", role: .destructive) { if let deleting { Task { await deleting() } }; deleting = nil } } .confirmationDialog("Delete this item?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) { Button("Delete", role: .destructive) { if let deleting { Task { await deleting() } }; deleting = nil } }
} }
} }
+14 -4
View File
@@ -208,14 +208,24 @@ final class ChatStore: ObservableObject {
else if regenerate { draft = target.content; await send() } else if regenerate { draft = target.content; await send() }
} catch { busy = false; await failure(error) } } catch { busy = false; await failure(error) }
} }
func saveWorkspace(_ workspace: HopeWorkspace?, name: String, instructions: String) async { @discardableResult
guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } func saveWorkspace(_ workspace: HopeWorkspace?, name: String, instructions: String) async -> Bool {
guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
error = "Enter a workspace name."
return false
}
do { do {
var body: [String: Any] = ["name": name, "instructions": instructions] var body: [String: Any] = ["name": name, "instructions": instructions]
if let workspace { body["revision"] = workspace.revision } 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 _: 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 let result: WorkspacePage = try await call("api/workspaces")
} catch { await failure(error) } workspaces = result.workspaces
error = nil
return true
} catch {
await failure(error)
return false
}
} }
func deleteWorkspace(_ workspace: HopeWorkspace) async { func deleteWorkspace(_ workspace: HopeWorkspace) async {
do { do {
+53 -10
View File
@@ -21,11 +21,12 @@ struct NativeChatView: View {
List { List {
if let error = store.error { Section { Label(error, systemImage: "exclamationmark.circle").foregroundStyle(.red); Button("Retry") { Task { await refresh() } } } } 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.searching && store.conversations.isEmpty { ProgressView("Loading chats") }
if store.conversations.isEmpty && !store.searching { if visible.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.")) ContentUnavailableView(emptyTitle, systemImage: "bubble.left.and.bubble.right", description: Text(emptyDescription))
} }
if workspace != nil || archived { Section { Label(scopeLabel, systemImage: archived ? "archivebox" : "folder") } }
if !pinned.isEmpty { Section("Pinned") { rows(pinned) } } if !pinned.isEmpty { Section("Pinned") { rows(pinned) } }
Section(archived ? "Archived" : "Recent chats") { rows(recent) } if !recent.isEmpty { Section(archived ? "Archived" : "Recent chats") { rows(recent) } }
if store.nextCursor != nil { Button("Load more") { Task { await store.load(query: query, archived: archived, more: true) } } } if store.nextCursor != nil { Button("Load more") { Task { await store.load(query: query, archived: archived, more: true) } } }
} }
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
@@ -65,7 +66,8 @@ struct NativeChatView: View {
.alert("Rename chat", isPresented: Binding(get: { rename != nil }, set: { if !$0 { rename = nil } })) { .alert("Rename chat", isPresented: Binding(get: { rename != nil }, set: { if !$0 { rename = nil } })) {
TextField("Title", text: $title) TextField("Title", text: $title)
Button("Cancel", role: .cancel) { rename = nil } Button("Cancel", role: .cancel) { rename = nil }
Button("Save") { if let rename { Task { await store.patch(rename, changes: ["title": title]) } }; rename = nil } Button("Save") { if let rename { Task { await store.patch(rename, changes: ["title": title]); if store.error == nil { self.rename = nil } } } }
.disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} }
.confirmationDialog("Delete this chat?", isPresented: Binding(get: { delete != nil }, set: { if !$0 { delete = nil } }), titleVisibility: .visible) { .confirmationDialog("Delete this chat?", isPresented: Binding(get: { delete != nil }, set: { if !$0 { delete = nil } }), titleVisibility: .visible) {
Button("Delete chat", role: .destructive) { if let delete { Task { await store.delete(delete) } }; delete = nil } Button("Delete chat", role: .destructive) { if let delete { Task { await store.delete(delete) } }; delete = nil }
@@ -74,6 +76,18 @@ struct NativeChatView: View {
} }
} }
private var visible: [HopeConversation] { store.conversations.filter { workspace == nil || $0.workspaceId == workspace } } private var visible: [HopeConversation] { store.conversations.filter { workspace == nil || $0.workspaceId == workspace } }
private var scopeLabel: String { archived ? "Archived chats" : (workspace.flatMap { id in store.workspaces.first(where: { $0.id == id })?.name } ?? "All chats") }
private var emptyTitle: String {
if !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "No matching chats" }
if archived { return "No archived chats" }
if workspace != nil { return "No chats in this workspace" }
return "A place to think"
}
private var emptyDescription: String {
if !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "Try another search or clear it to see your conversations." }
if workspace != nil { return "Move a chat here or start a new conversation in this workspace." }
return archived ? "Archived chats appear here." : "Start a chat. Your conversations stay with your account, independently of Talk."
}
private var pinned: [HopeConversation] { visible.filter(\.pinned).sorted { $0.pinRank < $1.pinRank } } private var pinned: [HopeConversation] { visible.filter(\.pinned).sorted { $0.pinRank < $1.pinRank } }
private var recent: [HopeConversation] { visible.filter { !$0.pinned } } private var recent: [HopeConversation] { visible.filter { !$0.pinned } }
private func refresh() async { await store.load(query: query, archived: archived) } private func refresh() async { await store.load(query: query, archived: archived) }
@@ -83,9 +97,11 @@ struct NativeChatView: View {
Task { await store.open(conversation); path = [conversation.id] } Task { await store.open(conversation); path = [conversation.id] }
} label: { } label: {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(conversation.title).foregroundStyle(.primary).font(.body) Text(conversation.title).foregroundStyle(.primary).font(.body.weight(.medium)).lineLimit(2)
if let id = conversation.workspaceId, let space = store.workspaces.first(where: { $0.id == id }) { Text(space.name).font(.caption).foregroundStyle(.secondary) } HStack(spacing: 6) {
Text(Date(timeIntervalSince1970: conversation.updatedAt), style: .date).font(.caption).foregroundStyle(.secondary) if let id = conversation.workspaceId, let space = store.workspaces.first(where: { $0.id == id }) { Text(space.name) }
Text(Date(timeIntervalSince1970: conversation.updatedAt), style: .date)
}.font(.caption).foregroundStyle(.secondary)
}.frame(minHeight: 44, alignment: .leading) }.frame(minHeight: 44, alignment: .leading)
} }
.contextMenu { .contextMenu {
@@ -127,7 +143,11 @@ struct ChatDetailView: View {
else { Button("Regenerate in new branch", systemImage: "arrow.clockwise") { Task { await store.branch(message, regenerate: true) } } } 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) } } label: { Label("Message actions", systemImage: "ellipsis").font(.caption).frame(minHeight: 44) }
.disabled(store.busy || store.activeRun != nil) .disabled(store.busy || store.activeRun != nil)
}.id(message.id) }
.padding(message.role == "user" ? 12 : 0)
.background(message.role == "user" ? HopeTheme.color(.surface, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal) : .clear, in: RoundedRectangle(cornerRadius: 12))
.frame(maxWidth: .infinity, alignment: message.role == "user" ? .trailing : .leading)
.id(message.id)
} }
if let progress = store.progress { Label(progress, systemImage: "ellipsis").foregroundStyle(.secondary) } if let progress = store.progress { Label(progress, systemImage: "ellipsis").foregroundStyle(.secondary) }
Color.clear.frame(height: 1).id("chat-bottom") Color.clear.frame(height: 1).id("chat-bottom")
@@ -160,7 +180,9 @@ struct ChatDetailView: View {
HStack { Button("Deny", role: .destructive) { Task { await store.decide("deny") } }; Button("Approve") { Task { await store.decide("approve") } } } 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.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 = [] } } if !store.selectedAttachmentIds.isEmpty {
AttachmentSelectionSummary(store: store)
}
HStack(alignment: .bottom) { HStack(alignment: .bottom) {
Button { library = true } label: { Image(systemName: "paperclip").frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Attach files") 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") TextField("Message Hope", text: $store.draft, axis: .vertical).lineLimit(1...7).textFieldStyle(.roundedBorder).accessibilityIdentifier("chat-draft")
@@ -181,6 +203,23 @@ struct ChatDetailView: View {
} }
} }
private struct AttachmentSelectionSummary: View {
@ObservedObject var store: ChatStore
var selectedAttachments: [ChatAttachment] { store.features.attachments.filter { store.selectedAttachmentIds.contains($0.id) } }
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Attached to next message (\(store.selectedAttachmentIds.count)/8)").font(.caption.weight(.semibold))
ForEach(selectedAttachments) { item in
HStack {
Label(item.filename, systemImage: "doc").font(.caption).lineLimit(1)
Spacer()
Button("Remove") { store.selectedAttachmentIds.remove(item.id) }.font(.caption).frame(minHeight: 36)
}
}
}
}
}
/// Native selectable prose and fenced code; no WebView or remote rendering. /// Native selectable prose and fenced code; no WebView or remote rendering.
struct RichChatMarkdown: View { struct RichChatMarkdown: View {
let content: String let content: String
@@ -277,7 +316,11 @@ struct WorkspaceManagerView: View {
TextField("Workspace name", text: $name) TextField("Workspace name", text: $name)
TextField("Instructions", text: $instructions, axis: .vertical).lineLimit(3...8) TextField("Instructions", text: $instructions, axis: .vertical).lineLimit(3...8)
Text("Instructions guide chats. Workspaces do not isolate tool access.").font(.footnote).foregroundStyle(.secondary) 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) Button("Save workspace") { Task {
let saved = await store.saveWorkspace(editing, name: name, instructions: instructions)
if saved { editing = nil; name = ""; instructions = "" }
} }.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
if let error = store.error { Text(error).foregroundStyle(.red) }
if editing != nil { Button("Cancel edit") { editing = nil; name = ""; instructions = "" } } if editing != nil { Button("Cancel edit") { editing = nil; name = ""; instructions = "" } }
} }
} }
+26 -7
View File
@@ -7,6 +7,8 @@ struct LoginView: View {
@State private var password = "" @State private var password = ""
@State private var server = "" @State private var server = ""
@State private var serverError: String? @State private var serverError: String?
@State private var savedServer: String?
@State private var passwordVisible = false
@Environment(\.dynamicTypeSize) private var typeSize @Environment(\.dynamicTypeSize) private var typeSize
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -29,11 +31,21 @@ struct LoginView: View {
Section("Account") { Section("Account") {
TextField("Username", text: $username).textContentType(.username) TextField("Username", text: $username).textContentType(.username)
.textInputAutocapitalization(.never).autocorrectionDisabled() .textInputAutocapitalization(.never).autocorrectionDisabled()
SecureField("Password", text: $password).textContentType(.password) HStack {
Group {
if passwordVisible { TextField("Password", text: $password) }
else { SecureField("Password", text: $password) }
}
.textContentType(.password)
Button(passwordVisible ? "Hide" : "Show") { passwordVisible.toggle() }
.accessibilityLabel(passwordVisible ? "Hide password" : "Show password")
}
Button("Sign in") { Button("Sign in") {
let suppliedPassword = password let suppliedPassword = password
password = "" Task {
Task { await auth.login(username: username, password: suppliedPassword) } await auth.login(username: username, password: suppliedPassword)
if auth.user != nil { password = "" }
}
} }
.font(NearlightFont.control(typeSize)) .font(NearlightFont.control(typeSize))
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
@@ -54,13 +66,19 @@ struct LoginView: View {
.textInputAutocapitalization(.never).autocorrectionDisabled() .textInputAutocapitalization(.never).autocorrectionDisabled()
Button("Save server address") { Button("Save server address") {
switch ServerConfig.parse(server) { switch ServerConfig.parse(server) {
case .success(let config): settings.serverURL = config.baseURL.absoluteString case .success(let config):
case .failure(let error): serverError = error.localizedDescription server = config.baseURL.absoluteString
settings.serverURL = config.baseURL.absoluteString
serverError = nil
savedServer = config.baseURL.absoluteString
case .failure(let error): serverError = error.localizedDescription; savedServer = nil
} }
}.disabled(auth.isBusy) }.disabled(auth.isBusy || server.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || server == settings.serverURL)
if let serverError { if let serverError {
Text(serverError).foregroundStyle(Nearlight.alert) Label(serverError, systemImage: "exclamationmark.circle").foregroundStyle(Nearlight.alert)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} else if savedServer != nil {
Label("Server address saved. Sign in uses this server.", systemImage: "checkmark.circle").font(.footnote).foregroundStyle(Nearlight.quiet)
} }
Text("Connections use HTTPS and secure WebSockets. A private server certificate must be trusted by this iPhone for conversations.") Text("Connections use HTTPS and secure WebSockets. A private server certificate must be trusted by this iPhone for conversations.")
.font(.footnote).foregroundStyle(Nearlight.quiet) .font(.footnote).foregroundStyle(Nearlight.quiet)
@@ -81,6 +99,7 @@ struct LoginView: View {
.navigationTitle("Hope") .navigationTitle("Hope")
.toolbarBackground(Nearlight.ground, for: .navigationBar) .toolbarBackground(Nearlight.ground, for: .navigationBar)
.onAppear { server = settings.serverURL } .onAppear { server = settings.serverURL }
.onChange(of: server) { _, _ in serverError = nil; savedServer = nil }
} }
} }
} }
@@ -37,7 +37,7 @@ struct ConnectionTestView: View {
if let result { if let result {
switch result { switch result {
case .success(let response): case .success(let response):
Section("Success") { Section("Server access verified") {
LabeledContent("Room") { LabeledContent("Room") {
Text(response.room) Text(response.room)
.font(.footnote.monospaced()) .font(.footnote.monospaced())
@@ -45,8 +45,9 @@ struct ConnectionTestView: View {
LabeledContent("User") { LabeledContent("User") {
Text(response.user.displayName ?? response.user.id) Text(response.user.displayName ?? response.user.id)
} }
Label("Connected to your server. Start a conversation to check Hope’s voice and replies.", systemImage: "checkmark.circle") Label("Account access and session setup verified. This does not test microphone permission, secure voice transport, or spoken replies.", systemImage: "checkmark.circle")
.foregroundStyle(.green) .foregroundStyle(.green)
Text("Start a conversation to verify live voice and replies.").font(.footnote).foregroundStyle(.secondary)
} }
case .failure(let error): case .failure(let error):
Section("Error") { Section("Error") {
+94 -21
View File
@@ -13,6 +13,7 @@ struct SettingsView: View {
@State private var urlError: String? @State private var urlError: String?
@State private var showVoicePicker = false @State private var showVoicePicker = false
@State private var savedURLConfirmation: String? @State private var savedURLConfirmation: String?
@State private var pendingServerChange: String?
@FocusState private var urlFocused: Bool @FocusState private var urlFocused: Bool
init(settings: SettingsStore, session: VoiceSession) { init(settings: SettingsStore, session: VoiceSession) {
self.settings = settings self.settings = settings
@@ -82,7 +83,7 @@ struct SettingsView: View {
.foregroundStyle(Nearlight.quiet) .foregroundStyle(Nearlight.quiet)
.accessibilityIdentifier("settings.server.saved") .accessibilityIdentifier("settings.server.saved")
} }
Button("Save server address") { applyServerURL() } Button("Change server") { requestServerURLChange() }
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || urlText == settings.serverURL) .disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || urlText == settings.serverURL)
if !canApplyServerURL { if !canApplyServerURL {
Text(session.state == .tearingDown Text(session.state == .tearingDown
@@ -108,6 +109,15 @@ struct SettingsView: View {
.sheet(isPresented: $showVoicePicker) { .sheet(isPresented: $showVoicePicker) {
VoicePickerView(settings: settings, session: session) VoicePickerView(settings: settings, session: session)
} }
.confirmationDialog("Change Hope server?", isPresented: Binding(get: { pendingServerChange != nil }, set: { if !$0 { pendingServerChange = nil } }), titleVisibility: .visible) {
Button("Change server and sign out", role: .destructive) {
if let pendingServerChange { applyServerURL(pendingServerChange) }
pendingServerChange = nil
}
Button("Cancel", role: .cancel) { pendingServerChange = nil }
} message: {
Text("Changing servers signs you out and ends the current session. Conversations and account data remain with the current server.")
}
} }
@@ -115,47 +125,106 @@ struct SettingsView: View {
/// draft stays in the field with an inline error; `settings.serverURL` is /// draft stays in the field with an inline error; `settings.serverURL` is
/// left untouched so the active session keeps using its original URL. /// left untouched so the active session keeps using its original URL.
/// Guarded: only applies when the session is idle (no active call). /// Guarded: only applies when the session is idle (no active call).
private func applyServerURL() { private func requestServerURLChange() {
savedURLConfirmation = nil savedURLConfirmation = nil
guard canApplyServerURL else { guard canApplyServerURL else { urlError = "End the conversation before changing servers."; return }
urlError = "End the conversation before changing servers."
return
}
let trimmed = urlText.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { guard !trimmed.isEmpty else { urlError = "Enter a server URL."; return }
urlError = "Enter a server URL."
return
}
switch ServerConfig.parse(trimmed) { switch ServerConfig.parse(trimmed) {
case .success: case .success(let config):
settings.serverURL = trimmed pendingServerChange = config.baseURL.absoluteString
urlText = trimmed
urlError = nil urlError = nil
savedURLConfirmation = trimmed
urlFocused = false
case .failure(let error): case .failure(let error):
urlError = error.localizedDescription urlError = error.localizedDescription
urlFocused = true urlFocused = true
} }
} }
private func applyServerURL(_ canonicalURL: String) {
settings.serverURL = canonicalURL
urlText = canonicalURL
savedURLConfirmation = canonicalURL
urlError = nil
urlFocused = false
}
} }
struct VoicePickerView: View { struct VoicePickerView: View {
@ObservedObject var settings: SettingsStore @ObservedObject var settings: SettingsStore
@ObservedObject var session: VoiceSession @ObservedObject var session: VoiceSession
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var voices = Voices.all
@State private var search = ""
@State private var loading = false
@State private var catalogMessage: String?
@State private var catalogAvailable = false
@State private var requestID = UUID()
@State private var applyingVoiceID: String?
@State private var voiceError: String?
private var filteredVoices: [VoiceOption] {
let query = search.trimmingCharacters(in: .whitespacesAndNewlines)
return voices.filter { query.isEmpty || "\($0.name) \($0.details)".localizedCaseInsensitiveContains(query) }
}
@MainActor private func loadVoices() async {
guard !loading else { return }
loading = true
let id = UUID()
requestID = id
defer { if requestID == id { loading = false } }
do {
guard case .success(let config) = ServerConfig.parse(settings.serverURL) else { throw AuthError.invalidResponse }
let response = try await AuthClient(config: config).voices(token: session.bootstrapBearerToken)
try Task.checkCancellation()
guard requestID == id else { return }
let valid = response.voices.filter { $0.id.range(of: "^[A-Za-z0-9:_-]{1,120}$", options: .regularExpression) != nil }
guard !valid.isEmpty else { throw AuthError.invalidResponse }
voices = valid
catalogAvailable = response.catalogAvailable
catalogMessage = response.catalogAvailable ? nil : response.message
} catch is CancellationError {
return
} catch {
guard !Task.isCancelled, requestID == id else { return }
catalogAvailable = false
catalogMessage = "Couldn’t load all English voices. Your current choices remain available. Check your sign-in and server update, then retry."
}
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
ForEach(Voices.all, id: \.id) { voice in Section {
Text("Ava Dragon HD Omni is the recommended female default. Search by name, locale, gender, or model.")
.font(.footnote)
.foregroundStyle(.secondary)
if loading { ProgressView("Loading English voices…") }
if let catalogMessage { Text(catalogMessage).font(.footnote).foregroundStyle(.secondary) }
if let voiceError { Label(voiceError, systemImage: "exclamationmark.circle").font(.footnote).foregroundStyle(.red) }
if !catalogAvailable && !loading {
Button("Retry voice catalog") { Task { await loadVoices() } }
}
}
if filteredVoices.isEmpty {
Text("No matching voices. Try another name or locale.").foregroundStyle(.secondary)
}
ForEach(filteredVoices, id: \.id) { voice in
Button { Button {
settings.voiceID = voice.id guard applyingVoiceID == nil else { return }
Task { await session.selectVoice(voice.id) } applyingVoiceID = voice.id
dismiss() voiceError = nil
Task {
let applied = await session.selectVoice(voice.id)
applyingVoiceID = nil
if applied { dismiss() }
else { voiceError = "Couldn’t apply this voice to the live conversation. Your previous voice is still selected." }
}
} label: { } label: {
HStack { HStack {
Text(voice.displayName) VStack(alignment: .leading, spacing: 3) {
Text(voice.name)
Text(voice.details).font(.caption).foregroundStyle(.secondary)
}
if settings.voiceID == voice.id { if settings.voiceID == voice.id {
Spacer() Spacer()
Image(systemName: "checkmark") Image(systemName: "checkmark")
@@ -163,10 +232,14 @@ struct VoicePickerView: View {
} }
} }
} }
.disabled(applyingVoiceID != nil)
.accessibilityAddTraits(settings.voiceID == voice.id ? .isSelected : []) .accessibilityAddTraits(settings.voiceID == voice.id ? .isSelected : [])
} }
} }
.navigationTitle("Voice") .navigationTitle("English voices")
.searchable(text: $search, prompt: "Name, locale, gender, or Omni")
.task { await loadVoices() }
.onDisappear { requestID = UUID(); loading = false }
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() } Button("Cancel") { dismiss() }
+9 -4
View File
@@ -377,16 +377,21 @@ final class VoiceSession: ObservableObject {
} }
} }
func selectVoice(_ id: String) async { @discardableResult
settings.voiceID = id func selectVoice(_ id: String) async -> Bool {
guard isReady else { return } let previous = settings.voiceID
guard isReady else { settings.voiceID = id; return true }
let generation = sessionGeneration let generation = sessionGeneration
do { do {
try await controlChannel.setVoice(id, room: room) try await controlChannel.setVoice(id, room: room)
guard generation == sessionGeneration else { return } guard generation == sessionGeneration else { return false }
settings.voiceID = id
logger.log("set_voice sent: \(id, privacy: .public)") logger.log("set_voice sent: \(id, privacy: .public)")
return true
} catch { } catch {
settings.voiceID = previous
logger.error("set_voice failed: \(String(describing: error), privacy: .public)") logger.error("set_voice failed: \(String(describing: error), privacy: .public)")
return false
} }
} }
+12
View File
@@ -82,6 +82,18 @@ final class AuthClient {
struct Response: Decodable { let user: AuthUser } struct Response: Decodable { let user: AuthUser }
return try JSONDecoder().decode(Response.self, from: await request("api/auth/me", method: "GET", token: token)).user return try JSONDecoder().decode(Response.self, from: await request("api/auth/me", method: "GET", token: token)).user
} }
func voices(token: String) async throws -> VoiceCatalogResponse {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--ui-testing"),
!ProcessInfo.processInfo.arguments.contains("--live-testing") {
return VoiceCatalogResponse(defaultVoice: Voices.defaultID, voices: Voices.all,
catalogAvailable: false, message: "Offline test voice catalog.")
}
#endif
guard !token.isEmpty else { throw AuthError.status(401) }
return try JSONDecoder().decode(VoiceCatalogResponse.self, from: await request("voices", method: "GET", token: token))
}
func logout(token: String) async throws { func logout(token: String) async throws {
_ = try await request("api/auth/logout", method: "POST", token: token) _ = try await request("api/auth/logout", method: "POST", token: token)
} }
+24
View File
@@ -28,6 +28,30 @@ struct AuthTests {
config.protocolClasses = [AuthURLProtocol.self] config.protocolClasses = [AuthURLProtocol.self]
return URLSession(configuration: config) return URLSession(configuration: config)
} }
@Test func englishVoiceCatalogAndPreferenceMigration() async throws {
AuthURLProtocol.handler = { request in
#expect(request.url?.path == "/voices")
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-session")
return (200, Data(#"{"default_voice":"en-US-Ava:DragonHDOmniLatestNeural","catalog_available":true,"voices":[{"id":"en-GB-Sonia:DragonHDOmniLatestNeural","name":"Sonia","locale":"en-GB","gender":"Female","tag":"Omni"}]}"#.utf8))
}
let catalog = try await AuthClient(config: config(), session: session()).voices(token: "test-session")
#expect(catalog.catalogAvailable)
#expect(catalog.voices.first?.details == "en-GB · Female · Omni")
#expect(Voices.restoredID(catalog.voices.first?.id) == "en-GB-Sonia:DragonHDOmniLatestNeural")
// A valid server-catalogued choice must survive SettingsStore's next launch,
// even if it is not among the offline fallback voices.
#expect(Voices.restoredID("en-GB-Sonia:DragonHDOmniLatestNeural") == "en-GB-Sonia:DragonHDOmniLatestNeural")
#expect(Voices.restoredID("en-US-Ava:DragonHDLatestNeural") == Voices.defaultID)
for legacy in ["Jenny", "Guy", "Andrew", "Aria", "Emma", "Eric", "Brian", "Ashley", "Richard", "Tina", "Steffan"] {
let migrated = Voices.restoredID("en-US-\(legacy)Neural")
#expect(migrated.contains(":DragonHDOmniLatestNeural"))
#expect(Voices.display(for: migrated) != nil)
}
#expect(Voices.restoredID("invalid <voice>") == Voices.defaultID)
#expect(Voices.restoredID(nil) == Voices.defaultID)
}
@Test func certificateFailureHasSafeRecovery() throws { @Test func certificateFailureHasSafeRecovery() throws {
let url = URL(string: "https://192.168.86.2:9178")! let url = URL(string: "https://192.168.86.2:9178")!
let message = ConnectionFailure.message(URLError(.serverCertificateUntrusted), server: url) let message = ConnectionFailure.message(URLError(.serverCertificateUntrusted), server: url)
+25
View File
@@ -35,6 +35,31 @@ final class ChatTests: XCTestCase {
XCTAssertNil(store.selected) XCTAssertNil(store.selected)
XCTAssertTrue(store.messages.isEmpty) XCTAssertTrue(store.messages.isEmpty)
} }
func testWorkspaceSaveReportsFailureAndPreservesExistingWorkspaces() async throws {
let store = ChatStore(api: OfflineChatAPI())
let rejected = await store.saveWorkspace(nil, name: " ", instructions: "Ignored")
XCTAssertFalse(rejected)
XCTAssertEqual(store.error, "Enter a workspace name.")
XCTAssertTrue(store.workspaces.isEmpty)
let saved = await store.saveWorkspace(nil, name: "Writing", instructions: "Be concise")
XCTAssertTrue(saved)
XCTAssertEqual(store.workspaces.map(\.name), ["Writing"])
}
func testPreviewClearsOldContentBeforeNewRequestAndOnTeardown() async throws {
let api = OfflineChatAPI()
let store = ChatStore(api: api)
await store.features.load()
let data = Data("Private preview".utf8)
_ = await store.features.upload(data, filename: "preview.txt", mime: "text/plain")
let attachment = try XCTUnwrap(store.features.attachments.first)
store.features.previewText = "stale"
await store.features.preview(attachment)
XCTAssertEqual(store.features.previewAttachment?.id, attachment.id)
XCTAssertNotEqual(store.features.previewText, "stale")
store.features.clearPreview()
XCTAssertNil(store.features.previewAttachment)
XCTAssertNil(store.features.previewText)
}
func testOfflineResearchTruthfulExportAndTeardown() async throws { func testOfflineResearchTruthfulExportAndTeardown() async throws {
let store = ChatStore(api: OfflineChatAPI()) let store = ChatStore(api: OfflineChatAPI())
await store.create() await store.create()
+8 -19
View File
@@ -57,31 +57,20 @@ final class VoiceUITests: XCTestCase {
server.tap() server.tap()
let original = server.value as? String ?? "" let original = server.value as? String ?? ""
server.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: original.count) + "http://example.com") server.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: original.count) + "http://example.com")
app.buttons["Save server address"].tap() app.buttons["Change server"].tap()
XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'must use https'")).firstMatch.waitForExistence(timeout: 5)) XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'must use https'")).firstMatch.waitForExistence(timeout: 5))
let invalid = server.value as? String ?? "" let invalid = server.value as? String ?? ""
server.tap() server.tap()
server.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: invalid.count) + " https://127.0.0.1:1 ") server.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: invalid.count) + " https://127.0.0.1:1 ")
app.buttons["Save server address"].tap() app.buttons["Change server"].tap()
XCTAssertTrue(app.staticTexts["Server address saved."].waitForExistence(timeout: 5)) // A validated address presents an explicit account-switch action rather
XCTAssertEqual(server.value as? String, "https://127.0.0.1:1") // than silently replacing the active authenticated server.
let confirm = app.buttons["Change server and sign out"]
XCTAssertTrue(confirm.waitForExistence(timeout: 5))
// Do not apply the destructive action in this fixture: the assertion
// above proves a validated change cannot bypass the confirmation gate.
let unchanged = server.value as? String ?? ""
server.tap()
server.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: unchanged.count) + " https://127.0.0.1:2 ")
app.buttons["Save server address"].tap()
Thread.sleep(forTimeInterval: 2)
XCTAssertTrue(app.navigationBars["Settings"].exists)
XCTAssertTrue(app.staticTexts["Server address saved."].exists)
XCTAssertEqual(server.value as? String, "https://127.0.0.1:2")
app.terminate()
app.launchArguments = ["--ui-testing"]
app.launch()
app.buttons["Settings"].tap()
XCTAssertTrue(app.buttons.matching(NSPredicate(format: "label CONTAINS 'Speaking voice' AND label CONTAINS 'Jenny'")).firstMatch.waitForExistence(timeout: 5))
XCTAssertEqual(app.textFields["Server URL"].value as? String, "https://127.0.0.1:2")
} }
+29
View File
@@ -88,6 +88,27 @@ chat titled **Native qualification · disposable**, never unrelated user chats.
Further native live attempts stopped at parent's request while user uses voice. Further native live attempts stopped at parent's request while user uses voice.
No test-host UI guard was retained and no subsequent device action performed. No test-host UI guard was retained and no subsequent device action performed.
## English Dragon HD Omni voice picker — 2026-10-03
User reported missing new voices on iOS. Root cause: native picker used a fixed
12-voice list, and SettingsStore rejected persisted IDs outside that list.
Picker now loads authenticated same-origin `GET /voices` through existing pinned,
redirect-rejecting networking, supports name/locale/gender/model search, and shows
retry/failure state. Ava Omni remains default; fallback personas now use Omni.
Dynamic catalog IDs persist across relaunch; legacy Dragon HD IDs migrate to Omni.
Offline UI fixtures do not call the NAS. No login, TLS, microphone or account
storage behavior changed. Requires backend `/voices` route from voice-api commit
`6b8d55f`; real regional synthesis remains unqualified.
Final simulator unit run passed (60 tests including opt-in TLS cases returning
without live probes), `/tmp/hope-omni-ios-final-test.log`. Signed iPhone build passed,
`/tmp/hope-omni-phone-final.log`. Initial installation failed with CoreDevice
connection reset; fresh device check showed paired/available, but the single retry
was denied because the iPhone was locked (MobileImageMounterDeviceLocked). After
user unlocked it, installation and launch succeeded on the paired iPhone 14 Pro Max
at 08:00 local (dev.shane.voice). No live catalog/audio verification implied.
No credentials/preferences reset. No live voice/audio qualification claim.
## Approved Opal pink redesign — 2026-10-02 ## Approved Opal pink redesign — 2026-10-02
User approved the Opal HTML direction with the existing square reactive persona. User approved the Opal HTML direction with the existing square reactive persona.
@@ -369,6 +390,14 @@ Project `Voice.xcodeproj`, scheme `Voice_iOS`, iPhone 17 Pro simulator
Exact historical qualifications: [VERIFICATION.md](VERIFICATION.md). Artifacts Exact historical qualifications: [VERIFICATION.md](VERIFICATION.md). Artifacts
live on this Mac under the XcodeBuildMCP workspace, not in Git. live on this Mac under the XcodeBuildMCP workspace, not in Git.
## UX reliability refinement — 2026-10-03
Implemented the approved native UX refinement without changing backend contracts: Chats now shows accurate search/workspace/archive empty states and scope, clearer author hierarchy, named selected attachments, and a workspace editor that preserves its draft and reports an in-place failure. Private file preview clears stale data before a new request, identifies the file, and renders loading/error/retry states; unsupported Library accounts no longer appear to load indefinitely. Voice selection now reports a failed live application without overwriting the previous selection. Login preserves an in-memory password only until successful authentication, makes server save feedback explicit, and settings requires a confirmed account-switch warning before changing server. Connection diagnostics now identify token/session setup scope rather than claiming voice qualification.
Voice preference migration normalizes every legacy fallback persona to Dragon HD Omni while preserving safe dynamic English catalog IDs across relaunch. Added offline regressions for workspace result semantics, stale preview cleanup, static and dynamic voice migration. Sol code review initially found confirmation wiring, async-test compilation, fallback migration, and dynamic catalog persistence blockers; all were fixed before final test/build evidence below. Native iPhone build is signed but not installed in this refinement.
Verification scoped to current source: **60 unit tests passed** with `/tmp/hope-ios-redesign-derived/Logs/Test/Test-Voice_iOS-2026.10.03_18-46-50--0400.xcresult`; final confirmation-flow UI regression passed at `/tmp/hope-ios-redesign-ui-derived/Logs/Test/Test-Voice_iOS-2026.10.03_18-54-51--0400.xcresult`. An earlier complete offline run executed all existing UI tests but predated the final confirmation-test stabilization. A signed generic iPhone Debug build passed at `/tmp/hope-ios-redesign-phone/Build/Products/Debug-iphoneos/Voice.app`, log `/tmp/hope-ios-redesign-phone-build.log`. No production request, live voice test, device installation, or physical audio claim is made by this refinement.
## What remains, in order ## What remains, in order
1. **Qualify real audio:** microphone capture, TTS audibility, speaker/headphones, 1. **Qualify real audio:** microphone capture, TTS audibility, speaker/headphones,