fix: share private chat files and harden native lifecycle
This commit is contained in:
1 parent
7dcd06c911
commit
ec510b938b
8 files changed
+83
-15
No files matched your search
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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))) } }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user