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

+14
View File
@@ -0,0 +1,14 @@
import SwiftUI
import UniformTypeIdentifiers
/// Share through the system transfer service, not a persistent app cache.
/// Named representations carry server bytes unchanged and never expose an origin/token.
struct PrivateChatExport: Transferable {
let data: Data
let filename: String
let type: UTType
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(exportedContentType: .data) { value in value.data }
.suggestedFileName { value in value.filename }
}
}
+2 -1
View File
@@ -211,7 +211,8 @@ struct ChatLibraryView: View {
Picker("Kind", selection: $kind) { ForEach(["markdown", "text", "code"], id: \.self) { Text($0.capitalized).tag($0) } }
TextEditor(text: $content).frame(minHeight: 220)
if let artifact {
ShareLink("Export text", item: content)
let file = PrivateChatExport(data: Data(content.utf8), filename: "Hope-artifact-\(artifact.id).\(kind == "markdown" ? "md" : "txt")", type: .plainText)
ShareLink("Export file", item: file, preview: SharePreview(file.filename))
if artifact.revision > 1 { Menu("View earlier revision") { ForEach(1..<artifact.revision, id: \.self) { revision in Button("Revision \(revision)") { Task { if let value = await store.historical(artifact, revision: revision) { content = value.content ?? "" } } } } } }
Text("Historical content uses the current title and kind. Saving creates a new revision.").font(.footnote)
}
+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 }
+11 -2
View File
@@ -133,6 +133,7 @@ struct ChatDetailView: View {
Color.clear.frame(height: 1).id("chat-bottom")
}.padding(.horizontal, 20).padding(.vertical, 16).frame(maxWidth: 760)
}
.scrollDismissesKeyboard(.interactively)
.simultaneousGesture(DragGesture().onChanged { _ in follow = false })
.onChange(of: store.messages) { _, _ in if follow { proxy.scrollTo("chat-bottom", anchor: .bottom) } }
.overlay(alignment: .bottomTrailing) {
@@ -148,7 +149,7 @@ struct ChatDetailView: View {
Button("Talk", systemImage: "waveform") { if let id = store.selected?.id { Task { await talk(id) } } }.disabled(store.activeRun != nil)
Button("Export Markdown") { Task { await store.exportConversation(format: "markdown") } }
Button("Export JSON") { Task { await store.exportConversation(format: "json") } }
if let text = store.exportText { ShareLink("Share export", item: text) }
if let file = store.exportFile { ShareLink("Share export file", item: file, preview: SharePreview(file.filename)) }
} label: { Image(systemName: "ellipsis.circle").frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Chat actions")
} }
.safeAreaInset(edge: .bottom) {
@@ -193,7 +194,15 @@ struct RichChatMarkdown: View {
}.padding(12).background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
} else {
ForEach(Array(block.text.components(separatedBy: "\n").enumerated()), id: \.offset) { _, line in
if line.hasPrefix("# ") { markdown(String(line.dropFirst(2))).font(.title2.weight(.semibold)) }
if line.trimmingCharacters(in: .whitespaces).hasPrefix("|"), line.contains("|") {
let cells = line.split(separator: "|", omittingEmptySubsequences: true).map { String($0).trimmingCharacters(in: .whitespaces) }
if !cells.allSatisfy({ $0.allSatisfy { "-: ".contains($0) } }) {
HStack(alignment: .top, spacing: 12) { ForEach(Array(cells.enumerated()), id: \.offset) { _, cell in markdown(cell).frame(maxWidth: .infinity, alignment: .leading) } }
.padding(.vertical, 6).accessibilityElement(children: .combine)
Divider()
}
}
else if line.hasPrefix("# ") { markdown(String(line.dropFirst(2))).font(.title2.weight(.semibold)) }
else if line.hasPrefix("## ") { markdown(String(line.dropFirst(3))).font(.title3.weight(.semibold)) }
else if line.hasPrefix("### ") { markdown(String(line.dropFirst(4))).font(.headline) }
else if line.hasPrefix("- ") || line.hasPrefix("* ") { HStack(alignment: .top) { Text("•"); markdown(String(line.dropFirst(2))) } }
+3
View File
@@ -43,10 +43,13 @@ final class ChatTests: XCTestCase {
XCTAssertFalse(store.researchAvailable)
await store.exportConversation(format: "markdown")
XCTAssertTrue(store.exportText?.contains("Export this") == true)
XCTAssertTrue(store.exportFile?.filename.hasSuffix(".md") == true)
XCTAssertEqual(store.exportFile?.data, Data(store.exportText!.utf8))
store.foreground(true)
store.foreground(false)
store.close()
XCTAssertNil(store.exportText)
XCTAssertNil(store.exportFile)
XCTAssertFalse(store.research)
}
func testBranchAndRegeneratePreserveOriginal() async throws {
+22 -4
View File
@@ -3,7 +3,7 @@ import XCTest
final class ChatUITests: XCTestCase {
func testNativeTextChatWithoutVoiceAndAccountPrivacy() {
let app = XCUIApplication()
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat"]
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
app.launch()
XCTAssertTrue(app.navigationBars["Chats"].waitForExistence(timeout: 10))
app.buttons["New chat"].tap()
@@ -13,7 +13,8 @@ final class ChatUITests: XCTestCase {
app.buttons["Send message"].tap()
XCTAssertTrue(app.staticTexts["Native offline text"].waitForExistence(timeout: 10))
XCTAssertTrue(app.staticTexts["Offline reply"].exists)
capture(app, "Native chat")
app.swipeDown()
capture(app, "Native chat standard Opal")
XCTAssertFalse(app.buttons["End conversation"].exists)
app.navigationBars.buttons.element(boundBy: 0).tap()
app.buttons["Chat options"].tap()
@@ -23,7 +24,7 @@ final class ChatUITests: XCTestCase {
}
func testOfflineAdvancedLibraryAndArtifactEditor() {
let app = XCUIApplication()
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat"]
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
app.launch()
XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10))
app.buttons["Chat options"].tap(); app.buttons["Library and tools"].tap()
@@ -47,7 +48,7 @@ final class ChatUITests: XCTestCase {
}
func testWorkspaceNativeFixture() {
let app = XCUIApplication()
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat"]
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
app.launch()
XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10))
app.buttons["Chat options"].tap(); app.buttons["Manage workspaces"].tap()
@@ -57,6 +58,23 @@ final class ChatUITests: XCTestCase {
XCTAssertTrue(app.buttons["Writing"].waitForExistence(timeout: 10))
capture(app, "Native workspaces")
}
func testStandardDarkChatScreenshot() {
let app = XCUIApplication()
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
app.launch()
XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10))
app.buttons["Chat options"].tap(); app.buttons["Appearance and model"].tap()
app.buttons.matching(NSPredicate(format: "label CONTAINS 'Appearance' AND label CONTAINS 'System'")).firstMatch.tap(); app.buttons["Dark"].tap()
app.buttons["Save appearance"].tap(); app.buttons["Done"].tap()
capture(app, "Native chat list dark standard")
app.buttons["New chat"].tap()
let field = app.textFields["chat-draft"]
XCTAssertTrue(field.waitForExistence(timeout: 10)); field.tap(); field.typeText("Dark mode fixture")
app.buttons["Send message"].tap()
XCTAssertTrue(app.staticTexts["Offline reply"].waitForExistence(timeout: 10))
app.swipeDown()
capture(app, "Native chat dark standard")
}
func testChatLargeTextAndPreferences() {
let app = XCUIApplication()
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]
+4
View File
@@ -16,6 +16,7 @@
2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262B5A712708421C35ED6AC0 /* AuthTests.swift */; };
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; };
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; };
4AA89EFF10F261A3FCF8C366 /* ChatExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14505F4DA9DB1610B2EEB271 /* ChatExport.swift */; };
4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC751BFB00B04C73D19C714 /* AuthClient.swift */; };
57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */; };
5D37D5933765F80A283DC47B /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */; };
@@ -69,6 +70,7 @@
/* Begin PBXFileReference section */
0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMonitor.swift; sourceTree = "<group>"; };
14505F4DA9DB1610B2EEB271 /* ChatExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatExport.swift; sourceTree = "<group>"; };
1CF5F1830927F8D44C4CC89A /* ChatViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViews.swift; sourceTree = "<group>"; };
20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerReplacementCoordinatorTests.swift; sourceTree = "<group>"; };
262B5A712708421C35ED6AC0 /* AuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthTests.swift; sourceTree = "<group>"; };
@@ -204,6 +206,7 @@
94F63FF6C1F0CD224010E2F5 /* Chat */ = {
isa = PBXGroup;
children = (
14505F4DA9DB1610B2EEB271 /* ChatExport.swift */,
41286AD7E2621F77B4F7CC85 /* ChatFeatures.swift */,
96CFD3A353D5B5E4A97D263F /* ChatModels.swift */,
6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */,
@@ -398,6 +401,7 @@
C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */,
4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */,
6488E54A6D93278C04D870E2 /* AuthStore.swift in Sources */,
4AA89EFF10F261A3FCF8C366 /* ChatExport.swift in Sources */,
29EF2C8C93F9A4270816D22D /* ChatFeatures.swift in Sources */,
5E4FBAAB530CA2BEEA013A57 /* ChatModels.swift in Sources */,
5D37D5933765F80A283DC47B /* ChatStore.swift in Sources */,
+14
View File
@@ -56,6 +56,20 @@ source signed build passed again at `/tmp/hope-chat-phone-final-build.log`. No i
production requests, backend deployment or physical speech qualification occurred.
Opt-in live TLS cases return without probes in offline suite and are not TLS proof.
Native follow-up: named `.md`/`.json` conversation and `.md`/`.txt` artifact
Transferable file exports preserve bytes and keep no persistent private cache;
exports clear on conversation switch/logout. Basic markdown tables render selectable
native rows. Polling uses weak account lifetime, teardown/deinit cancellation, and
background stream cancellation with foreground replay. Explicit standard-size UI
fixtures now distinguish default screenshots from XXXL accessibility fixtures.
Follow-up **70 unit + 5 chat UI passed** at `/tmp/hope-chat-polish-final.xcresult`;
signed current build passed `/tmp/hope-chat-phone-export-final.log`. Standard
light/dark chat and dark list screenshots exported to
`/tmp/hope-chat-standard-screenshots/` and inspected. Conversation captures retain
the active keyboard; dark list capture shows full shell. Initial new dark UI test
failed the combined native picker label and was corrected; failed bundle retained
`/tmp/hope-chat-polish-tests.xcresult`. No hardware installation or live request.
## Approved Opal pink redesign — 2026-10-02
User approved the Opal HTML direction with the existing square reactive persona.