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.") } } } }