feat(ios): align chat library memory and tasks with web

This commit is contained in:
snowspeeder committed 2026-10-04 10:41:17 -04:00
1 parent 8905dc2c2a
commit 235dd65487
12 files changed
+957 -128

No files matched your search

+247 -58
View File
@@ -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 {