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
@@ -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 = "" } }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user