feat: refine native iOS chat experience
This commit is contained in:
1 parent
9c5e9bce5b
commit
7bd893ae5c
14 files changed
+415
-93
No files matched your search
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = "" } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user