fix: share private chat files and harden native lifecycle

This commit is contained in:
snowspeeder committed 2026-10-03 09:16:56 -04:00
1 parent 7dcd06c911
commit ec510b938b
8 files changed
+83 -15

No files matched your search

+13 -8
View File
@@ -22,6 +22,7 @@ final class ChatStore: ObservableObject {
@Published var researchAvailable = false
@Published var research = false
@Published var exportText: String?
@Published var exportFile: PrivateChatExport?
private var syncCursor = 0
private var syncTask: Task<Void, Never>?
@Published var model = ""
@@ -41,7 +42,7 @@ final class ChatStore: ObservableObject {
var activeRun: HopeRun? { runs.first(where: \.active) }
init(api: ChatTransport) { self.api = api; self.features = ChatFeatureStore(api: api) }
deinit { stream?.cancel() }
deinit { stream?.cancel(); syncTask?.cancel() }
private func call<T: Decodable>(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T {
let epoch = generation
let data = try await api.request(path, method: method, body: body, query: query)
@@ -77,7 +78,7 @@ final class ChatStore: ObservableObject {
func open(_ conversation: HopeConversation) async {
if let selected { drafts[selected.id] = draft }
generation += 1; stream?.cancel(); stream = nil
selected = conversation; messages = []; runs = []; progress = nil; approval = nil
selected = conversation; messages = []; runs = []; progress = nil; approval = nil; exportText = nil; exportFile = nil
draft = drafts[conversation.id] ?? ""; pendingKey = nil; selectedAttachmentIds = []; olderMessagesCursor = nil; error = nil
do {
let detail: ConversationDetail = try await call("api/conversations/\(conversation.id)")
@@ -249,16 +250,20 @@ final class ChatStore: ObservableObject {
let bytes = try await api.request("api/conversations/\(selected.id)/export", method: "GET", body: nil, query: [URLQueryItem(name: "format", value: format)])
guard epoch == generation, !closed else { return }
exportText = String(data: bytes, encoding: .utf8)
exportFile = PrivateChatExport(data: bytes, filename: "Hope-chat-\(selected.id).\(format == "json" ? "json" : "md")", type: format == "json" ? .json : .plainText)
} catch { await failure(error) }
}
func foreground(_ active: Bool) {
syncTask?.cancel(); syncTask = nil
guard active, !closed else { return }
guard active, !closed else { stream?.cancel(); stream = nil; return }
resume()
syncTask = Task { [weak self] in
guard let self else { return }
await loadResearch()
while !Task.isCancelled, !closed {
await synchronize()
await self?.loadResearch()
while !Task.isCancelled {
guard self?.closed == false else { return }
await self?.synchronize()
// Release the store before suspension; its retained task must not
// keep an otherwise-discarded account alive indefinitely.
do { try await Task.sleep(for: .seconds(5)) } catch { return }
}
}
@@ -296,7 +301,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; 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 = []; 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 }
}
struct ChatModelChoice: Decodable, Identifiable { let id: String; let thinkingLevels: [String]? }
struct ChatSettingsValue: Decodable { let model: String; let thinkingLevel: String }