Archived
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
@@ -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