409 lines
28 KiB
Swift
409 lines
28 KiB
Swift
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.") }
|
||
}
|
||
}
|
||
}
|