Files

409 lines
28 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import UIKit
struct NativeChatView: View {
@ObservedObject var store: ChatStore
let talk: (String) async -> Void
let logout: () async -> Void
let serverURL: String
let accountName: String
let voiceConversationLocked: (String) -> Bool
@State private var libraryDestination: ChatLibraryDestination = .files
@State private var attachmentGeneration = 0
@State private var showSecurity = false
@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 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) } }
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, workspace: workspace, 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) + (workspace ?? "")) {
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 {
Menu("Settings", systemImage: "gearshape") {
Button("Appearance and model") { showPreferences = true }
Button("Security", systemImage: "lock.shield") { showSecurity = true }
Button("Manage workspaces") { showWorkspaces = true }
Button("Sign out", role: .destructive) { Task { await logout() } }
}
Menu("Filter chats", systemImage: "line.3.horizontal.decrease") {
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", systemImage: "books.vertical") { openLibrary(.files) }
Button("Scheduled tasks", systemImage: "calendar") { openLibrary(.tasks) }
Button("Memory & data", systemImage: "brain") { openLibrary(.memory) }
} 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, initialDestination: libraryDestination, selectedAttachmentIds: store.selectedAttachmentIds, onUseAttachment: { item in
guard await store.queueAttachment(item, expectedDraftGeneration: attachmentGeneration) else { return false }
if let id = store.selected?.id { path = [id] }
return true
}, onRemoveAttachment: { store.selectedAttachmentIds.remove($0) })
}
.sheet(isPresented: $showSecurity) { ChatSecurityView(serverURL: serverURL, accountName: accountName, logout: logout) }
.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, !voiceConversationLocked(rename.id) { 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, !voiceConversationLocked(delete.id) { 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 }
private func openLibrary(_ destination: ChatLibraryDestination) {
libraryDestination = destination; attachmentGeneration = store.draftGeneration; showLibrary = true
}
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, workspace: workspace) }
@ViewBuilder private func rows(_ items: [HopeConversation]) -> some View {
ForEach(items) { conversation in
HStack(alignment: .center, spacing: 8) {
Button {
Task { await store.open(conversation); path = [conversation.id] }
} label: {
VStack(alignment: .leading, spacing: 4) {
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)
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: .leading)
.contextMenu { conversationActions(conversation) }
Menu { conversationActions(conversation) } label: {
Image(systemName: "ellipsis").frame(minWidth: 44, minHeight: 44).contentShape(Rectangle())
}
.accessibilityLabel("Actions for \(conversation.title)")
.disabled(!store.canMutate(conversation) || voiceConversationLocked(conversation.id))
}
}
}
@ViewBuilder private func conversationActions(_ conversation: HopeConversation) -> some View {
Group {
Button(conversation.pinned ? "Unpin" : "Pin", systemImage: conversation.pinned ? "pin.slash" : "pin") { Task { guard !voiceConversationLocked(conversation.id) else { return }; 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 { guard !voiceConversationLocked(conversation.id) else { return }; await store.patch(conversation, changes: ["workspace_id": NSNull()]) } }
ForEach(store.workspaces) { space in Button(space.name) { Task { guard !voiceConversationLocked(conversation.id) else { return }; await store.patch(conversation, changes: ["workspace_id": space.id]) } } }
}
Button(conversation.archived ? "Unarchive" : "Archive", systemImage: "archivebox") { Task { guard !voiceConversationLocked(conversation.id) else { return }; await store.patch(conversation, changes: ["archived": !conversation.archived]) } }
Button("Delete", systemImage: "trash", role: .destructive) { delete = conversation }
}.disabled(!store.canMutate(conversation) || voiceConversationLocked(conversation.id))
}
}
struct ChatSecurityView: View {
let serverURL: String
let accountName: String
let logout: () async -> Void
@Environment(\.dismiss) private var dismiss
@State private var confirmSignOut = false
var body: some View {
NavigationStack {
Form {
Section {
LabeledContent("Connected server", value: URL(string: serverURL)?.host ?? serverURL)
LabeledContent("Connection", value: "HTTPS · Encrypted in transit")
LabeledContent("Signed-in account", value: accountName)
} header: { Text("Your connection") } footer: {
Text("Hope uses this server for your private account. The app checks the configured HTTPS certificate; changing servers signs you out.")
}
Section {
Text("Your sign-in is stored securely in this iPhone’s Keychain. Signing out removes local account access and ends the current session; it does not delete conversations, files, or saved memory on your server.")
Button("Sign out of this iPhone", role: .destructive) { confirmSignOut = true }
} header: { Text("Session access") }
Section("Password and account access") {
Text("Password resets are managed by your Hope administrator. Device-session lists and two-factor settings are not available in this app.")
}
}
.navigationTitle("Security")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
.confirmationDialog("Sign out of Hope?", isPresented: $confirmSignOut, titleVisibility: .visible) {
Button("Sign out", role: .destructive) { Task { dismiss(); await logout() } }
} message: { Text("Your account data stays on the server. You’ll need to sign in again on this iPhone.") }
}
}
}
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
@State private var attachmentGeneration = 0
@FocusState private var composerFocused: Bool
@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)
}
.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")
}.padding(.horizontal, 20).padding(.vertical, 16).frame(maxWidth: 760)
}
.scrollDismissesKeyboard(.interactively)
.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 file = store.exportFile { ShareLink("Share export file", item: file, preview: SharePreview(file.filename)) }
} 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 {
AttachmentSelectionSummary(store: store)
if !store.canSendAttachments { Text("A queued file is no longer supported. Remove it or retry file support in Library.").font(.footnote).foregroundStyle(.secondary) }
}
HStack(alignment: .bottom) {
Button { attachmentGeneration = store.draftGeneration; 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").focused($composerFocused)
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.canSendAttachments || store.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
}
}.padding(16).background(.regularMaterial)
}
.sheet(isPresented: $library, onDismiss: { if !store.selectedAttachmentIds.isEmpty { composerFocused = true } }) {
ChatLibraryView(store: store.features, selectedAttachmentIds: store.selectedAttachmentIds, onUseAttachment: { await store.queueAttachment($0, expectedDraftGeneration: attachmentGeneration) }, onRemoveAttachment: { store.selectedAttachmentIds.remove($0) })
}
.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() } }
}
}
private struct AttachmentSelectionSummary: View {
@ObservedObject var store: ChatStore
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Attached to next message (\(store.selectedAttachmentIds.count)/8)").font(.caption.weight(.semibold))
ForEach(store.selectedAttachmentIds.sorted(), id: \.self) { id in
HStack {
Label(store.queuedAttachment(id)?.filename ?? "Unavailable attachment", systemImage: "doc").font(.caption).lineLimit(1)
Spacer()
Button("Remove") { store.selectedAttachmentIds.remove(id) }.font(.caption).frame(minWidth: 44, minHeight: 44)
.accessibilityLabel("Remove \(store.queuedAttachment(id)?.filename ?? "unavailable attachment")")
}
}
}
}
}
/// 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.trimmingCharacters(in: .whitespaces).hasPrefix("|"), line.contains("|") {
let cells = line.split(separator: "|", omittingEmptySubsequences: true).map { String($0).trimmingCharacters(in: .whitespaces) }
if !cells.allSatisfy({ $0.allSatisfy { "-: ".contains($0) } }) {
HStack(alignment: .top, spacing: 12) { ForEach(Array(cells.enumerated()), id: \.offset) { _, cell in markdown(cell).frame(maxWidth: .infinity, alignment: .leading) } }
.padding(.vertical, 6).accessibilityElement(children: .combine)
Divider()
}
}
else 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 {
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 = "" } }
}
}
.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.") }
}
}
}