feat: add authenticated durable native Hope chat

This commit is contained in:
snowspeeder committed 2026-10-03 09:06:06 -04:00
1 parent 74d2b63614
commit 7dcd06c911
15 files changed
+1675 -9

No files matched your search

+282
View File
@@ -0,0 +1,282 @@
import SwiftUI
import UIKit
struct NativeChatView: View {
@ObservedObject var store: ChatStore
let talk: (String) async -> Void
let logout: () async -> Void
@State private var query = ""
@State private var archived = false
@State private var workspace: String?
@State private var path: [String] = []
@State private var rename: HopeConversation?
@State private var delete: HopeConversation?
@State private var title = ""
@State private var showPreferences = false
@State private var showWorkspaces = false
@State private var showLibrary = false
@Environment(\.scenePhase) private var scenePhase
var body: some View {
NavigationStack(path: $path) {
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 !pinned.isEmpty { Section("Pinned") { rows(pinned) } }
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)
.background(HopeTheme.color(.ground, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
.navigationTitle("Chats")
.searchable(text: $query, prompt: "Search chats and messages")
.task(id: query + String(archived)) {
do { try await Task.sleep(for: .milliseconds(250)); try Task.checkCancellation(); await refresh() } catch {}
}
.refreshable { await refresh() }
.onAppear { store.foreground(scenePhase == .active) }
.onChange(of: scenePhase) { _, phase in store.foreground(phase == .active) }
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Menu {
Toggle("Show archived", isOn: $archived)
Picker("Workspace", selection: $workspace) {
Text("All chats").tag(nil as String?)
ForEach(store.workspaces) { Text($0.name).tag(Optional($0.id)) }
}
Button("Library and tools") { showLibrary = true }
Button("Manage workspaces") { showWorkspaces = true }
Button("Appearance and model") { showPreferences = true }
Button("Sign out", role: .destructive) { Task { await logout() } }
} label: { Image(systemName: "line.3.horizontal.decrease.circle").frame(minWidth: 44, minHeight: 44) }
.accessibilityLabel("Chat options")
}
ToolbarItem(placement: .topBarTrailing) {
Button { Task { await store.create(workspace: workspace); if let id = store.selected?.id { path = [id] } } } label: { Image(systemName: "square.and.pencil").frame(minWidth: 44, minHeight: 44) }
.accessibilityLabel("New chat").disabled(store.busy)
}
}
.navigationDestination(for: String.self) { _ in ChatDetailView(store: store, talk: talk) }
.sheet(isPresented: $showLibrary) { ChatLibraryView(store: store.features, selectedAttachmentIds: $store.selectedAttachmentIds) }
.sheet(isPresented: $showPreferences) { ChatPreferencesView(store: store) }
.sheet(isPresented: $showWorkspaces) { WorkspaceManagerView(store: store) }
.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 }
}
.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 }
Button("Cancel", role: .cancel) { delete = nil }
} message: { Text("This removes the conversation, not your account memory.") }
}
}
private var visible: [HopeConversation] { store.conversations.filter { workspace == nil || $0.workspaceId == workspace } }
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) }
@ViewBuilder private func rows(_ items: [HopeConversation]) -> some View {
ForEach(items) { conversation in
Button {
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)
}.frame(minHeight: 44, alignment: .leading)
}
.contextMenu {
Button(conversation.pinned ? "Unpin" : "Pin", systemImage: conversation.pinned ? "pin.slash" : "pin") { Task { await store.patch(conversation, changes: ["pinned": !conversation.pinned]) } }
Button("Rename", systemImage: "pencil") { title = conversation.title; rename = conversation }
Menu("Move to workspace") {
Button("Outside workspaces") { Task { await store.patch(conversation, changes: ["workspace_id": NSNull()]) } }
ForEach(store.workspaces) { space in Button(space.name) { Task { await store.patch(conversation, changes: ["workspace_id": space.id]) } } }
}
Button(conversation.archived ? "Unarchive" : "Archive", systemImage: "archivebox") { Task { await store.patch(conversation, changes: ["archived": !conversation.archived]) } }
Button("Delete", systemImage: "trash", role: .destructive) { delete = conversation }
}
}
}
}
struct ChatDetailView: View {
@ObservedObject var store: ChatStore
let talk: (String) async -> Void
@State private var editing: HopeMessage?
@State private var editedText = ""
@State private var follow = false
@State private var library = false
@Environment(\.scenePhase) private var scenePhase
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 24) {
if store.messages.isEmpty { ContentUnavailableView("What’s on your mind?", systemImage: "bubble.left", description: Text("Write below to begin. Text chat does not connect your microphone.")) }
if store.olderMessagesCursor != nil { Button("Load earlier messages") { follow = false; Task { await store.loadOlder() } } }
ForEach(store.messages) { message in
VStack(alignment: .leading, spacing: 8) {
HStack { Text(message.role == "user" ? "You" : "Hope").font(.subheadline.weight(.semibold)); Spacer(); if message.status != "completed" { Text(message.status.capitalized).font(.caption).foregroundStyle(.secondary) } }
RichChatMarkdown(content: message.content)
Menu {
Button("Copy", systemImage: "doc.on.doc") { UIPasteboard.general.string = message.content }
Button("Branch from here", systemImage: "arrow.triangle.branch") { Task { await store.branch(message) } }
if message.role == "user" { Button("Edit in new branch", systemImage: "pencil") { editing = message; editedText = message.content } }
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)
}
if let progress = store.progress { Label(progress, systemImage: "ellipsis").foregroundStyle(.secondary) }
Color.clear.frame(height: 1).id("chat-bottom")
}.padding(.horizontal, 20).padding(.vertical, 16).frame(maxWidth: 760)
}
.simultaneousGesture(DragGesture().onChanged { _ in follow = false })
.onChange(of: store.messages) { _, _ in if follow { proxy.scrollTo("chat-bottom", anchor: .bottom) } }
.overlay(alignment: .bottomTrailing) {
if !follow && !store.messages.isEmpty { Button("Latest", systemImage: "arrow.down") { follow = true; proxy.scrollTo("chat-bottom", anchor: .bottom) }.buttonStyle(.bordered).padding() }
}
}
.background(HopeTheme.color(.ground, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
.foregroundStyle(HopeTheme.color(.ink, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
.navigationTitle(store.selected?.title ?? "Chat")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("Talk", systemImage: "waveform") { if let id = store.selected?.id { Task { await talk(id) } } }.disabled(store.activeRun != nil)
Button("Export Markdown") { Task { await store.exportConversation(format: "markdown") } }
Button("Export JSON") { Task { await store.exportConversation(format: "json") } }
if let text = store.exportText { ShareLink("Share export", item: text) }
} label: { Image(systemName: "ellipsis.circle").frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Chat actions")
} }
.safeAreaInset(edge: .bottom) {
VStack(alignment: .leading, spacing: 8) {
if let error = store.error { Text(error).font(.footnote).foregroundStyle(.red); if store.activeRun != nil { Button("Resume response") { store.resume() } } }
if let approval = store.approval {
Text(approval.prompt).font(.body)
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 = [] } }
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")
if store.activeRun != nil { Button("Stop", systemImage: "stop.fill") { Task { await store.stop() } }.frame(minWidth: 44, minHeight: 44) }
else { Button { follow = true; Task { await store.send() } } label: { Image(systemName: "arrow.up.circle.fill").font(.title).frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Send message").disabled(store.busy || store.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
}
}.padding(16).background(.regularMaterial)
}
.sheet(isPresented: $library) { ChatLibraryView(store: store.features, selectedAttachmentIds: $store.selectedAttachmentIds) }
.sheet(item: $editing) { message in
NavigationStack {
Form { Section("Edit message") { TextEditor(text: $editedText).frame(minHeight: 200) }; Text("A new branch preserves the original conversation.").font(.footnote).foregroundStyle(.secondary) }
.navigationTitle("Edit in branch")
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { editing = nil } }; ToolbarItem(placement: .confirmationAction) { Button("Send") { editing = nil; Task { await store.branch(message, edited: editedText) } }.disabled(editedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } }
}
}
.onChange(of: scenePhase) { _, phase in if phase == .active { store.resume() } }
}
}
/// Native selectable prose and fenced code; no WebView or remote rendering.
struct RichChatMarkdown: View {
let content: String
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
if block.code {
VStack(alignment: .leading, spacing: 4) {
HStack { Text(block.language.isEmpty ? "Code" : block.language).font(.caption).foregroundStyle(.secondary); Spacer(); Button("Copy code") { UIPasteboard.general.string = block.text }.font(.caption).frame(minHeight: 44) }
ScrollView(.horizontal) { Text(block.text).font(.system(.body, design: .monospaced)).textSelection(.enabled).fixedSize(horizontal: true, vertical: false) }
}.padding(12).background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
} else {
ForEach(Array(block.text.components(separatedBy: "\n").enumerated()), id: \.offset) { _, line in
if line.hasPrefix("# ") { markdown(String(line.dropFirst(2))).font(.title2.weight(.semibold)) }
else if line.hasPrefix("## ") { markdown(String(line.dropFirst(3))).font(.title3.weight(.semibold)) }
else if line.hasPrefix("### ") { markdown(String(line.dropFirst(4))).font(.headline) }
else if line.hasPrefix("- ") || line.hasPrefix("* ") { HStack(alignment: .top) { Text("•"); markdown(String(line.dropFirst(2))) } }
else if line.hasPrefix("> ") { markdown(String(line.dropFirst(2))).foregroundStyle(.secondary) }
else { markdown(line) }
}
}
}
}.textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
}
private func markdown(_ text: String) -> Text {
Text((try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text))
}
private var blocks: [(text: String, code: Bool, language: String)] {
let pieces = content.components(separatedBy: "```")
return pieces.enumerated().map { i, piece in
if i.isMultiple(of: 2) { return (piece, false, "") }
let lines = piece.components(separatedBy: "\n")
return (lines.dropFirst().joined(separator: "\n").trimmingCharacters(in: .newlines), true, lines.first ?? "")
}
}
}
struct ChatPreferencesView: View {
@ObservedObject var store: ChatStore
@Environment(\.dismiss) private var dismiss
@State private var palette = "opal"
@State private var appearance = "system"
var body: some View {
NavigationStack {
Form {
Section("Appearance") {
Picker("Palette", selection: $palette) { ForEach(["opal", "graphite", "ocean", "sage", "lavender"], id: \.self) { Text($0.capitalized).tag($0) } }
Picker("Appearance", selection: $appearance) { ForEach(["system", "light", "dark"], id: \.self) { Text($0.capitalized).tag($0) } }
Button("Save appearance") { Task { await store.savePreferences(palette: palette, appearance: appearance) } }
}
Section("Server model") {
if !store.models.isEmpty {
Picker("Model", selection: $store.model) { ForEach(store.models) { Text($0.id).tag($0.id) } }
if let levels = store.models.first(where: { $0.id == store.model })?.thinkingLevels, !levels.isEmpty { Picker("Thinking", selection: $store.thinkingLevel) { ForEach(levels, id: \.self) { Text($0.capitalized).tag($0) } } }
Button("Save model settings") { Task { await store.saveModel() } }
}
if let note = store.modelNote { Text(note).font(.footnote).foregroundStyle(.secondary) }
Text("Models are provided by your authenticated Hope server. No direct provider connection.").font(.footnote).foregroundStyle(.secondary)
}
if let error = store.error { Text(error).foregroundStyle(.red) }
}
.navigationTitle("Chat preferences")
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
.task { palette = store.preferences.palette; appearance = store.preferences.appearance; await store.loadModels() }
}
}
}
struct WorkspaceManagerView: View {
@ObservedObject var store: ChatStore
@Environment(\.dismiss) private var dismiss
@State private var editing: HopeWorkspace?
@State private var name = ""
@State private var instructions = ""
@State private var deleting: HopeWorkspace?
var body: some View {
NavigationStack {
Form {
Section("Workspaces") {
ForEach(store.workspaces) { space in
Button(space.name) { editing = space; name = space.name; instructions = space.instructions }
.swipeActions { Button("Delete", role: .destructive) { deleting = space } }
}
}
Section(editing == nil ? "New workspace" : "Edit workspace") {
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)
if editing != nil { Button("Cancel edit") { editing = nil; name = ""; instructions = "" } }
}
}
.navigationTitle("Workspaces")
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
.confirmationDialog("Delete workspace?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) {
Button("Delete workspace", role: .destructive) { if let deleting { Task { await store.deleteWorkspace(deleting) } }; deleting = nil }
} message: { Text("Its chats are retained outside the workspace.") }
}
}
}