feat(ios): align chat library memory and tasks with web
This commit is contained in:
1 parent
8905dc2c2a
commit
235dd65487
12 files changed
+957
-128
No files matched your search
@@ -30,24 +30,94 @@ final class ChatFeatureStore: ObservableObject {
|
||||
private let api: ChatTransport
|
||||
private var epoch = 0
|
||||
private var closed = false
|
||||
private var capabilityGeneration = 0
|
||||
private var loadGeneration = 0
|
||||
init(api: ChatTransport) { self.api = api }
|
||||
private func call<T: Decodable>(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T {
|
||||
guard !closed else { throw CancellationError() }
|
||||
let current = epoch
|
||||
let data = try await api.request(path, method: method, body: body, query: query)
|
||||
guard !closed, current == epoch else { throw CancellationError() }
|
||||
try Task.checkCancellation()
|
||||
return try ChatCoding.decoder.decode(T.self, from: data)
|
||||
}
|
||||
func load() async {
|
||||
@discardableResult
|
||||
func refreshCapabilities() async -> Bool {
|
||||
guard !closed else { return false }
|
||||
capabilityGeneration += 1
|
||||
let generation = capabilityGeneration
|
||||
enabled = [] // Never retain image qualification while a refresh is pending or failed.
|
||||
do {
|
||||
let caps: FeatureCapabilities = try await call("api/capabilities")
|
||||
guard generation == capabilityGeneration else { return false }
|
||||
enabled = Set(caps.features.filter { $0.value.enabled }.map(\.key))
|
||||
if enabled.contains("attachments") { struct Page: Decodable { let attachments: [ChatAttachment] }; let page: Page = try await call("api/attachments"); attachments = page.attachments }
|
||||
if enabled.contains("artifacts") { struct Page: Decodable { let artifacts: [ChatArtifact] }; let page: Page = try await call("api/artifacts"); artifacts = page.artifacts }
|
||||
if enabled.contains("scheduled_tasks") { struct Page: Decodable { let tasks: [ScheduledChatTask] }; let page: Page = try await call("api/tasks"); tasks = page.tasks }
|
||||
if enabled.contains("memory_controls") { struct Page: Decodable { let sections: [ChatMemory] }; let page: Page = try await call("api/memory"); memory = page.sections }
|
||||
return true
|
||||
} catch {
|
||||
guard generation == capabilityGeneration else { return false }
|
||||
report(error); return false
|
||||
}
|
||||
}
|
||||
func load() async {
|
||||
loadGeneration += 1
|
||||
let generation = loadGeneration
|
||||
guard await refreshCapabilities() else { return }
|
||||
let capabilities = capabilityGeneration
|
||||
do {
|
||||
if enabled.contains("attachments") {
|
||||
struct Page: Decodable { let attachments: [ChatAttachment] }
|
||||
let page: Page = try await call("api/attachments")
|
||||
guard generation == loadGeneration, capabilities == capabilityGeneration else { return }
|
||||
attachments = page.attachments
|
||||
} else { attachments = [] }
|
||||
if enabled.contains("artifacts") {
|
||||
struct Page: Decodable { let artifacts: [ChatArtifact] }
|
||||
let page: Page = try await call("api/artifacts")
|
||||
guard generation == loadGeneration, capabilities == capabilityGeneration else { return }
|
||||
artifacts = page.artifacts
|
||||
} else { artifacts = [] }
|
||||
if enabled.contains("scheduled_tasks") {
|
||||
struct Page: Decodable { let tasks: [ScheduledChatTask] }
|
||||
let page: Page = try await call("api/tasks")
|
||||
guard generation == loadGeneration, capabilities == capabilityGeneration else { return }
|
||||
tasks = page.tasks
|
||||
} else { tasks = [] }
|
||||
if enabled.contains("memory_controls") {
|
||||
struct Page: Decodable { let sections: [ChatMemory] }
|
||||
let page: Page = try await call("api/memory")
|
||||
guard generation == loadGeneration, capabilities == capabilityGeneration else { return }
|
||||
memory = page.sections
|
||||
} else { memory = [] }
|
||||
error = nil
|
||||
} catch { report(error) }
|
||||
} catch { if generation == loadGeneration { report(error) } }
|
||||
}
|
||||
static func blockedReason(_ item: ChatAttachment, imageInput: Bool) -> String? {
|
||||
if ["failed", "encrypted", "unavailable"].contains(item.extractionStatus) {
|
||||
return "This file cannot be used: \(item.extractionStatus). Preview it or upload a readable copy."
|
||||
}
|
||||
guard ["ready", "truncated", "image"].contains(item.extractionStatus) else {
|
||||
return "This file is not ready to use. Refresh and try again."
|
||||
}
|
||||
if item.mime.hasPrefix("image/") && !imageInput { return "Image input is not available for the current model. Choose a text file or PDF." }
|
||||
return nil
|
||||
}
|
||||
func validatedAttachment(_ item: ChatAttachment) async -> ChatAttachment? {
|
||||
guard await refreshCapabilities(), enabled.contains("attachments") else {
|
||||
if !closed { error = "Private files are unavailable. Refresh and try again." }; return nil
|
||||
}
|
||||
let generation = capabilityGeneration
|
||||
do {
|
||||
struct Envelope: Decodable { let attachment: ChatAttachment }
|
||||
let result: Envelope = try await call("api/attachments/\(item.id)")
|
||||
guard generation == capabilityGeneration else { return nil }
|
||||
if let reason = Self.blockedReason(result.attachment, imageInput: enabled.contains("image_input")) { error = reason; return nil }
|
||||
if let i = attachments.firstIndex(where: { $0.id == item.id }) { attachments[i] = result.attachment }
|
||||
error = nil; return result.attachment
|
||||
} catch {
|
||||
guard !closed, generation == capabilityGeneration else { return nil }
|
||||
if case ChatFailure.status(404) = error { self.error = "This file is no longer available. Refresh your files." }
|
||||
else { report(error) }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
private func report(_ value: Error) {
|
||||
guard !(value is CancellationError), !closed else { return }
|
||||
@@ -55,7 +125,9 @@ final class ChatFeatureStore: ObservableObject {
|
||||
error = value.localizedDescription
|
||||
}
|
||||
func upload(_ data: Data, filename: String, mime: String) async -> ChatAttachment? {
|
||||
guard !busy, enabled.contains("attachments") else { return nil }
|
||||
guard !busy, !closed else { return nil }
|
||||
guard await refreshCapabilities(), enabled.contains("attachments"), !busy else { return nil }
|
||||
if mime.hasPrefix("image/"), !enabled.contains("image_input") { error = "Image input is unavailable for the current model. Choose a text file or PDF."; return nil }
|
||||
guard data.count <= 8 * 1024 * 1024 else { error = "Choose a file smaller than 8 MiB."; return nil }
|
||||
busy = true; defer { busy = false }
|
||||
do {
|
||||
@@ -68,6 +140,7 @@ final class ChatFeatureStore: ObservableObject {
|
||||
} catch { report(error); return nil }
|
||||
}
|
||||
func preview(_ attachment: ChatAttachment) async {
|
||||
guard !closed else { return }
|
||||
previewAttachment = attachment
|
||||
previewLoading = true
|
||||
previewError = nil
|
||||
@@ -121,10 +194,13 @@ final class ChatFeatureStore: ObservableObject {
|
||||
do { struct Result: Decodable { let artifact: ChatArtifact }; let result: Result = try await call("api/artifacts/\(item.id)", query: [URLQueryItem(name: "revision", value: String(revision))]); return result.artifact } catch { report(error); return nil }
|
||||
}
|
||||
func deleteArtifact(_ item: ChatArtifact) async { await remove("api/artifacts/\(item.id)"); if error == nil { artifacts.removeAll { $0.id == item.id } } }
|
||||
func saveTask(_ item: ScheduledChatTask?, name: String, schedule: String, prompt: String) async {
|
||||
func saveTask(_ item: ScheduledChatTask?, name: String, schedule: String, prompt: String, repeatCount: Int? = nil, paused: Bool = false) async {
|
||||
do {
|
||||
struct Result: Decodable { let task: ScheduledChatTask }
|
||||
let result: Result = try await call(item.map { "api/tasks/\($0.id)" } ?? "api/tasks", method: item == nil ? "POST" : "PATCH", body: ["name": name, "schedule": schedule, "prompt": prompt])
|
||||
var body: [String: Any] = ["name": name, "schedule": schedule, "prompt": prompt]
|
||||
if let repeatCount { guard repeatCount > 0 else { error = "Repeat count must be a positive whole number."; return }; body["repeat"] = repeatCount }
|
||||
if item == nil { body["paused"] = paused }
|
||||
let result: Result = try await call(item.map { "api/tasks/\($0.id)" } ?? "api/tasks", method: item == nil ? "POST" : "PATCH", body: body)
|
||||
if let i = tasks.firstIndex(where: { $0.id == result.task.id }) { tasks[i] = result.task } else { tasks.insert(result.task, at: 0) }; error = nil
|
||||
} catch { report(error) }
|
||||
}
|
||||
@@ -132,15 +208,37 @@ final class ChatFeatureStore: ObservableObject {
|
||||
do { struct Result: Decodable { let task: ScheduledChatTask }; let result: Result = try await call("api/tasks/\(item.id)/\(action)", method: "POST"); if let i = tasks.firstIndex(where: { $0.id == item.id }) { tasks[i] = result.task }; error = nil } catch { report(error) }
|
||||
}
|
||||
func deleteTask(_ item: ScheduledChatTask) async { await remove("api/tasks/\(item.id)"); if error == nil { tasks.removeAll { $0.id == item.id } } }
|
||||
func saveMemory(_ item: ChatMemory, content: String) async {
|
||||
do { let result: ChatMemory = try await call("api/memory/\(item.section)", method: "PUT", body: ["revision": item.revision, "content": content]); if let i = memory.firstIndex(where: { $0.id == item.id }) { memory[i] = result }; error = nil } catch { report(error) }
|
||||
func latestMemory(_ item: ChatMemory) async -> ChatMemory? {
|
||||
do {
|
||||
struct Page: Decodable { let sections: [ChatMemory] }
|
||||
let page: Page = try await call("api/memory")
|
||||
guard let latest = page.sections.first(where: { $0.section == item.section }) else { throw ChatFailure.invalidResponse }
|
||||
memory = page.sections; error = nil; return latest
|
||||
} catch { report(error); return nil }
|
||||
}
|
||||
func close() { closed = true; epoch += 1; enabled = []; attachments = []; artifacts = []; tasks = []; memory = []; clearPreview(); error = nil }
|
||||
@discardableResult
|
||||
func saveMemory(_ item: ChatMemory, content: String) async -> Bool {
|
||||
do {
|
||||
let result: ChatMemory = try await call("api/memory/\(item.section)", method: "PUT", body: ["revision": item.revision, "content": content])
|
||||
if let i = memory.firstIndex(where: { $0.id == item.id }) { memory[i] = result }
|
||||
error = nil; return true
|
||||
} catch {
|
||||
if case ChatFailure.status(409) = error { self.error = "Memory changed on the server. Compare the latest version, then explicitly accept its revision before saving your draft." }
|
||||
else { report(error) }
|
||||
return false
|
||||
}
|
||||
}
|
||||
func close() { closed = true; epoch += 1; capabilityGeneration += 1; loadGeneration += 1; enabled = []; attachments = []; artifacts = []; tasks = []; memory = []; clearPreview(); error = nil }
|
||||
}
|
||||
|
||||
enum ChatLibraryDestination { case files, tasks, memory }
|
||||
|
||||
struct ChatLibraryView: View {
|
||||
@ObservedObject var store: ChatFeatureStore
|
||||
@Binding var selectedAttachmentIds: Set<String>
|
||||
var initialDestination: ChatLibraryDestination = .files
|
||||
var selectedAttachmentIds: Set<String> = []
|
||||
var onUseAttachment: ((ChatAttachment) async -> Bool)?
|
||||
var onRemoveAttachment: ((String) -> Void)?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var importing = false
|
||||
@State private var photo: PhotosPickerItem?
|
||||
@@ -156,6 +254,15 @@ struct ChatLibraryView: View {
|
||||
@State private var name = ""
|
||||
@State private var schedule = ""
|
||||
@State private var prompt = ""
|
||||
@State private var repeatCount = ""
|
||||
@State private var createPaused = true
|
||||
@State private var usingAttachment = false
|
||||
@State private var feedback: String?
|
||||
@State private var latestMemory: ChatMemory?
|
||||
@State private var acceptedMemory: ChatMemory?
|
||||
@State private var memoryWorking = false
|
||||
@State private var memoryRequiresReview = false
|
||||
@State private var confirmingClear = false
|
||||
@State private var memory: ChatMemory?
|
||||
@State private var memoryContent = ""
|
||||
@State private var deleting: (() async -> Void)?
|
||||
@@ -165,57 +272,75 @@ struct ChatLibraryView: View {
|
||||
if let error = store.error { Section { Text(error).foregroundStyle(.red); Button("Retry") { Task { await store.load() } } } }
|
||||
if loadingCapabilities {
|
||||
ContentUnavailableView("Checking server capabilities", systemImage: "server.rack", description: Text("Only features your account supports are shown."))
|
||||
} else if store.enabled.isEmpty {
|
||||
ContentUnavailableView("Library features aren’t available", systemImage: "lock", description: Text("Your server has not enabled private files, documents, schedules, or memory controls for this account."))
|
||||
} else if (initialDestination == .files && !store.enabled.contains("attachments") && !store.enabled.contains("artifacts")) || (initialDestination == .tasks && !store.enabled.contains("scheduled_tasks")) || (initialDestination == .memory && !store.enabled.contains("memory_controls")) {
|
||||
ContentUnavailableView("Not available on this server", systemImage: "lock", description: Text("These features must be enabled for your account by the Hope server."))
|
||||
}
|
||||
if store.enabled.contains("attachments") {
|
||||
Section("Files") {
|
||||
Button("Import from Files", systemImage: "doc.badge.plus") { importing = true }.disabled(store.busy)
|
||||
if store.enabled.contains("image_input") { PhotosPicker(selection: $photo, matching: .images) { Label("Import photo", systemImage: "photo") }.disabled(store.busy) }
|
||||
if initialDestination == .files && store.enabled.contains("attachments") {
|
||||
Section("Private files") {
|
||||
Text("Upload private files, then use them in your next message. Nothing is sent automatically.").font(.footnote).foregroundStyle(.secondary)
|
||||
if let feedback { Text(feedback).font(.footnote) }
|
||||
if store.busy { ProgressView("Uploading private file…") }
|
||||
Button("Upload file", systemImage: "doc.badge.plus") { importing = true }.disabled(store.busy)
|
||||
if store.enabled.contains("image_input") { PhotosPicker(selection: $photo, matching: .images) { Label("Upload photo", systemImage: "photo") }.disabled(store.busy) }
|
||||
ForEach(store.attachments) { item in
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Button {
|
||||
if selectedAttachmentIds.contains(item.id) { selectedAttachmentIds.remove(item.id) }
|
||||
else if selectedAttachmentIds.count < 8 { selectedAttachmentIds.insert(item.id) }
|
||||
else { store.error = "Select up to 8 files for one message." }
|
||||
} label: { Label(item.filename, systemImage: selectedAttachmentIds.contains(item.id) ? "checkmark.circle.fill" : "circle").frame(minHeight: 44) }
|
||||
.disabled(item.extractionStatus == "failed" || item.extractionStatus == "encrypted" || (item.mime.hasPrefix("image/") && !store.enabled.contains("image_input")))
|
||||
Text(item.filename).font(.body)
|
||||
Spacer()
|
||||
Button("Preview") { showingPreview = true; Task { await store.preview(item) } }.frame(minHeight: 44)
|
||||
}
|
||||
Text("\(ByteCountFormatter.string(fromByteCount: Int64(item.size), countStyle: .file)) · \(item.extractionStatus)").font(.caption).foregroundStyle(.secondary)
|
||||
}.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteAttachment(item); selectedAttachmentIds.remove(item.id) } } }
|
||||
if let reason = ChatFeatureStore.blockedReason(item, imageInput: store.enabled.contains("image_input")) {
|
||||
Text(reason).font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
if selectedAttachmentIds.contains(item.id) {
|
||||
Label("Queued for your next message", systemImage: "checkmark.circle.fill").font(.footnote)
|
||||
Button("Remove from message") { onRemoveAttachment?(item.id) }
|
||||
} else {
|
||||
Button("Use in conversation", systemImage: "arrow.up.message") {
|
||||
Task {
|
||||
usingAttachment = true; defer { usingAttachment = false }
|
||||
guard let validated = await store.validatedAttachment(item) else { return }
|
||||
guard let onUseAttachment else { store.error = "Open Library from a conversation to use this file."; return }
|
||||
if await onUseAttachment(validated) { dismiss() }
|
||||
}
|
||||
}.disabled(usingAttachment || store.busy || onUseAttachment == nil || ChatFeatureStore.blockedReason(item, imageInput: store.enabled.contains("image_input")) != nil)
|
||||
.accessibilityHint("Queues this private file in your draft. Does not send a message.")
|
||||
}
|
||||
}.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteAttachment(item); if store.error == nil { onRemoveAttachment?(item.id) } } } }
|
||||
}
|
||||
Text("Select up to 8 files for the next message. Uploading alone does not send them.").font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if store.enabled.contains("artifacts") {
|
||||
Section("Artifacts") {
|
||||
Button("New artifact", systemImage: "doc.text") { artifact = nil; title = ""; content = ""; kind = "markdown"; artifactEditor = true }
|
||||
if initialDestination == .files && store.enabled.contains("artifacts") {
|
||||
Section("Saved drafts") {
|
||||
Text("Documents you save here are separate from conversation history.").font(.footnote).foregroundStyle(.secondary)
|
||||
Button("New saved draft", systemImage: "doc.text") { artifact = nil; title = ""; content = ""; kind = "markdown"; artifactEditor = true }
|
||||
ForEach(store.artifacts) { item in Button { Task { if let detail = await store.detail(item) { artifact = detail; title = detail.title; kind = detail.kind; content = detail.content ?? ""; artifactEditor = true } } } label: { VStack(alignment: .leading) { Text(item.title); Text("Revision \(item.revision) · \(item.kind)").font(.caption).foregroundStyle(.secondary) } }.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteArtifact(item) } } } }
|
||||
}
|
||||
}
|
||||
if store.enabled.contains("scheduled_tasks") {
|
||||
if initialDestination == .tasks && store.enabled.contains("scheduled_tasks") {
|
||||
Section("Scheduled tasks") {
|
||||
Text("Tasks run on your Hope server. Push notifications and task output history are not available in this app.").font(.footnote).foregroundStyle(.secondary)
|
||||
Button("New scheduled task", systemImage: "calendar.badge.plus") { task = nil; name = ""; schedule = ""; prompt = ""; taskEditor = true }
|
||||
Button("New scheduled task", systemImage: "calendar.badge.plus") { task = nil; name = ""; schedule = ""; prompt = ""; repeatCount = ""; createPaused = true; taskEditor = true }
|
||||
ForEach(store.tasks) { item in
|
||||
VStack(alignment: .leading) {
|
||||
Button(item.name ?? item.id) { task = item; name = item.name ?? ""; schedule = item.schedule ?? ""; prompt = item.prompt ?? ""; taskEditor = true }.frame(minHeight: 44)
|
||||
Text(item.schedule ?? "Schedule not provided").font(.caption).foregroundStyle(.secondary)
|
||||
Button(item.name ?? item.id) { task = item; name = item.name ?? ""; schedule = item.schedule ?? ""; prompt = item.prompt ?? ""; repeatCount = item.repeat.map(String.init) ?? ""; taskEditor = true }.frame(minHeight: 44)
|
||||
Text("\(item.enabled == true ? "Active" : item.enabled == false ? "Paused" : "Status not provided") · \(item.schedule ?? "Schedule not provided")").font(.caption).foregroundStyle(.secondary)
|
||||
HStack { Button(item.enabled == false ? "Resume" : "Pause") { Task { await store.taskAction(item, action: item.enabled == false ? "resume" : "pause") } }; Button("Run now") { Task { await store.taskAction(item, action: "run") } } }
|
||||
}.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteTask(item) } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
if store.enabled.contains("memory_controls") {
|
||||
Section("Account memory") {
|
||||
ForEach(store.memory) { item in Button(item.section.capitalized) { memory = item; memoryContent = item.content } }
|
||||
if initialDestination == .memory && store.enabled.contains("memory_controls") {
|
||||
Section("Durable account memory") {
|
||||
Text("Guidance shapes future replies. About you stores lasting preferences and personal context. Neither is your conversation history.").font(.footnote).foregroundStyle(.secondary)
|
||||
ForEach(store.memory) { item in Button(item.section == "user" ? "About you" : "Guidance") { memory = item; memoryContent = item.content; latestMemory = nil; acceptedMemory = nil; memoryRequiresReview = false } }
|
||||
Text("Changes may take effect on the next Hermes run or session. Clearing memory does not delete chats or all account data.").font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Library and tools")
|
||||
.navigationTitle(initialDestination == .files ? "Library" : initialDestination == .tasks ? "Scheduled tasks" : "Memory")
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
|
||||
.task { await store.load(); loadingCapabilities = false }
|
||||
.fileImporter(isPresented: $importing, allowedContentTypes: [.plainText, .pdf, .png, .jpeg, UTType(filenameExtension: "md") ?? .plainText, UTType(filenameExtension: "webp") ?? .image]) { result in
|
||||
@@ -226,11 +351,11 @@ struct ChatLibraryView: View {
|
||||
guard size <= 8 * 1024 * 1024 else { store.error = "Choose a file smaller than 8 MiB."; return }
|
||||
let mime: String
|
||||
switch url.pathExtension.lowercased() { case "md": mime = "text/markdown"; case "pdf": mime = "application/pdf"; case "png": mime = "image/png"; case "jpg", "jpeg": mime = "image/jpeg"; case "webp": mime = "image/webp"; default: mime = "text/plain" }
|
||||
_ = await store.upload(try Data(contentsOf: url), filename: url.lastPathComponent, mime: mime)
|
||||
if let uploaded = await store.upload(try Data(contentsOf: url), filename: url.lastPathComponent, mime: mime) { feedback = "\(uploaded.filename) uploaded privately. Tap Use in conversation to queue it." }
|
||||
} catch { store.error = "Could not read this file. Select it again to retry." }
|
||||
} }
|
||||
}
|
||||
.onChange(of: photo) { _, value in Task { if let data = try? await value?.loadTransferable(type: Data.self), data.count <= 8 * 1024 * 1024, let image = UIImage(data: data), let jpeg = image.jpegData(compressionQuality: 0.85) { _ = await store.upload(jpeg, filename: "photo.jpg", mime: "image/jpeg") } else if value != nil { store.error = "This photo could not be imported or exceeds 8 MiB. Choose a smaller JPEG or PNG." }; photo = nil } }
|
||||
.onChange(of: photo) { _, value in Task { if let data = try? await value?.loadTransferable(type: Data.self), data.count <= 8 * 1024 * 1024, let image = UIImage(data: data), let jpeg = image.jpegData(compressionQuality: 0.85) { if await store.upload(jpeg, filename: "photo.jpg", mime: "image/jpeg") != nil { feedback = "Photo uploaded privately. Tap Use in conversation to queue it." } } else if value != nil { store.error = "This photo could not be imported or exceeds 8 MiB. Choose a smaller JPEG or PNG." }; photo = nil } }
|
||||
.sheet(isPresented: $showingPreview, onDismiss: { store.clearPreview() }) {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
@@ -248,20 +373,7 @@ struct ChatLibraryView: View {
|
||||
}
|
||||
.sheet(isPresented: $artifactEditor) { artifactForm }
|
||||
.sheet(isPresented: $taskEditor) { taskForm }
|
||||
.sheet(item: $memory) { item in
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Memory") { TextEditor(text: $memoryContent).frame(minHeight: 260).accessibilityLabel("Memory content") }
|
||||
Text("An empty value clears this section. Existing sessions may cache earlier memory.").font(.footnote)
|
||||
if let error = store.error { Text(error).foregroundStyle(.red) }
|
||||
}
|
||||
.navigationTitle(item.section.capitalized)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { memory = nil } }
|
||||
ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveMemory(item, content: memoryContent); if store.error == nil { memory = nil } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $memory) { item in memoryForm(item) }
|
||||
.confirmationDialog("Delete this item?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) { Button("Delete", role: .destructive) { if let deleting { Task { await deleting() } }; deleting = nil } }
|
||||
}
|
||||
}
|
||||
@@ -278,17 +390,94 @@ struct ChatLibraryView: View {
|
||||
Text("Historical content uses the current title and kind. Saving creates a new revision.").font(.footnote)
|
||||
}
|
||||
if let error = store.error { Text(error).foregroundStyle(.red) }
|
||||
}.navigationTitle(artifact == nil ? "New artifact" : "Edit artifact")
|
||||
}.navigationTitle(artifact == nil ? "New saved draft" : "Edit saved draft")
|
||||
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { artifactEditor = false } }; ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveArtifact(artifact, title: title, kind: kind, content: content); if store.error == nil { artifactEditor = false } } }.disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } }
|
||||
}
|
||||
}
|
||||
private var taskForm: some View {
|
||||
private func memoryForm(_ item: ChatMemory) -> some View {
|
||||
NavigationStack {
|
||||
Form { TextField("Name", text: $name); TextField("Hermes schedule", text: $schedule); Text("Use a schedule accepted by your Hermes server. Hope does not invent a separate scheduling format.").font(.footnote); TextEditor(text: $prompt).frame(minHeight: 180); if let error = store.error { Text(error).foregroundStyle(.red) } }
|
||||
.navigationTitle(task == nil ? "New task" : "Edit task")
|
||||
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { taskEditor = false } }; ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveTask(task, name: name, schedule: schedule, prompt: prompt); if store.error == nil { taskEditor = false } } }.disabled(name.isEmpty || schedule.isEmpty || prompt.isEmpty) } }
|
||||
Form {
|
||||
Section("Your local draft") {
|
||||
TextEditor(text: $memoryContent).frame(minHeight: 220).accessibilityLabel("Memory draft").disabled(memoryWorking)
|
||||
Text("Your draft stays here while you compare server changes. Saving affects future runs; existing sessions may retain earlier memory.").font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
Section("Revision review") {
|
||||
Button("Compare latest server version") {
|
||||
Task {
|
||||
memoryWorking = true; defer { memoryWorking = false }
|
||||
if let value = await store.latestMemory(item) { latestMemory = value; acceptedMemory = nil; memoryRequiresReview = true }
|
||||
}
|
||||
}.disabled(memoryWorking)
|
||||
if let latestMemory {
|
||||
Text(latestMemory.revision == item.revision ? "The server revision matches the version you opened." : "The server has a newer revision. Review it before overwriting with your draft.").font(.footnote)
|
||||
Text(latestMemory.content.isEmpty ? "This section is empty on the server." : latestMemory.content).textSelection(.enabled)
|
||||
Button("Accept this revision for my next save") { acceptedMemory = latestMemory; memoryRequiresReview = false }
|
||||
.disabled(acceptedMemory?.revision == latestMemory.revision)
|
||||
if acceptedMemory != nil { Text("Revision accepted. Saving will write your local draft, not the server text.").font(.footnote) }
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Button("Clear this memory section", role: .destructive) { confirmingClear = true }.disabled(memoryWorking || memoryRequiresReview)
|
||||
Text("Clearing removes only this durable section, not chats or all account data.").font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
if let error = store.error { Text(error).foregroundStyle(.red) }
|
||||
}
|
||||
.navigationTitle(item.section == "user" ? "About you" : "Guidance")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { memory = nil } }
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
if memoryContent.isEmpty { confirmingClear = true }
|
||||
else { Task { await writeMemory(acceptedMemory ?? item, content: memoryContent) } }
|
||||
}.disabled(memoryWorking || memoryRequiresReview)
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Clear this memory section?", isPresented: $confirmingClear, titleVisibility: .visible) {
|
||||
Button("Clear section", role: .destructive) { Task { await writeMemory(acceptedMemory ?? item, content: "") } }
|
||||
} message: { Text("This removes durable memory only. Conversation history is unchanged.") }
|
||||
}
|
||||
}
|
||||
private func writeMemory(_ base: ChatMemory, content: String) async {
|
||||
memoryWorking = true; defer { memoryWorking = false }
|
||||
if await store.saveMemory(base, content: content) { memory = nil }
|
||||
else if store.error?.contains("Memory changed") == true { memoryRequiresReview = true; acceptedMemory = nil }
|
||||
}
|
||||
private var taskForm: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Task") {
|
||||
TextField("Task name", text: $name)
|
||||
TextField("Schedule", text: $schedule).autocorrectionDisabled().textInputAutocapitalization(.never)
|
||||
Text("Enter a schedule accepted by your server, for example cron: 0 9 * * *. The server validates it; timezone and next run are not inferred here.").font(.footnote).foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Instructions").font(.subheadline.weight(.semibold))
|
||||
TextEditor(text: $prompt).frame(minHeight: 150).accessibilityLabel("Task instructions")
|
||||
Text("Tell Hope what to do when this task runs. For example: review my plans and suggest one practical next step.").font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Section("Options") {
|
||||
TextField("Repeat count (optional)", text: $repeatCount).keyboardType(.numberPad)
|
||||
Text("Leave blank for the server default, or enter a positive number of executions.").font(.footnote).foregroundStyle(.secondary)
|
||||
if task == nil { Toggle("Create paused", isOn: $createPaused) }
|
||||
Text("Tasks execute on your Hope server. This app does not deliver push notifications or show task output history.").font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
if let error = store.error { Text(error).foregroundStyle(.red) }
|
||||
}
|
||||
.navigationTitle(task == nil ? "New scheduled task" : "Edit task")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { taskEditor = false } }
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") { Task {
|
||||
let trimmed = repeatCount.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty && (Int(trimmed) == nil || (Int(trimmed) ?? 0) <= 0) { store.error = "Repeat count must be a positive whole number."; return }
|
||||
await store.saveTask(task, name: name, schedule: schedule, prompt: prompt, repeatCount: Int(trimmed), paused: createPaused)
|
||||
if store.error == nil { taskEditor = false }
|
||||
} }.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || schedule.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct PrivatePDFPreview: UIViewRepresentable {
|
||||
|
||||
@@ -20,7 +20,22 @@ struct HopeMessage: Codable, Identifiable, Equatable {
|
||||
var status: String
|
||||
var runId: String?
|
||||
var createdAt: Double
|
||||
// Attachments are deliberately unavailable until their contract is qualified.
|
||||
var attachments: [String] = []
|
||||
enum CodingKeys: String, CodingKey { case id, conversationId, role, content, status, runId, createdAt, attachments }
|
||||
init(id: String, conversationId: String, role: String, content: String, status: String, runId: String?, createdAt: Double, attachments: [String] = []) {
|
||||
self.id = id; self.conversationId = conversationId; self.role = role; self.content = content; self.status = status; self.runId = runId; self.createdAt = createdAt; self.attachments = attachments
|
||||
}
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
conversationId = try values.decode(String.self, forKey: .conversationId)
|
||||
role = try values.decode(String.self, forKey: .role)
|
||||
content = try values.decode(String.self, forKey: .content)
|
||||
status = try values.decode(String.self, forKey: .status)
|
||||
runId = try values.decodeIfPresent(String.self, forKey: .runId)
|
||||
createdAt = try values.decode(Double.self, forKey: .createdAt)
|
||||
attachments = try values.decodeIfPresent([String].self, forKey: .attachments) ?? []
|
||||
}
|
||||
}
|
||||
struct HopeRun: Codable, Identifiable, Equatable {
|
||||
var id: String
|
||||
@@ -188,7 +203,7 @@ final class OfflineChatAPI: ChatTransport {
|
||||
if method == "GET" { return try JSONSerialization.data(withJSONObject: [kind: featureObjects[kind] ?? [], "next_cursor": NSNull()]) }
|
||||
var item = body; item["id"] = UUID().uuidString; item["created_at"] = now; item["updated_at"] = now
|
||||
if kind == "artifacts" { item["revision"] = 1 }
|
||||
if kind == "tasks" { item["enabled"] = true }
|
||||
if kind == "tasks" { item["enabled"] = !(body["paused"] as? Bool ?? false) }
|
||||
featureObjects[kind, default: []].insert(item, at: 0)
|
||||
return try JSONSerialization.data(withJSONObject: [singular: item])
|
||||
}
|
||||
@@ -223,7 +238,8 @@ final class OfflineChatAPI: ChatTransport {
|
||||
if method == "POST" { let c = HopeConversation(id: UUID().uuidString, title: body["title"] as? String ?? "New chat", workspaceId: body["workspace_id"] as? String, pinned: false, pinRank: 0, archived: false, temporary: false, revision: 1, createdAt: now, updatedAt: now); conversations.insert(c, at: 0); return try wrap("conversation", c) }
|
||||
let q = query.first(where: { $0.name == "q" })?.value ?? ""
|
||||
let archived = query.first(where: { $0.name == "archived" })?.value == "true"
|
||||
let filtered = conversations.filter { $0.archived == archived && (q.isEmpty || $0.title.localizedCaseInsensitiveContains(q) || (messages[$0.id] ?? []).contains { $0.content.localizedCaseInsensitiveContains(q) }) }
|
||||
let workspace = query.first(where: { $0.name == "workspace_id" })?.value
|
||||
let filtered = conversations.filter { $0.archived == archived && (workspace == nil || $0.workspaceId == workspace) && (q.isEmpty || $0.title.localizedCaseInsensitiveContains(q) || (messages[$0.id] ?? []).contains { $0.content.localizedCaseInsensitiveContains(q) }) }
|
||||
return try JSONSerialization.data(withJSONObject: ["conversations": JSONSerialization.jsonObject(with: encode(filtered)), "next_cursor": NSNull()])
|
||||
}
|
||||
if parts.count >= 3, parts[1] == "conversations", let index = conversations.firstIndex(where: { $0.id == parts[2] }) {
|
||||
|
||||
@@ -17,6 +17,9 @@ final class ChatStore: ObservableObject {
|
||||
@Published var olderMessagesCursor: String?
|
||||
private var currentQuery = ""
|
||||
private var currentArchived = false
|
||||
private var currentWorkspace: String?
|
||||
@Published private(set) var mutatingConversationIds: Set<String> = []
|
||||
private(set) var draftGeneration = 0
|
||||
private var searchGeneration = 0
|
||||
@Published var approval: (id: String, prompt: String)?
|
||||
@Published var researchAvailable = false
|
||||
@@ -31,6 +34,22 @@ final class ChatStore: ObservableObject {
|
||||
@Published var modelNote: String?
|
||||
let features: ChatFeatureStore
|
||||
@Published var selectedAttachmentIds: Set<String> = []
|
||||
private var queuedAttachments: [String: ChatAttachment] = [:]
|
||||
var canSendAttachments: Bool {
|
||||
guard !closed else { return false }
|
||||
if selectedAttachmentIds.isEmpty { return true }
|
||||
guard features.enabled.contains("attachments"), selectedAttachmentIds.count <= 8 else { return false }
|
||||
return selectedAttachmentIds.allSatisfy { id in
|
||||
guard let attachment = queuedAttachments[id] ?? features.attachments.first(where: { $0.id == id }) else { return false }
|
||||
return attachmentEligible(attachment)
|
||||
}
|
||||
}
|
||||
private func attachmentEligible(_ attachment: ChatAttachment) -> Bool {
|
||||
let image = ["image/png", "image/jpeg", "image/webp"].contains(attachment.mime)
|
||||
let document = ["text/plain", "text/markdown", "application/pdf"].contains(attachment.mime)
|
||||
return (image && attachment.extractionStatus == "image" && features.enabled.contains("image_input")) ||
|
||||
(document && ["ready", "truncated"].contains(attachment.extractionStatus))
|
||||
}
|
||||
private let api: ChatTransport
|
||||
private var generation = 0
|
||||
private var stream: Task<Void, Never>?
|
||||
@@ -43,11 +62,11 @@ final class ChatStore: ObservableObject {
|
||||
|
||||
init(api: ChatTransport) { self.api = api; self.features = ChatFeatureStore(api: api) }
|
||||
deinit { stream?.cancel(); syncTask?.cancel() }
|
||||
private func call<T: Decodable>(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T {
|
||||
private func call<T: Decodable>(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = [], selectionBound: Bool = true) async throws -> T {
|
||||
let epoch = generation
|
||||
let data = try await api.request(path, method: method, body: body, query: query)
|
||||
try Task.checkCancellation()
|
||||
guard !closed, generation == epoch else { throw CancellationError() }
|
||||
guard !closed, !selectionBound || generation == epoch else { throw CancellationError() }
|
||||
return try ChatCoding.decoder.decode(T.self, from: data)
|
||||
}
|
||||
private func failure(_ value: Error) async {
|
||||
@@ -55,27 +74,33 @@ final class ChatStore: ObservableObject {
|
||||
if case ChatFailure.status(let code) = value, code == 401 || code == 403 { await onUnauthorized?(); return }
|
||||
error = value.localizedDescription
|
||||
}
|
||||
func load(query: String = "", archived: Bool = false, more: Bool = false) async {
|
||||
func load(query: String = "", archived: Bool = false, workspace: String? = nil, more: Bool = false) async {
|
||||
guard !closed else { return }
|
||||
currentQuery = query; currentArchived = archived
|
||||
let sameScope = currentQuery == query && currentArchived == archived && currentWorkspace == workspace
|
||||
currentQuery = query; currentArchived = archived; currentWorkspace = workspace
|
||||
searchGeneration += 1
|
||||
let search = searchGeneration
|
||||
searching = true
|
||||
defer { if search == searchGeneration { searching = false } }
|
||||
do {
|
||||
var q = [URLQueryItem(name: "q", value: query), URLQueryItem(name: "archived", value: String(archived)), URLQueryItem(name: "limit", value: "50")]
|
||||
if more, let nextCursor { q.append(URLQueryItem(name: "cursor", value: nextCursor)) }
|
||||
let page: ConversationPage = try await call("api/conversations", query: q)
|
||||
if let workspace { q.append(URLQueryItem(name: "workspace_id", value: workspace)) }
|
||||
if more, sameScope, let nextCursor { q.append(URLQueryItem(name: "cursor", value: nextCursor)) }
|
||||
let page: ConversationPage = try await call("api/conversations", query: q, selectionBound: false)
|
||||
guard search == searchGeneration else { return }
|
||||
if more { let existing = Set(conversations.map(\.id)); conversations += page.conversations.filter { !existing.contains($0.id) } }
|
||||
if more && sameScope { let existing = Set(conversations.map(\.id)); conversations += page.conversations.filter { !existing.contains($0.id) } }
|
||||
else { conversations = page.conversations }
|
||||
nextCursor = page.nextCursor
|
||||
let spaces: WorkspacePage = try await call("api/workspaces"); workspaces = spaces.workspaces
|
||||
let prefs: PreferencesEnvelope = try await call("api/preferences"); preferences = prefs.preferences
|
||||
let spaces: WorkspacePage = try await call("api/workspaces", selectionBound: false)
|
||||
guard search == searchGeneration else { return }; workspaces = spaces.workspaces
|
||||
let prefs: PreferencesEnvelope = try await call("api/preferences", selectionBound: false)
|
||||
guard search == searchGeneration else { return }; preferences = prefs.preferences
|
||||
error = nil
|
||||
} catch { await failure(error) }
|
||||
}
|
||||
func open(_ conversation: HopeConversation) async {
|
||||
guard !closed else { return }
|
||||
draftGeneration += 1
|
||||
if let selected { drafts[selected.id] = draft }
|
||||
generation += 1; stream?.cancel(); stream = nil
|
||||
selected = conversation; messages = []; runs = []; progress = nil; approval = nil; exportText = nil; exportFile = nil
|
||||
@@ -100,18 +125,26 @@ final class ChatStore: ObservableObject {
|
||||
do {
|
||||
var body: [String: Any] = [:]; if let workspace { body["workspace_id"] = workspace }
|
||||
let response: ConversationEnvelope = try await call("api/conversations", method: "POST", body: body)
|
||||
conversations.insert(response.conversation, at: 0); await open(response.conversation)
|
||||
let selectionEpoch = generation
|
||||
await refreshList()
|
||||
guard !closed, generation == selectionEpoch else { busy = false; return }
|
||||
await open(response.conversation)
|
||||
} catch { await failure(error) }
|
||||
}
|
||||
func send() async {
|
||||
guard let selected, !busy, activeRun == nil else { return }
|
||||
guard let selected, !busy, activeRun == nil, !mutatingConversationIds.contains(selected.id) else { return }
|
||||
let content = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !content.isEmpty else { return }
|
||||
guard canSendAttachments else {
|
||||
error = "Some attachments are unavailable for the current model. Refresh Files, choose a supported model, or remove those files before sending."
|
||||
return
|
||||
}
|
||||
busy = true; error = nil; defer { busy = false }
|
||||
let payloadIdentity = content + "\n" + selectedAttachmentIds.sorted().joined(separator: ",") + String(research && researchAvailable)
|
||||
if pendingKey?.content != payloadIdentity { pendingKey = (payloadIdentity, UUID().uuidString) }
|
||||
do {
|
||||
let response: SendEnvelope = try await call("api/conversations/\(selected.id)/messages", method: "POST", body: ["content": content, "idempotency_key": pendingKey!.key, "attachment_ids": Array(selectedAttachmentIds).sorted(), "mode": research && researchAvailable ? "research" : "normal"])
|
||||
draftGeneration += 1
|
||||
upsert(response.message); upsert(response.run); draft = ""; drafts[selected.id] = ""; pendingKey = nil; selectedAttachmentIds = []
|
||||
if response.run.active { replay(response.run.id) }
|
||||
else { let detail: ConversationDetail = try await call("api/conversations/\(selected.id)"); messages = detail.messages }
|
||||
@@ -173,24 +206,78 @@ final class ChatStore: ObservableObject {
|
||||
do { let result: RunEnvelope = try await call("api/runs/\(activeRun.id)/approval", method: "POST", body: ["approval_id": approval.id, "decision": decision]); upsert(result.run); self.approval = nil; replay(result.run.id) }
|
||||
catch { await failure(error) }
|
||||
}
|
||||
func canMutate(_ conversation: HopeConversation) -> Bool {
|
||||
!closed && !mutatingConversationIds.contains(conversation.id) &&
|
||||
(selected?.id != conversation.id || (!busy && activeRun == nil))
|
||||
}
|
||||
private func refreshList() async {
|
||||
await load(query: currentQuery, archived: currentArchived, workspace: currentWorkspace)
|
||||
}
|
||||
private func updateMetadata(_ value: HopeConversation) {
|
||||
if selected?.id == value.id { selected = value }
|
||||
if value.archived != currentArchived || (currentWorkspace != nil && value.workspaceId != currentWorkspace) {
|
||||
conversations.removeAll { $0.id == value.id }
|
||||
} else if let index = conversations.firstIndex(where: { $0.id == value.id }) {
|
||||
conversations[index] = value
|
||||
}
|
||||
}
|
||||
func patch(_ conversation: HopeConversation, changes: [String: Any]) async {
|
||||
guard canMutate(conversation) else { return }
|
||||
mutatingConversationIds.insert(conversation.id)
|
||||
defer { mutatingConversationIds.remove(conversation.id) }
|
||||
var body = changes; body["revision"] = conversation.revision
|
||||
do {
|
||||
let response: ConversationEnvelope = try await call("api/conversations/\(conversation.id)", method: "PATCH", body: body)
|
||||
if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { conversations[index] = response.conversation }
|
||||
if selected?.id == conversation.id { selected = response.conversation }
|
||||
let response: ConversationEnvelope = try await call("api/conversations/\(conversation.id)", method: "PATCH", body: body, selectionBound: false)
|
||||
updateMetadata(response.conversation)
|
||||
await refreshList()
|
||||
} catch ChatFailure.status(409) {
|
||||
do {
|
||||
let detail: ConversationDetail = try await call("api/conversations/\(conversation.id)", selectionBound: false)
|
||||
updateMetadata(detail.conversation)
|
||||
await refreshList()
|
||||
guard !closed else { return }
|
||||
error = "This chat changed elsewhere. Its latest details are loaded. Review your change and try again."
|
||||
} catch { await failure(error) }
|
||||
} catch { await failure(error) }
|
||||
}
|
||||
func delete(_ conversation: HopeConversation) async {
|
||||
guard canMutate(conversation) else { return }
|
||||
mutatingConversationIds.insert(conversation.id)
|
||||
defer { mutatingConversationIds.remove(conversation.id) }
|
||||
do {
|
||||
struct Result: Decodable { let ok: Bool }
|
||||
let _: Result = try await call("api/conversations/\(conversation.id)", method: "DELETE")
|
||||
let _: Result = try await call("api/conversations/\(conversation.id)", method: "DELETE", selectionBound: false)
|
||||
conversations.removeAll { $0.id == conversation.id }; drafts[conversation.id] = nil
|
||||
if selected?.id == conversation.id { generation += 1; stream?.cancel(); selected = nil; messages = []; runs = []; draft = "" }
|
||||
if selected?.id == conversation.id { clearSelection() }
|
||||
await refreshList()
|
||||
} catch { await failure(error) }
|
||||
}
|
||||
private func clearSelection() {
|
||||
generation += 1; draftGeneration += 1; stream?.cancel(); stream = nil
|
||||
selected = nil; messages = []; runs = []; draft = ""; selectedAttachmentIds = []
|
||||
pendingKey = nil; approval = nil; progress = nil; exportText = nil; exportFile = nil; olderMessagesCursor = nil
|
||||
}
|
||||
func queuedAttachment(_ id: String) -> ChatAttachment? {
|
||||
queuedAttachments[id] ?? features.attachments.first(where: { $0.id == id })
|
||||
}
|
||||
/// Bind asynchronous library/upload results to the composer that opened them.
|
||||
@discardableResult
|
||||
func queueAttachment(_ attachment: ChatAttachment, expectedDraftGeneration: Int? = nil) async -> Bool {
|
||||
guard !closed, let selected, !busy, activeRun == nil, !mutatingConversationIds.contains(selected.id),
|
||||
expectedDraftGeneration == nil || expectedDraftGeneration == draftGeneration else { return false }
|
||||
guard features.enabled.contains("attachments") else { error = "Attachments are unavailable. Refresh Files and try again."; return false }
|
||||
guard attachmentEligible(attachment) else {
|
||||
error = "This file cannot be used with the current model. Choose an extracted document or a supported image."; return false
|
||||
}
|
||||
guard selectedAttachmentIds.contains(attachment.id) || selectedAttachmentIds.count < 8 else {
|
||||
error = "Use at most eight attachments per message."; return false
|
||||
}
|
||||
queuedAttachments[attachment.id] = attachment
|
||||
selectedAttachmentIds.insert(attachment.id); pendingKey = nil; error = nil
|
||||
return true
|
||||
}
|
||||
func branch(_ message: HopeMessage, edited: String? = nil, regenerate: Bool = false) async {
|
||||
guard let selected, !busy, activeRun == nil else { return }
|
||||
guard let selected, !busy, activeRun == nil, !mutatingConversationIds.contains(selected.id) else { return }
|
||||
busy = true
|
||||
do {
|
||||
let target: HopeMessage
|
||||
@@ -202,10 +289,26 @@ final class ChatStore: ObservableObject {
|
||||
if let edited { body["content"] = edited }
|
||||
else if regenerate { body["content"] = target.content }
|
||||
let response: ConversationEnvelope = try await call("api/conversations/\(selected.id)/branch", method: "POST", body: body)
|
||||
conversations.insert(response.conversation, at: 0); await open(response.conversation)
|
||||
let selectionEpoch = generation
|
||||
await refreshList()
|
||||
guard !closed, generation == selectionEpoch else { busy = false; return }
|
||||
await open(response.conversation)
|
||||
guard !closed, self.selected?.id == response.conversation.id else { busy = false; return }
|
||||
if let edited { draft = edited; selectedAttachmentIds = Set(target.attachments) }
|
||||
else if regenerate { draft = target.content; selectedAttachmentIds = Set(target.attachments) }
|
||||
if edited != nil || regenerate, !target.attachments.isEmpty {
|
||||
let attachmentEpoch = generation
|
||||
_ = await features.refreshCapabilities()
|
||||
guard !closed, generation == attachmentEpoch, self.selected?.id == response.conversation.id else { busy = false; return }
|
||||
for id in target.attachments {
|
||||
struct AttachmentEnvelope: Decodable { let attachment: ChatAttachment }
|
||||
let metadata: AttachmentEnvelope = try await call("api/attachments/\(id)")
|
||||
queuedAttachments[id] = metadata.attachment
|
||||
}
|
||||
}
|
||||
busy = false
|
||||
if let edited { draft = edited; await send() }
|
||||
else if regenerate { draft = target.content; await send() }
|
||||
if let edited { draft = edited; selectedAttachmentIds = Set(target.attachments); await send() }
|
||||
else if regenerate { draft = target.content; selectedAttachmentIds = Set(target.attachments); await send() }
|
||||
} catch { busy = false; await failure(error) }
|
||||
}
|
||||
@discardableResult
|
||||
@@ -233,6 +336,8 @@ final class ChatStore: ObservableObject {
|
||||
let _: Result = try await call("api/workspaces/\(workspace.id)", method: "DELETE")
|
||||
workspaces.removeAll { $0.id == workspace.id }
|
||||
for i in conversations.indices where conversations[i].workspaceId == workspace.id { conversations[i].workspaceId = nil }
|
||||
if selected?.workspaceId == workspace.id { selected?.workspaceId = nil }
|
||||
await refreshList()
|
||||
} catch { await failure(error) }
|
||||
}
|
||||
func savePreferences(palette: String, appearance: String) async {
|
||||
@@ -246,7 +351,7 @@ final class ChatStore: ObservableObject {
|
||||
} catch { modelNote = "Server model settings are not available." }
|
||||
}
|
||||
func saveModel() async {
|
||||
do { let result: ChatSettingsSaved = try await call("api/settings/model", method: "PUT", body: ["model": model, "thinking_level": thinkingLevel]); model = result.settings.model; thinkingLevel = result.settings.thinkingLevel; modelNote = result.message }
|
||||
do { let result: ChatSettingsSaved = try await call("api/settings/model", method: "PUT", body: ["model": model, "thinking_level": thinkingLevel], selectionBound: false); model = result.settings.model; thinkingLevel = result.settings.thinkingLevel; modelNote = result.message; _ = await features.refreshCapabilities(); await loadResearch() }
|
||||
catch { await failure(error) }
|
||||
}
|
||||
func loadResearch() async {
|
||||
@@ -278,20 +383,20 @@ final class ChatStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
private func synchronize() async {
|
||||
func synchronize() async {
|
||||
do {
|
||||
struct Change: Decodable { let id: String; let kind: String; let deleted: Bool; let data: HopeSyncData? }
|
||||
struct Result: Decodable { let changes: [Change]; let cursor: Int }
|
||||
let result: Result = try await call("api/sync", query: [URLQueryItem(name: "after", value: String(syncCursor))])
|
||||
let result: Result = try await call("api/sync", query: [URLQueryItem(name: "after", value: String(syncCursor))], selectionBound: false)
|
||||
syncCursor = result.cursor
|
||||
guard !result.changes.isEmpty else { return }
|
||||
for change in result.changes {
|
||||
if change.kind == "conversation" {
|
||||
if change.deleted {
|
||||
conversations.removeAll { $0.id == change.id }
|
||||
if selected?.id == change.id { stream?.cancel(); self.selected = nil; messages = []; runs = []; draft = "" }
|
||||
if selected?.id == change.id { clearSelection() }
|
||||
} else if let value = change.data?.conversation {
|
||||
if value.archived != currentArchived { conversations.removeAll { $0.id == value.id } }
|
||||
if value.archived != currentArchived || (currentWorkspace != nil && value.workspaceId != currentWorkspace) { conversations.removeAll { $0.id == value.id } }
|
||||
else if let i = conversations.firstIndex(where: { $0.id == change.id }) { conversations[i] = value }
|
||||
else if currentQuery.isEmpty { conversations.insert(value, at: 0) }
|
||||
}
|
||||
@@ -302,6 +407,10 @@ final class ChatStore: ObservableObject {
|
||||
else if let value = change.data?.workspace { if let i = workspaces.firstIndex(where: { $0.id == value.id }) { workspaces[i] = value } else { workspaces.append(value) } }
|
||||
}
|
||||
}
|
||||
// Reconcile only loaded metadata. Re-fetching page one here discards
|
||||
// older loaded pages and their continuation cursor every sync tick.
|
||||
// Search membership depends on message content, so newly matching rows
|
||||
// are discovered only by an explicit search refresh, not guessed here.
|
||||
if let selected, result.changes.contains(where: { $0.id == selected.id && !$0.deleted }) {
|
||||
let detail: ConversationDetail = try await call("api/conversations/\(selected.id)")
|
||||
self.selected = detail.conversation
|
||||
@@ -311,7 +420,7 @@ final class ChatStore: ObservableObject {
|
||||
} catch { if case ChatFailure.status(let status) = error, status == 401 || status == 403 { await failure(error) } }
|
||||
}
|
||||
/// Invalidate every outstanding callback before clearing account-visible content.
|
||||
func close() { syncTask?.cancel(); syncTask = nil; exportText = nil; exportFile = nil; research = false; features.close(); selectedAttachmentIds = []; closed = true; generation += 1; stream?.cancel(); stream = nil; api.cancel(); conversations = []; workspaces = []; selected = nil; messages = []; runs = []; draft = ""; drafts = [:]; cursors = [:]; pendingKey = nil; approval = nil; progress = nil; error = nil }
|
||||
func close() { syncTask?.cancel(); syncTask = nil; exportText = nil; exportFile = nil; research = false; features.close(); selectedAttachmentIds = []; queuedAttachments = [:]; closed = true; generation += 1; draftGeneration += 1; searchGeneration += 1; mutatingConversationIds = []; stream?.cancel(); stream = nil; api.cancel(); conversations = []; workspaces = []; selected = nil; messages = []; runs = []; draft = ""; drafts = [:]; cursors = [:]; pendingKey = nil; approval = nil; progress = nil; error = nil }
|
||||
}
|
||||
struct ChatModelChoice: Decodable, Identifiable { let id: String; let thinkingLevels: [String]? }
|
||||
struct ChatSettingsValue: Decodable { let model: String; let thinkingLevel: String }
|
||||
|
||||
@@ -5,6 +5,12 @@ 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?
|
||||
@@ -27,13 +33,13 @@ struct NativeChatView: View {
|
||||
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, more: true) } } }
|
||||
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)) {
|
||||
.task(id: query + String(archived) + (workspace ?? "")) {
|
||||
do { try await Task.sleep(for: .milliseconds(250)); try Task.checkCancellation(); await refresh() } catch {}
|
||||
}
|
||||
.refreshable { await refresh() }
|
||||
@@ -42,15 +48,22 @@ struct NativeChatView: View {
|
||||
.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)) }
|
||||
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() } }
|
||||
}
|
||||
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() } }
|
||||
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")
|
||||
}
|
||||
@@ -60,22 +73,32 @@ struct NativeChatView: View {
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: String.self) { _ in ChatDetailView(store: store, talk: talk) }
|
||||
.sheet(isPresented: $showLibrary) { ChatLibraryView(store: store.features, selectedAttachmentIds: $store.selectedAttachmentIds) }
|
||||
.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 { Task { await store.patch(rename, changes: ["title": title]); if store.error == nil { self.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 { Task { await store.delete(delete) } }; delete = nil }
|
||||
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.filter { workspace == nil || $0.workspaceId == workspace } }
|
||||
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" }
|
||||
@@ -90,9 +113,10 @@ struct NativeChatView: View {
|
||||
}
|
||||
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) }
|
||||
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: {
|
||||
@@ -104,16 +128,61 @@ struct NativeChatView: View {
|
||||
}.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 }
|
||||
.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.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,6 +194,8 @@ struct ChatDetailView: View {
|
||||
@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
|
||||
@@ -182,16 +253,19 @@ struct ChatDetailView: View {
|
||||
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 { 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")
|
||||
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.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
|
||||
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) { ChatLibraryView(store: store.features, selectedAttachmentIds: $store.selectedAttachmentIds) }
|
||||
.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) }
|
||||
@@ -205,15 +279,15 @@ 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
|
||||
ForEach(store.selectedAttachmentIds.sorted(), id: \.self) { id in
|
||||
HStack {
|
||||
Label(item.filename, systemImage: "doc").font(.caption).lineLimit(1)
|
||||
Label(store.queuedAttachment(id)?.filename ?? "Unavailable attachment", systemImage: "doc").font(.caption).lineLimit(1)
|
||||
Spacer()
|
||||
Button("Remove") { store.selectedAttachmentIds.remove(item.id) }.font(.caption).frame(minHeight: 36)
|
||||
Button("Remove") { store.selectedAttachmentIds.remove(id) }.font(.caption).frame(minWidth: 44, minHeight: 44)
|
||||
.accessibilityLabel("Remove \(store.queuedAttachment(id)?.filename ?? "unavailable attachment")")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user