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)
}
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)
d.set(voiceID, forKey: Keys.voiceID)
}
var trimmedServerURL: String {
+42 -12
View File
@@ -1,33 +1,63 @@
import Foundation
struct VoiceOption: Equatable {
struct VoiceOption: Codable, Equatable {
let id: String
let name: 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 {
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 {
static let all: [VoiceOption] = [
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-GuyNeural", name: "Guy", tag: nil),
VoiceOption(id: "en-US-AndrewNeural", name: "Andrew", tag: nil),
VoiceOption(id: "en-US-AriaNeural", name: "Aria", tag: nil),
VoiceOption(id: "en-US-EmmaNeural", name: "Emma", tag: nil),
VoiceOption(id: "en-US-EricNeural", name: "Eric", tag: nil),
VoiceOption(id: "en-US-BrianNeural", name: "Brian", tag: nil),
VoiceOption(id: "en-US-AshleyNeural", name: "Ashley", tag: nil),
VoiceOption(id: "en-US-RichardNeural", name: "Richard", tag: nil),
VoiceOption(id: "en-US-TinaNeural", name: "Tina", tag: nil),
VoiceOption(id: "en-US-SteffanNeural", name: "Steffan", tag: nil),
VoiceOption(id: "en-US-Jenny:DragonHDOmniLatestNeural", name: "Jenny", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Guy:DragonHDOmniLatestNeural", name: "Guy", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Andrew:DragonHDOmniLatestNeural", name: "Andrew", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Aria:DragonHDOmniLatestNeural", name: "Aria", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Emma:DragonHDOmniLatestNeural", name: "Emma", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Eric:DragonHDOmniLatestNeural", name: "Eric", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Brian:DragonHDOmniLatestNeural", name: "Brian", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Ashley:DragonHDOmniLatestNeural", name: "Ashley", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Richard:DragonHDOmniLatestNeural", name: "Richard", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Tina:DragonHDOmniLatestNeural", name: "Tina", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Steffan:DragonHDOmniLatestNeural", name: "Steffan", tag: "Dragon HD Omni"),
]
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? {
all.first { $0.id == id }?.displayName
}
+74 -13
View File
@@ -23,6 +23,9 @@ final class ChatFeatureStore: ObservableObject {
@Published var previewText: String?
@Published var previewImage: UIImage?
@Published var previewPDF: Data?
@Published var previewAttachment: ChatAttachment?
@Published var previewLoading = false
@Published var previewError: String?
var onUnauthorized: (() async -> Void)?
private let api: ChatTransport
private var epoch = 0
@@ -65,15 +68,36 @@ final class ChatFeatureStore: ObservableObject {
} catch { report(error); return nil }
}
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 {
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) }
guard current == epoch, !closed, previewAttachment?.id == attachment.id else { return }
if attachment.mime.hasPrefix("image/") {
guard let image = UIImage(data: data) else { throw ChatFailure.invalidResponse }
previewImage = image
} else if attachment.mime == "application/pdf" {
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 {
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 {
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 {
@@ -121,6 +145,7 @@ struct ChatLibraryView: View {
@State private var importing = false
@State private var photo: PhotosPickerItem?
@State private var showingPreview = false
@State private var loadingCapabilities = true
@State private var artifact: ChatArtifact?
@State private var artifactEditor = false
@State private var title = ""
@@ -138,7 +163,11 @@ struct ChatLibraryView: 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 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") {
Section("Files") {
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
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)
Button {
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)
}.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")
.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
if case .success(let url) = result { Task {
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 } }
.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: $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 } }
}
}
+14 -4
View File
@@ -208,14 +208,24 @@ final class ChatStore: ObservableObject {
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 }
@discardableResult
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 {
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) }
let result: WorkspacePage = try await call("api/workspaces")
workspaces = result.workspaces
error = nil
return true
} catch {
await failure(error)
return false
}
}
func deleteWorkspace(_ workspace: HopeWorkspace) async {
do {
+53 -10
View File
@@ -21,11 +21,12 @@ struct NativeChatView: View {
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 visible.isEmpty && !store.searching {
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) } }
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) } } }
}
.scrollContentBackground(.hidden)
@@ -65,7 +66,8 @@ struct NativeChatView: View {
.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 }
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) {
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 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 recent: [HopeConversation] { visible.filter { !$0.pinned } }
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] }
} 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)
Text(conversation.title).foregroundStyle(.primary).font(.body.weight(.medium)).lineLimit(2)
HStack(spacing: 6) {
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)
}
.contextMenu {
@@ -127,7 +143,11 @@ struct ChatDetailView: View {
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)
}
.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) }
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") } } }
}
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) {
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")
@@ -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.
struct RichChatMarkdown: View {
let content: String
@@ -277,7 +316,11 @@ struct WorkspaceManagerView: View {
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)
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 = "" } }
}
}
+26 -7
View File
@@ -7,6 +7,8 @@ struct LoginView: View {
@State private var password = ""
@State private var server = ""
@State private var serverError: String?
@State private var savedServer: String?
@State private var passwordVisible = false
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
NavigationStack {
@@ -29,11 +31,21 @@ struct LoginView: View {
Section("Account") {
TextField("Username", text: $username).textContentType(.username)
.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") {
let suppliedPassword = password
password = ""
Task { await auth.login(username: username, password: suppliedPassword) }
Task {
await auth.login(username: username, password: suppliedPassword)
if auth.user != nil { password = "" }
}
}
.font(NearlightFont.control(typeSize))
.buttonStyle(.borderedProminent)
@@ -54,13 +66,19 @@ struct LoginView: View {
.textInputAutocapitalization(.never).autocorrectionDisabled()
Button("Save server address") {
switch ServerConfig.parse(server) {
case .success(let config): settings.serverURL = config.baseURL.absoluteString
case .failure(let error): serverError = error.localizedDescription
case .success(let config):
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 {
Text(serverError).foregroundStyle(Nearlight.alert)
Label(serverError, systemImage: "exclamationmark.circle").foregroundStyle(Nearlight.alert)
.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.")
.font(.footnote).foregroundStyle(Nearlight.quiet)
@@ -81,6 +99,7 @@ struct LoginView: View {
.navigationTitle("Hope")
.toolbarBackground(Nearlight.ground, for: .navigationBar)
.onAppear { server = settings.serverURL }
.onChange(of: server) { _, _ in serverError = nil; savedServer = nil }
}
}
}
@@ -37,7 +37,7 @@ struct ConnectionTestView: View {
if let result {
switch result {
case .success(let response):
Section("Success") {
Section("Server access verified") {
LabeledContent("Room") {
Text(response.room)
.font(.footnote.monospaced())
@@ -45,8 +45,9 @@ struct ConnectionTestView: View {
LabeledContent("User") {
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)
Text("Start a conversation to verify live voice and replies.").font(.footnote).foregroundStyle(.secondary)
}
case .failure(let error):
Section("Error") {
+94 -21
View File
@@ -13,6 +13,7 @@ struct SettingsView: View {
@State private var urlError: String?
@State private var showVoicePicker = false
@State private var savedURLConfirmation: String?
@State private var pendingServerChange: String?
@FocusState private var urlFocused: Bool
init(settings: SettingsStore, session: VoiceSession) {
self.settings = settings
@@ -82,7 +83,7 @@ struct SettingsView: View {
.foregroundStyle(Nearlight.quiet)
.accessibilityIdentifier("settings.server.saved")
}
Button("Save server address") { applyServerURL() }
Button("Change server") { requestServerURLChange() }
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || urlText == settings.serverURL)
if !canApplyServerURL {
Text(session.state == .tearingDown
@@ -108,6 +109,15 @@ struct SettingsView: View {
.sheet(isPresented: $showVoicePicker) {
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
/// left untouched so the active session keeps using its original URL.
/// Guarded: only applies when the session is idle (no active call).
private func applyServerURL() {
private func requestServerURLChange() {
savedURLConfirmation = nil
guard canApplyServerURL else {
urlError = "End the conversation before changing servers."
return
}
guard canApplyServerURL else { urlError = "End the conversation before changing servers."; return }
let trimmed = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
urlError = "Enter a server URL."
return
}
guard !trimmed.isEmpty else { urlError = "Enter a server URL."; return }
switch ServerConfig.parse(trimmed) {
case .success:
settings.serverURL = trimmed
urlText = trimmed
case .success(let config):
pendingServerChange = config.baseURL.absoluteString
urlError = nil
savedURLConfirmation = trimmed
urlFocused = false
case .failure(let error):
urlError = error.localizedDescription
urlFocused = true
}
}
private func applyServerURL(_ canonicalURL: String) {
settings.serverURL = canonicalURL
urlText = canonicalURL
savedURLConfirmation = canonicalURL
urlError = nil
urlFocused = false
}
}
struct VoicePickerView: View {
@ObservedObject var settings: SettingsStore
@ObservedObject var session: VoiceSession
@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 {
NavigationStack {
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 {
settings.voiceID = voice.id
Task { await session.selectVoice(voice.id) }
dismiss()
guard applyingVoiceID == nil else { return }
applyingVoiceID = voice.id
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: {
HStack {
Text(voice.displayName)
VStack(alignment: .leading, spacing: 3) {
Text(voice.name)
Text(voice.details).font(.caption).foregroundStyle(.secondary)
}
if settings.voiceID == voice.id {
Spacer()
Image(systemName: "checkmark")
@@ -163,10 +232,14 @@ struct VoicePickerView: View {
}
}
}
.disabled(applyingVoiceID != nil)
.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 {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
+9 -4
View File
@@ -377,16 +377,21 @@ final class VoiceSession: ObservableObject {
}
}
func selectVoice(_ id: String) async {
settings.voiceID = id
guard isReady else { return }
@discardableResult
func selectVoice(_ id: String) async -> Bool {
let previous = settings.voiceID
guard isReady else { settings.voiceID = id; return true }
let generation = sessionGeneration
do {
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)")
return true
} catch {
settings.voiceID = previous
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 }
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 {
_ = try await request("api/auth/logout", method: "POST", token: token)
}
+24
View File
@@ -28,6 +28,30 @@ struct AuthTests {
config.protocolClasses = [AuthURLProtocol.self]
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 {
let url = URL(string: "https://192.168.86.2:9178")!
let message = ConnectionFailure.message(URLError(.serverCertificateUntrusted), server: url)
+25
View File
@@ -35,6 +35,31 @@ final class ChatTests: XCTestCase {
XCTAssertNil(store.selected)
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 {
let store = ChatStore(api: OfflineChatAPI())
await store.create()
+8 -19
View File
@@ -57,31 +57,20 @@ final class VoiceUITests: XCTestCase {
server.tap()
let original = server.value as? String ?? ""
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))
let invalid = server.value as? String ?? ""
server.tap()
server.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: invalid.count) + " https://127.0.0.1:1 ")
app.buttons["Save server address"].tap()
XCTAssertTrue(app.staticTexts["Server address saved."].waitForExistence(timeout: 5))
XCTAssertEqual(server.value as? String, "https://127.0.0.1:1")
app.buttons["Change server"].tap()
// A validated address presents an explicit account-switch action rather
// 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.
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
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
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
1. **Qualify real audio:** microphone capture, TTS audibility, speaker/headphones,