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

+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 = "" } }
}
}