diff --git a/Sources/Chat/ChatExport.swift b/Sources/Chat/ChatExport.swift new file mode 100644 index 0000000..58e59cc --- /dev/null +++ b/Sources/Chat/ChatExport.swift @@ -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 } + } +} diff --git a/Sources/Chat/ChatFeatures.swift b/Sources/Chat/ChatFeatures.swift index 1ec966a..2a70e46 100644 --- a/Sources/Chat/ChatFeatures.swift +++ b/Sources/Chat/ChatFeatures.swift @@ -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..? @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(_ 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 } diff --git a/Sources/Chat/ChatViews.swift b/Sources/Chat/ChatViews.swift index cb1bb95..4f40155 100644 --- a/Sources/Chat/ChatViews.swift +++ b/Sources/Chat/ChatViews.swift @@ -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))) } } diff --git a/Tests/ChatTests.swift b/Tests/ChatTests.swift index 3cf3a76..4139c75 100644 --- a/Tests/ChatTests.swift +++ b/Tests/ChatTests.swift @@ -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 { diff --git a/UITests/ChatUITests.swift b/UITests/ChatUITests.swift index d514013..ec34f70 100644 --- a/UITests/ChatUITests.swift +++ b/UITests/ChatUITests.swift @@ -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"] diff --git a/Voice.xcodeproj/project.pbxproj b/Voice.xcodeproj/project.pbxproj index 6b5cd28..3428672 100644 --- a/Voice.xcodeproj/project.pbxproj +++ b/Voice.xcodeproj/project.pbxproj @@ -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 = ""; }; + 14505F4DA9DB1610B2EEB271 /* ChatExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatExport.swift; sourceTree = ""; }; 1CF5F1830927F8D44C4CC89A /* ChatViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViews.swift; sourceTree = ""; }; 20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerReplacementCoordinatorTests.swift; sourceTree = ""; }; 262B5A712708421C35ED6AC0 /* AuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthTests.swift; sourceTree = ""; }; @@ -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 */, diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index ad6899c..b5c94d9 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -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.