From 21c826e1b42c139337544c707dd065118b401acb Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 12:10:42 -0700 Subject: [PATCH 01/92] test: cover artifact canonical identity dedup --- .../ChatArtifactCanonicalIdentityTests.swift | 144 ++++++++++++++++++ .../SessionGallerySnapshotTests.swift | 52 +++++++ 2 files changed, 196 insertions(+) create mode 100644 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactCanonicalIdentityTests.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/SessionGallerySnapshotTests.swift diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactCanonicalIdentityTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactCanonicalIdentityTests.swift new file mode 100644 index 000000000000..90099f8a42a5 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactCanonicalIdentityTests.swift @@ -0,0 +1,144 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact canonical identity") +struct ChatArtifactCanonicalIdentityTests { + @Test("symlink and target spellings produce one row") + func symlinkAndTargetDeduplicate() throws { + let root = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let target = root.appendingPathComponent("Report.txt") + let symlink = root.appendingPathComponent("report-link.txt") + try Data("report".utf8).write(to: target) + try FileManager.default.createSymbolicLink(at: symlink, withDestinationURL: target) + + let records = ChatArtifactIndexedReference.derive(from: [ + toolMessage(id: "target", seq: 1, path: target.path), + toolMessage(id: "symlink", seq: 2, path: symlink.path), + ]) + + #expect(records == [ChatArtifactIndexedReference( + path: try lexicalPath(target.path), + provenance: .referenced, + lastReferencedSeq: 2 + )]) + } + + @Test("case variants produce one row with the on-disk spelling") + func caseVariantsUseOnDiskSpelling() throws { + let root = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let onDisk = root.appendingPathComponent("MixedCaseReport.TXT") + let variant = root.appendingPathComponent("mixedcasereport.txt") + try Data("report".utf8).write(to: onDisk) + #expect(FileManager.default.fileExists(atPath: variant.path)) + + let records = ChatArtifactIndexedReference.derive(from: [ + toolMessage(id: "canonical-case", seq: 1, path: onDisk.path), + toolMessage(id: "variant-case", seq: 2, path: variant.path), + ]) + + #expect(records == [ChatArtifactIndexedReference( + path: try lexicalPath(onDisk.path), + provenance: .referenced, + lastReferencedSeq: 2 + )]) + } + + @Test("a deleted file remains listed under its lexical path") + func deletedFileRemainsLexical() throws { + let root = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let deleted = root.appendingPathComponent("DeletedReport.TXT") + try Data("report".utf8).write(to: deleted) + try FileManager.default.removeItem(at: deleted) + + let records = ChatArtifactIndexedReference.derive(from: [ + toolMessage(id: "deleted", seq: 3, path: deleted.path), + ]) + + #expect(records == [ChatArtifactIndexedReference( + path: try lexicalPath(deleted.path), + provenance: .referenced, + lastReferencedSeq: 3 + )]) + } + + @Test("created provenance survives a later symlink reference and advances sequence") + func provenanceAndSequenceMergeAcrossSpellings() throws { + let root = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let target = root.appendingPathComponent("Created.swift") + let symlink = root.appendingPathComponent("created-link.swift") + try Data("let value = 1".utf8).write(to: target) + try FileManager.default.createSymbolicLink(at: symlink, withDestinationURL: target) + + let records = ChatArtifactIndexedReference.derive(from: [ + editMessage(id: "created", seq: 4, path: target.path), + toolMessage(id: "referenced", seq: 9, path: symlink.path), + ]) + + #expect(records == [ChatArtifactIndexedReference( + path: try lexicalPath(target.path), + provenance: .created, + lastReferencedSeq: 9 + )]) + } + + @Test("chip session total matches the deduplicated gallery total") + func chipTotalMatchesDeduplicatedGallery() throws { + let root = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let target = root.appendingPathComponent("ChipCount.md") + let symlink = root.appendingPathComponent("chip-count-link.md") + try Data("count".utf8).write(to: target) + try FileManager.default.createSymbolicLink(at: symlink, withDestinationURL: target) + let records = ChatArtifactIndexedReference.derive(from: [ + toolMessage(id: "target", seq: 1, path: target.path), + toolMessage(id: "symlink", seq: 2, path: symlink.path), + ]) + + let total = ChatArtifactGalleryOrdering().sessionTotal(records) + + #expect(total == records.count) + #expect(total == 1) + } + + private func makeTemporaryDirectory() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-artifact-identity-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private func lexicalPath(_ path: String) throws -> String { + try #require(ChatArtifactPathNormalizer(workingDirectory: nil).structuredPath(path)) + } + + private func toolMessage(id: String, seq: Int, path: String) -> ChatMessage { + ChatMessage( + id: id, + seq: seq, + role: .agent, + timestamp: Date(timeIntervalSince1970: 0), + kind: .toolUse(ChatToolUse( + toolName: "Read", + summary: "read", + status: .succeeded, + referencedPaths: [path] + )) + ) + } + + private func editMessage(id: String, seq: Int, path: String) -> ChatMessage { + ChatMessage( + id: id, + seq: seq, + role: .agent, + timestamp: Date(timeIntervalSince1970: 0), + kind: .fileEdit(ChatFileEdit(filePath: path, operation: .write)) + ) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/SessionGallerySnapshotTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/SessionGallerySnapshotTests.swift new file mode 100644 index 000000000000..04b28fd0e772 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/SessionGallerySnapshotTests.swift @@ -0,0 +1,52 @@ +#if os(iOS) +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxMobileShellUI + +@Suite("Session gallery snapshot") +struct SessionGallerySnapshotTests { + @Test("appending drops paths already present in any section") + func appendingDeduplicatesAcrossSections() { + let created = item(path: "/session/created.txt", provenance: .created) + let attached = item(path: "/session/attached.txt", provenance: .attached) + let referenced = item(path: "/session/referenced.txt", provenance: .referenced) + let next = item(path: "/session/next.txt", provenance: .referenced) + let initial = ChatArtifactGalleryPage( + sessionID: "session", + created: [created], + attached: [attached], + referenced: [referenced], + referencedTotal: 2, + nextCursor: "next", + generation: "generation" + ) + let page = ChatArtifactGalleryPage( + sessionID: "session", + referenced: [created, attached, referenced, next, next], + referencedTotal: 2, + generation: "generation" + ) + + let snapshot = TerminalArtifactFilesSheet.SessionGallerySnapshot(page: initial) + .appending(page) + + #expect(snapshot.created.map(\.path) == [created.path]) + #expect(snapshot.attached.map(\.path) == [attached.path]) + #expect(snapshot.referenced.map(\.path) == [referenced.path, next.path]) + } + + private func item( + path: String, + provenance: ChatArtifactProvenance + ) -> ChatArtifactGalleryItem { + ChatArtifactGalleryItem( + path: path, + kind: .text, + displayName: (path as NSString).lastPathComponent, + provenance: provenance + ) + } +} +#endif From c4a1f16c0da386b452f24b309fd7cf67310032a6 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 12:19:14 -0700 Subject: [PATCH 02/92] fix: deduplicate artifact gallery by canonical path --- .../ChatArtifactGallerySnapshot.swift | 69 +++++++++++++++++++ .../ChatArtifactIndexedReference.swift | 50 +++++++++++--- .../ChatArtifactPathCanonicalizer.swift | 46 +++++++++++++ .../Artifacts/TerminalArtifactScope.swift | 14 ++-- .../ChatArtifactCanonicalIdentityTests.swift | 24 +++++++ .../ChatArtifactGallerySnapshotTests.swift | 48 +++++++++++++ .../TerminalArtifactScopeTests.swift | 19 +++++ .../TerminalArtifactFilesSheet.swift | 49 +------------ ...alController+MobileTerminalArtifacts.swift | 32 +++++---- 9 files changed, 274 insertions(+), 77 deletions(-) create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactPathCanonicalizer.swift create mode 100644 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySnapshotTests.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift new file mode 100644 index 000000000000..e812add400f4 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift @@ -0,0 +1,69 @@ +/// Accumulated section rows and paging state for a session artifact gallery. +public struct ChatArtifactGallerySnapshot: Sendable, Equatable { + /// Created artifact rows from the first page. + public let created: [ChatArtifactGalleryItem] + /// Attached artifact rows from the first page. + public let attached: [ChatArtifactGalleryItem] + /// Referenced artifact rows accumulated across pages. + public let referenced: [ChatArtifactGalleryItem] + /// Complete referenced-row count reported by the host. + public let referencedTotal: Int + /// Cursor for the next referenced page. + public let nextCursor: String? + /// Host snapshot generation that served the latest page. + public let generation: String + + /// Whether the complete gallery has no rows. + public var isEmpty: Bool { + created.isEmpty && attached.isEmpty && referencedTotal == 0 + } + + /// Creates an accumulated snapshot from a first gallery page. + /// + /// - Parameter page: The first sectioned gallery page. + public init(page: ChatArtifactGalleryPage) { + created = page.created + attached = page.attached + referenced = page.referenced + referencedTotal = page.referencedTotal + nextCursor = page.nextCursor + generation = page.generation + } + + /// Appends a referenced page while dropping paths already in any section. + /// + /// First-seen order is preserved both across pages and within `page`. + /// + /// - Parameter page: The next referenced gallery page. + /// - Returns: A snapshot containing only path-unique appended rows. + public func appending(_ page: ChatArtifactGalleryPage) -> ChatArtifactGallerySnapshot { + var seenPaths = Set((created + attached + referenced).map(\.path)) + let uniqueReferenced = page.referenced.filter { item in + seenPaths.insert(item.path).inserted + } + return ChatArtifactGallerySnapshot( + created: created, + attached: attached, + referenced: referenced + uniqueReferenced, + referencedTotal: page.referencedTotal, + nextCursor: page.nextCursor, + generation: page.generation + ) + } + + private init( + created: [ChatArtifactGalleryItem], + attached: [ChatArtifactGalleryItem], + referenced: [ChatArtifactGalleryItem], + referencedTotal: Int, + nextCursor: String?, + generation: String + ) { + self.created = created + self.attached = attached + self.referenced = referenced + self.referencedTotal = referencedTotal + self.nextCursor = nextCursor + self.generation = generation + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactIndexedReference.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactIndexedReference.swift index 67b060c9b189..2ff84eee34ff 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactIndexedReference.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactIndexedReference.swift @@ -2,7 +2,7 @@ import Foundation /// A transcript-derived path with de-duplicated provenance and its last position. public struct ChatArtifactIndexedReference: Sendable, Equatable, Codable, Identifiable { - /// Transcript path exactly as parsed. + /// Canonical display path when the file exists, otherwise its lexical path. public let path: String /// Highest-precedence provenance observed for the path. public let provenance: ChatArtifactProvenance @@ -19,25 +19,28 @@ public struct ChatArtifactIndexedReference: Sendable, Equatable, Codable, Identi self.lastReferencedSeq = lastReferencedSeq } - /// Derives one record per normalized path from parsed transcript messages. + /// Derives one record per canonical path identity from parsed transcript messages. /// /// Agent edits outrank attachments, which outrank read-only references; /// every occurrence still advances the path's last-reference sequence. - /// Relative paths are lexically resolved against the session working - /// directory without accessing the filesystem. + /// Existing absolute paths resolve filesystem aliases after lexical + /// normalization. Missing paths stay lexical so deleted artifacts remain. /// /// - Parameters: /// - messages: Parsed transcript messages to inspect. /// - supplementalReferences: Raw pre-budget and artifacts-only parser /// occurrences that are absent from the visible message stream. /// - workingDirectory: Absolute session directory used for relative paths. - /// - Returns: De-duplicated artifact references with normalized paths. + /// - canonicalizer: Filesystem identity operation used after lexical normalization. + /// - Returns: De-duplicated artifact references with canonical display paths. public static func derive( from messages: [ChatMessage], supplementalReferences: [ChatArtifactTranscriptReference] = [], - workingDirectory: String? = nil + workingDirectory: String? = nil, + canonicalizer: ChatArtifactPathCanonicalizer = ChatArtifactPathCanonicalizer() ) -> [ChatArtifactIndexedReference] { var byPath: [String: ChatArtifactIndexedReference] = [:] + var canonicalPathByLexicalPath: [String: String] = [:] let detector = TerminalArtifactPathDetector() let normalizer = ChatArtifactPathNormalizer(workingDirectory: workingDirectory) for message in messages { @@ -72,12 +75,26 @@ public struct ChatArtifactIndexedReference: Sendable, Equatable, Codable, Identi guard let path = normalizer.structuredPath(rawPath) else { continue } - Self.merge(path: path, provenance: provenance, seq: message.seq, into: &byPath) + Self.merge( + path: path, + provenance: provenance, + seq: message.seq, + canonicalizer: canonicalizer, + canonicalPathByLexicalPath: &canonicalPathByLexicalPath, + into: &byPath + ) } for rawPath in textOccurrences where ChatArtifactPathNormalizer.isAbsoluteFreeTextCandidate(rawPath) { guard let path = normalizer.freeTextPath(rawPath) else { continue } - Self.merge(path: path, provenance: .referenced, seq: message.seq, into: &byPath) + Self.merge( + path: path, + provenance: .referenced, + seq: message.seq, + canonicalizer: canonicalizer, + canonicalPathByLexicalPath: &canonicalPathByLexicalPath, + into: &byPath + ) } } for reference in supplementalReferences { @@ -92,6 +109,8 @@ public struct ChatArtifactIndexedReference: Sendable, Equatable, Codable, Identi path: path, provenance: reference.provenance, seq: reference.seq, + canonicalizer: canonicalizer, + canonicalPathByLexicalPath: &canonicalPathByLexicalPath, into: &byPath ) } @@ -102,11 +121,20 @@ public struct ChatArtifactIndexedReference: Sendable, Equatable, Codable, Identi path: String, provenance: ChatArtifactProvenance, seq: Int, + canonicalizer: ChatArtifactPathCanonicalizer, + canonicalPathByLexicalPath: inout [String: String], into byPath: inout [String: ChatArtifactIndexedReference] ) { - let previous = byPath[path] - byPath[path] = ChatArtifactIndexedReference( - path: path, + let canonicalPath: String + if let cached = canonicalPathByLexicalPath[path] { + canonicalPath = cached + } else { + canonicalPath = canonicalizer.canonicalPathKey(for: path) + canonicalPathByLexicalPath[path] = canonicalPath + } + let previous = byPath[canonicalPath] + byPath[canonicalPath] = ChatArtifactIndexedReference( + path: canonicalPath, provenance: Self.higherPrecedence(previous?.provenance, provenance), lastReferencedSeq: max(previous?.lastReferencedSeq ?? Int.min, seq) ) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactPathCanonicalizer.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactPathCanonicalizer.swift new file mode 100644 index 000000000000..30077014902b --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactPathCanonicalizer.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Produces stable filesystem identity keys for lexically normalized artifact paths. +public struct ChatArtifactPathCanonicalizer: Sendable { + private let implementation: @Sendable (String) -> String + + /// Creates a canonicalizer backed by the current filesystem. + public init() { + implementation = Self.foundationCanonicalPathKey + } + + /// Creates a canonicalizer with an injected identity operation. + /// + /// - Parameter canonicalPathKey: An operation that maps one lexically + /// normalized path to its canonical identity key. + public init(canonicalPathKey: @escaping @Sendable (String) -> String) { + implementation = canonicalPathKey + } + + /// Returns the canonical identity key for a lexically normalized path. + /// + /// Existing absolute paths resolve symlinks and use their on-disk case. + /// Missing and relative paths remain unchanged so deleted or unresolved + /// transcript artifacts stay visible. + /// + /// - Parameter path: An already-lexically-normalized artifact path. + /// - Returns: The filesystem identity key, or `path` when it is absent. + public func canonicalPathKey(for path: String) -> String { + implementation(path) + } + + private static func foundationCanonicalPathKey(_ path: String) -> String { + guard path.hasPrefix("/") else { return path } + let sourceURL = URL(fileURLWithPath: path) + guard (try? sourceURL.checkResourceIsReachable()) == true else { + return path + } + let resolvedURL = sourceURL.resolvingSymlinksInPath().standardizedFileURL + if let values = try? resolvedURL.resourceValues(forKeys: [.canonicalPathKey]), + let canonicalPath = values.canonicalPath, + !canonicalPath.isEmpty { + return canonicalPath + } + return resolvedURL.path + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactScope.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactScope.swift index 5f9725abfcde..873b3498db76 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactScope.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactScope.swift @@ -6,6 +6,7 @@ public struct TerminalArtifactScope: Sendable { private let workingDirectory: String? private let resolver: any ChatArtifactScope.FileSystemResolving private let detector: TerminalArtifactPathDetector + private let canonicalizer: ChatArtifactPathCanonicalizer /// Creates a terminal artifact scope checker. /// @@ -14,16 +15,19 @@ public struct TerminalArtifactScope: Sendable { /// - workingDirectory: Terminal cwd used to resolve relative path tokens. /// - resolver: Filesystem resolver used for existence and canonicalization. /// - detector: Path-token detector. + /// - canonicalizer: Filesystem identity operation used to de-duplicate rows. public init( terminalText: String, workingDirectory: String?, resolver: any ChatArtifactScope.FileSystemResolving, - detector: TerminalArtifactPathDetector = TerminalArtifactPathDetector() + detector: TerminalArtifactPathDetector = TerminalArtifactPathDetector(), + canonicalizer: ChatArtifactPathCanonicalizer = ChatArtifactPathCanonicalizer() ) { self.terminalText = terminalText self.workingDirectory = workingDirectory self.resolver = resolver self.detector = detector + self.canonicalizer = canonicalizer } /// Current terminal artifact paths, canonicalized, existing, deduped, and capped. @@ -36,11 +40,13 @@ public struct TerminalArtifactScope: Sendable { var result: [String] = [] for candidate in candidates { guard resolver.isDirectory(candidate) != nil, - let canonical = ChatArtifactScope.canonicalizedPath(candidate, resolver: resolver), - !seen.contains(canonical) else { + let resolved = ChatArtifactScope.canonicalizedPath(candidate, resolver: resolver) else { + continue + } + let canonical = canonicalizer.canonicalPathKey(for: resolved) + guard seen.insert(canonical).inserted else { continue } - seen.insert(canonical) result.append(canonical) if result.count >= limit { break } } diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactCanonicalIdentityTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactCanonicalIdentityTests.swift index 90099f8a42a5..f0afc5ba9068 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactCanonicalIdentityTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactCanonicalIdentityTests.swift @@ -5,6 +5,30 @@ import Testing @Suite("Chat artifact canonical identity") struct ChatArtifactCanonicalIdentityTests { + @Test("derive accepts an injected canonical identity operation") + func injectedCanonicalizerDeduplicates() { + let target = "/fixture/Report.md" + let symlink = "/fixture/report-link.md" + let canonical = "/canonical/Report.md" + let canonicalizer = ChatArtifactPathCanonicalizer { path in + [target: canonical, symlink: canonical][path] ?? path + } + + let records = ChatArtifactIndexedReference.derive( + from: [ + editMessage(id: "created", seq: 4, path: target), + toolMessage(id: "referenced", seq: 9, path: symlink), + ], + canonicalizer: canonicalizer + ) + + #expect(records == [ChatArtifactIndexedReference( + path: canonical, + provenance: .created, + lastReferencedSeq: 9 + )]) + } + @Test("symlink and target spellings produce one row") func symlinkAndTargetDeduplicate() throws { let root = try makeTemporaryDirectory() diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySnapshotTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySnapshotTests.swift new file mode 100644 index 000000000000..26f9d8aadb99 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySnapshotTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact gallery snapshot") +struct ChatArtifactGallerySnapshotTests { + @Test("appending drops paths already present in any section") + func appendingDeduplicatesAcrossSections() { + let created = item(path: "/session/created.txt", provenance: .created) + let attached = item(path: "/session/attached.txt", provenance: .attached) + let referenced = item(path: "/session/referenced.txt", provenance: .referenced) + let next = item(path: "/session/next.txt", provenance: .referenced) + let initial = ChatArtifactGalleryPage( + sessionID: "session", + created: [created], + attached: [attached], + referenced: [referenced], + referencedTotal: 2, + nextCursor: "next", + generation: "generation" + ) + let page = ChatArtifactGalleryPage( + sessionID: "session", + referenced: [created, attached, referenced, next, next], + referencedTotal: 2, + generation: "generation" + ) + + let snapshot = ChatArtifactGallerySnapshot(page: initial).appending(page) + + #expect(snapshot.created.map(\.path) == [created.path]) + #expect(snapshot.attached.map(\.path) == [attached.path]) + #expect(snapshot.referenced.map(\.path) == [referenced.path, next.path]) + } + + private func item( + path: String, + provenance: ChatArtifactProvenance + ) -> ChatArtifactGalleryItem { + ChatArtifactGalleryItem( + path: path, + kind: .text, + displayName: (path as NSString).lastPathComponent, + provenance: provenance + ) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift index 0b28615d9f8d..de92f3d7505c 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift @@ -52,6 +52,25 @@ struct TerminalArtifactScopeTests { #expect(scope.canonicalPath(for: "/safe/project/src/main.swift") == "/safe/project/src/main.swift") } + @Test("visible scan deduplicates canonical identities in first-seen order") + func visibleScanCanonicalIdentityDeduplication() { + let canonicalizer = ChatArtifactPathCanonicalizer { path in + path == "/safe/report.txt" ? "/safe/Report.txt" : path + } + let scope = TerminalArtifactScope( + terminalText: "cat /safe/report.txt /safe/notes.txt /safe/Report.txt", + workingDirectory: "/safe", + resolver: FakeResolver( + files: ["/safe/report.txt", "/safe/notes.txt", "/safe/Report.txt"], + directories: ["/safe"], + symlinks: [:] + ), + canonicalizer: canonicalizer + ) + + #expect(scope.artifactPaths() == ["/safe/Report.txt", "/safe/notes.txt"]) + } + private func scope( text: String, workingDirectory: String? = "/safe/project", diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift index 6036143d4384..a08526de2f22 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift @@ -252,54 +252,7 @@ struct TerminalArtifactFilesSheet: View { case failed } - struct SessionGallerySnapshot: Equatable { - let created: [ChatArtifactGalleryItem] - let attached: [ChatArtifactGalleryItem] - let referenced: [ChatArtifactGalleryItem] - let referencedTotal: Int - let nextCursor: String? - let generation: String - - var isEmpty: Bool { - created.isEmpty && attached.isEmpty && referencedTotal == 0 - } - - init(page: ChatArtifactGalleryPage) { - created = page.created - attached = page.attached - referenced = page.referenced - referencedTotal = page.referencedTotal - nextCursor = page.nextCursor - generation = page.generation - } - - func appending(_ page: ChatArtifactGalleryPage) -> SessionGallerySnapshot { - SessionGallerySnapshot( - created: created, - attached: attached, - referenced: referenced + page.referenced, - referencedTotal: page.referencedTotal, - nextCursor: page.nextCursor, - generation: page.generation - ) - } - - private init( - created: [ChatArtifactGalleryItem], - attached: [ChatArtifactGalleryItem], - referenced: [ChatArtifactGalleryItem], - referencedTotal: Int, - nextCursor: String?, - generation: String - ) { - self.created = created - self.attached = attached - self.referenced = referenced - self.referencedTotal = referencedTotal - self.nextCursor = nextCursor - self.generation = generation - } - } + typealias SessionGallerySnapshot = ChatArtifactGallerySnapshot enum Scope: Hashable { case inView diff --git a/Sources/TerminalController+MobileTerminalArtifacts.swift b/Sources/TerminalController+MobileTerminalArtifacts.swift index 49473e3c3ff7..8d5850e68d04 100644 --- a/Sources/TerminalController+MobileTerminalArtifacts.swift +++ b/Sources/TerminalController+MobileTerminalArtifacts.swift @@ -52,31 +52,31 @@ extension TerminalController { } if countOnly { guard let sessionID = context.sessionID else { - return .ok(TerminalArtifactWire.payload( + return TerminalArtifactWire.result( TerminalArtifactScanResponse(artifacts: []) - ) ?? [:]) + ) } do { guard let indexedSession = try await mobileChatArtifactIndexedSession(sessionID: sessionID) else { - return .ok(TerminalArtifactWire.payload( + return TerminalArtifactWire.result( TerminalArtifactScanResponse(artifacts: [], sessionID: sessionID) - ) ?? [:]) + ) } let response = TerminalArtifactScanResponse.sessionCount( sessionID: indexedSession.sessionID, sessionArtifacts: indexedSession.snapshot.artifacts ) - return .ok(TerminalArtifactWire.payload(response) ?? [:]) + return TerminalArtifactWire.result(response) } catch { - return .ok(TerminalArtifactWire.payload( + return TerminalArtifactWire.result( TerminalArtifactScanResponse(artifacts: [], sessionID: sessionID) - ) ?? [:]) + ) } } let response = await Task.detached(priority: .utility) { context.scan() }.value - return .ok(TerminalArtifactWire.payload(response) ?? [:]) + return TerminalArtifactWire.result(response) } func v2MobileTerminalArtifactStat(params: [String: Any]) async -> V2CallResult { @@ -90,7 +90,7 @@ extension TerminalController { try reader.stat(path: canonicalPath) } }.value - return .ok(TerminalArtifactWire.payload(stat) ?? [:]) + return TerminalArtifactWire.result(stat) } catch TerminalArtifactReadContext.Error.forbidden { debugLogMobileTerminalArtifactDenial(op: "stat", path: context.requestedPath) return mobileTerminalArtifactError(.forbidden, path: context.requestedPath) @@ -117,7 +117,7 @@ extension TerminalController { try reader.fetch(path: canonicalPath, offset: offset, length: length) } }.value - return .ok(TerminalArtifactWire.payload(chunk) ?? [:]) + return TerminalArtifactWire.result(chunk) } catch TerminalArtifactReadContext.Error.forbidden { debugLogMobileTerminalArtifactDenial(op: "fetch", path: context.requestedPath) return mobileTerminalArtifactError(.forbidden, path: context.requestedPath) @@ -140,7 +140,7 @@ extension TerminalController { try reader.thumbnail(path: canonicalPath, maxDimension: maxDimension) } }.value - return .ok(TerminalArtifactWire.payload(thumbnail) ?? [:]) + return TerminalArtifactWire.result(thumbnail) } catch TerminalArtifactReadContext.Error.forbidden { debugLogMobileTerminalArtifactDenial(op: "thumbnail", path: context.requestedPath) return mobileTerminalArtifactError(.forbidden, path: context.requestedPath) @@ -335,12 +335,16 @@ private struct TerminalArtifactReadContext: Sendable { } private struct TerminalArtifactWire { - static func payload(_ value: T) -> [String: Any]? { + static func result(_ value: T) -> TerminalController.V2CallResult { let coding = ChatWireCoding() guard let data = try? coding.encode(value), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return nil + return .err( + code: "internal_error", + message: "Failed to encode terminal artifact response", + data: nil + ) } - return object + return .ok(object) } } From 0d3b3cee73071fd302d2cf49c66010660e4ec267 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 13:01:39 -0700 Subject: [PATCH 03/92] test: drop duplicated iOS session gallery snapshot suite SessionGallerySnapshot is a typealias of the shared ChatArtifactGallerySnapshot, whose behavior is covered by ChatArtifactGallerySnapshotTests in CmuxAgentChat. Co-Authored-By: Claude Fable 5 --- .../SessionGallerySnapshotTests.swift | 52 ------------------- 1 file changed, 52 deletions(-) delete mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/SessionGallerySnapshotTests.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/SessionGallerySnapshotTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/SessionGallerySnapshotTests.swift deleted file mode 100644 index 04b28fd0e772..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/SessionGallerySnapshotTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -#if os(iOS) -import CmuxAgentChat -import Foundation -import Testing - -@testable import CmuxMobileShellUI - -@Suite("Session gallery snapshot") -struct SessionGallerySnapshotTests { - @Test("appending drops paths already present in any section") - func appendingDeduplicatesAcrossSections() { - let created = item(path: "/session/created.txt", provenance: .created) - let attached = item(path: "/session/attached.txt", provenance: .attached) - let referenced = item(path: "/session/referenced.txt", provenance: .referenced) - let next = item(path: "/session/next.txt", provenance: .referenced) - let initial = ChatArtifactGalleryPage( - sessionID: "session", - created: [created], - attached: [attached], - referenced: [referenced], - referencedTotal: 2, - nextCursor: "next", - generation: "generation" - ) - let page = ChatArtifactGalleryPage( - sessionID: "session", - referenced: [created, attached, referenced, next, next], - referencedTotal: 2, - generation: "generation" - ) - - let snapshot = TerminalArtifactFilesSheet.SessionGallerySnapshot(page: initial) - .appending(page) - - #expect(snapshot.created.map(\.path) == [created.path]) - #expect(snapshot.attached.map(\.path) == [attached.path]) - #expect(snapshot.referenced.map(\.path) == [referenced.path, next.path]) - } - - private func item( - path: String, - provenance: ChatArtifactProvenance - ) -> ChatArtifactGalleryItem { - ChatArtifactGalleryItem( - path: path, - kind: .text, - displayName: (path as NSString).lastPathComponent, - provenance: provenance - ) - } -} -#endif From ad349f9a247b16c9401cde1793b1bd694a5cb635 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 13:01:41 -0700 Subject: [PATCH 04/92] fix: use one canonical identity in TerminalArtifactScope.canonicalPath(for:) Apply the injected ChatArtifactPathCanonicalizer to the requested path and each candidate so single-path resolution returns the same identity key that artifactPaths(limit:) lists, keeping scope identity consistent across both entrypoints. Co-Authored-By: Claude Fable 5 --- .../Artifacts/TerminalArtifactScope.swift | 15 +++++++------- .../TerminalArtifactScopeTests.swift | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactScope.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactScope.swift index 873b3498db76..59b8d1d86dad 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactScope.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactScope.swift @@ -58,19 +58,20 @@ public struct TerminalArtifactScope: Sendable { /// - Parameter path: Requested path, absolute or relative to the terminal cwd. /// - Returns: Canonical path when authorized, otherwise `nil`. public func canonicalPath(for path: String) -> String? { - guard let absoluteRequest = absolutePath(for: path) else { - return nil - } - guard let canonicalRequest = ChatArtifactScope.canonicalizedPath(absoluteRequest, resolver: resolver) else { + guard let absoluteRequest = absolutePath(for: path), + let resolvedRequest = ChatArtifactScope.canonicalizedPath(absoluteRequest, resolver: resolver) else { return nil } + let canonicalRequest = canonicalizer.canonicalPathKey(for: resolvedRequest) var seen: Set = [] for candidate in detector.paths(in: terminalText).compactMap(absolutePath(for:)) { - guard let canonical = ChatArtifactScope.canonicalizedPath(candidate, resolver: resolver), - !seen.contains(canonical) else { + guard let resolved = ChatArtifactScope.canonicalizedPath(candidate, resolver: resolver) else { + continue + } + let canonical = canonicalizer.canonicalPathKey(for: resolved) + guard seen.insert(canonical).inserted else { continue } - seen.insert(canonical) if canonical == canonicalRequest { return canonicalRequest } diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift index de92f3d7505c..3256f40ec0b0 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift @@ -71,6 +71,26 @@ struct TerminalArtifactScopeTests { #expect(scope.artifactPaths() == ["/safe/Report.txt", "/safe/notes.txt"]) } + @Test("request resolution shares the visible-scan canonical identity") + func requestResolutionUsesCanonicalIdentity() { + let canonicalizer = ChatArtifactPathCanonicalizer { path in + path == "/safe/report.txt" ? "/safe/Report.txt" : path + } + let scope = TerminalArtifactScope( + terminalText: "cat /safe/report.txt", + workingDirectory: "/safe", + resolver: FakeResolver( + files: ["/safe/report.txt", "/safe/Report.txt"], + directories: ["/safe"], + symlinks: [:] + ), + canonicalizer: canonicalizer + ) + + #expect(scope.canonicalPath(for: "/safe/report.txt") == "/safe/Report.txt") + #expect(scope.canonicalPath(for: "/safe/Report.txt") == "/safe/Report.txt") + } + private func scope( text: String, workingDirectory: String? = "/safe/project", From 771a2b70c88a8847bc46b5b10371434a6b9a5f44 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 12:51:03 -0700 Subject: [PATCH 05/92] feat: add mobile artifact folder access policy --- .../Artifacts/ChatArtifactScope.swift | 61 ++++++++--- .../Artifacts/TerminalArtifactScope.swift | 58 ++++++---- .../ChatArtifactScopeTests.swift | 52 ++++++++- .../TerminalArtifactScopeTests.swift | 51 ++++++++- .../Keys/MobileCatalogSection.swift | 7 ++ .../Values/MobileArtifactFolderAccess.swift | 10 ++ .../MobileArtifactFolderAccessTests.swift | 17 +++ .../SettingCatalogTests.swift | 1 + .../CuratedSettingEntry+Default.swift | 8 ++ .../Sections/MobileSection.swift | 54 ++++++++++ .../SettingsRowAnchorResolutionTests.swift | 1 + Resources/Localizable.xcstrings | 102 ++++++++++++++++++ Sources/CmuxSettingsJSONPathSupport.swift | 1 + ...rdShortcutSettingsFileStore+Template.swift | 5 + .../KeyboardShortcutSettingsFileStore.swift | 18 ++++ .../AgentChat/AgentChatArtifactIndex.swift | 17 +-- Sources/SettingsSearchAliases.swift | 1 + ...rminalController+MobileChatArtifacts.swift | 20 +++- ...alController+MobileTerminalArtifacts.swift | 15 ++- docs/configuration.md | 17 +++ web/data/cmux.schema.json | 14 +++ 21 files changed, 482 insertions(+), 48 deletions(-) create mode 100644 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/MobileArtifactFolderAccess.swift create mode 100644 Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/MobileArtifactFolderAccessTests.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactScope.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactScope.swift index ff7ad0f0f718..fbfccdcd8abe 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactScope.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactScope.swift @@ -5,10 +5,18 @@ import Foundation /// The checker canonicalizes transcript-referenced paths and requested paths /// through an injected resolver, then answers whether a request may stat, /// fetch, thumbnail, or list a path without touching the requested path's -/// filesystem metadata. File operations are allowed for exact referenced -/// paths and for one immediate child of a referenced directory. Directory -/// listing is allowed only for directories that were themselves referenced. +/// filesystem metadata. Exact referenced paths are always allowed. Descendants +/// of referenced directories follow the configured ``DirectoryAccessMode``. public struct ChatArtifactScope: Sendable { + /// How referenced directories authorize descendant paths. + public enum DirectoryAccessMode: String, Sendable { + /// Authorize every canonical descendant of a referenced directory. + case subtree + /// Authorize immediate children for file operations and only the + /// referenced directory itself for listing, matching the legacy rule. + case oneLevel + } + /// Filesystem operations needed to canonicalize and classify referenced paths. public protocol FileSystemResolving: Sendable { /// Resolves symlinks in a path and returns a filesystem path string. @@ -53,21 +61,25 @@ public struct ChatArtifactScope: Sendable { private static let maximumPathLength = 4096 - private let referencedPaths: Set private let referencedCanonicalPaths: Set private let referencedDirectoryCanonicalPaths: Set + private let directoryAccessMode: DirectoryAccessMode private let resolver: any FileSystemResolving /// Creates a scope checker from transcript-referenced path strings. /// /// - Parameters: /// - referencedPaths: Paths as they appeared in the transcript. + /// - directoryAccessMode: Descendant authorization policy. The default + /// preserves the legacy one-level behavior for existing callers. /// - resolver: Filesystem resolver used for canonicalization. public init( referencedPaths: Set, + directoryAccessMode: DirectoryAccessMode = .oneLevel, resolver: any FileSystemResolving ) { self.resolver = resolver + self.directoryAccessMode = directoryAccessMode let canonical = referencedPaths.compactMap { path -> ReferencedPath? in guard let canonicalPath = Self.canonicalPath(path, resolver: resolver) else { return nil @@ -77,7 +89,6 @@ public struct ChatArtifactScope: Sendable { isDirectory: resolver.isDirectory(canonicalPath) ) } - self.referencedPaths = Set(canonical) self.referencedCanonicalPaths = Set(canonical.map(\.canonicalPath)) self.referencedDirectoryCanonicalPaths = Set( canonical.compactMap { $0.isDirectory == true ? $0.canonicalPath : nil } @@ -95,10 +106,18 @@ public struct ChatArtifactScope: Sendable { if referencedCanonicalPaths.contains(canonicalPath) { return canonicalPath } - guard let parent = Self.parentPath(ofCanonicalPath: canonicalPath), - referencedDirectoryCanonicalPaths.contains(parent) - else { - return nil + switch directoryAccessMode { + case .subtree: + guard referencedDirectoryCanonicalPaths.contains(where: { + Self.isCanonicalPath(canonicalPath, containedIn: $0) + }) else { + return nil + } + case .oneLevel: + guard let parent = Self.parentPath(ofCanonicalPath: canonicalPath), + referencedDirectoryCanonicalPaths.contains(parent) else { + return nil + } } return canonicalPath } @@ -120,12 +139,19 @@ public struct ChatArtifactScope: Sendable { /// Resolves an allowed directory-list request to its canonical path. /// /// - Parameter path: Requested absolute directory path. - /// - Returns: Canonical path when the directory itself was referenced, - /// otherwise `nil`. + /// - Returns: Canonical path when the directory itself was referenced, or + /// when subtree access permits the canonical descendant; otherwise `nil`. public func canonicalDirectoryListPath(for path: String) -> String? { - guard let canonicalPath = Self.canonicalPath(path, resolver: resolver), - referencedPaths.contains(ReferencedPath(canonicalPath: canonicalPath, isDirectory: true)) - else { + guard let canonicalPath = Self.canonicalPath(path, resolver: resolver) else { + return nil + } + if referencedCanonicalPaths.contains(canonicalPath) { + return canonicalPath + } + guard directoryAccessMode == .subtree, + referencedDirectoryCanonicalPaths.contains(where: { + Self.isCanonicalPath(canonicalPath, containedIn: $0) + }) else { return nil } return canonicalPath @@ -162,4 +188,11 @@ public struct ChatArtifactScope: Sendable { } return parent } + + /// Tests containment only after both inputs have been canonicalized. + private static func isCanonicalPath(_ path: String, containedIn directory: String) -> Bool { + guard path != directory else { return true } + let prefix = directory == "/" ? "/" : "\(directory)/" + return path.hasPrefix(prefix) + } } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactScope.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactScope.swift index 59b8d1d86dad..e33694715e12 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactScope.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactScope.swift @@ -1,12 +1,13 @@ import Foundation -/// Exact artifact scope for paths currently present in terminal text. +/// Artifact scope for paths currently present in terminal text and authorized descendants. public struct TerminalArtifactScope: Sendable { private let terminalText: String private let workingDirectory: String? private let resolver: any ChatArtifactScope.FileSystemResolving private let detector: TerminalArtifactPathDetector private let canonicalizer: ChatArtifactPathCanonicalizer + private let directoryAccessMode: ChatArtifactScope.DirectoryAccessMode /// Creates a terminal artifact scope checker. /// @@ -16,18 +17,22 @@ public struct TerminalArtifactScope: Sendable { /// - resolver: Filesystem resolver used for existence and canonicalization. /// - detector: Path-token detector. /// - canonicalizer: Filesystem identity operation used to de-duplicate rows. + /// - directoryAccessMode: Descendant authorization policy. The default + /// preserves exact/one-level legacy behavior for existing callers. public init( terminalText: String, workingDirectory: String?, resolver: any ChatArtifactScope.FileSystemResolving, detector: TerminalArtifactPathDetector = TerminalArtifactPathDetector(), - canonicalizer: ChatArtifactPathCanonicalizer = ChatArtifactPathCanonicalizer() + canonicalizer: ChatArtifactPathCanonicalizer = ChatArtifactPathCanonicalizer(), + directoryAccessMode: ChatArtifactScope.DirectoryAccessMode = .oneLevel ) { self.terminalText = terminalText self.workingDirectory = workingDirectory self.resolver = resolver self.detector = detector self.canonicalizer = canonicalizer + self.directoryAccessMode = directoryAccessMode } /// Current terminal artifact paths, canonicalized, existing, deduped, and capped. @@ -53,30 +58,47 @@ public struct TerminalArtifactScope: Sendable { return result } - /// Resolves a request when its exact canonical path appears in terminal text. + /// Resolves a file request against canonical visible paths and directory policy. /// /// - Parameter path: Requested path, absolute or relative to the terminal cwd. /// - Returns: Canonical path when authorized, otherwise `nil`. public func canonicalPath(for path: String) -> String? { guard let absoluteRequest = absolutePath(for: path), - let resolvedRequest = ChatArtifactScope.canonicalizedPath(absoluteRequest, resolver: resolver) else { + let canonicalRequest = canonicalIdentity(for: absoluteRequest) else { return nil } - let canonicalRequest = canonicalizer.canonicalPathKey(for: resolvedRequest) - var seen: Set = [] - for candidate in detector.paths(in: terminalText).compactMap(absolutePath(for:)) { - guard let resolved = ChatArtifactScope.canonicalizedPath(candidate, resolver: resolver) else { - continue - } - let canonical = canonicalizer.canonicalPathKey(for: resolved) - guard seen.insert(canonical).inserted else { - continue - } - if canonical == canonicalRequest { - return canonicalRequest - } + return artifactScope().canonicalFilePath(for: canonicalRequest) + } + + /// Resolves a directory-list request against canonical visible paths and policy. + /// + /// - Parameter path: Requested path, absolute or relative to the terminal cwd. + /// - Returns: Canonical directory path when authorized, otherwise `nil`. + public func canonicalDirectoryListPath(for path: String) -> String? { + guard let absoluteRequest = absolutePath(for: path), + let canonicalRequest = canonicalIdentity(for: absoluteRequest) else { + return nil + } + return artifactScope().canonicalDirectoryListPath(for: canonicalRequest) + } + + private func artifactScope() -> ChatArtifactScope { + ChatArtifactScope( + referencedPaths: Set( + detector.paths(in: terminalText) + .compactMap(absolutePath(for:)) + .compactMap(canonicalIdentity(for:)) + ), + directoryAccessMode: directoryAccessMode, + resolver: resolver + ) + } + + private func canonicalIdentity(for path: String) -> String? { + guard let resolved = ChatArtifactScope.canonicalizedPath(path, resolver: resolver) else { + return nil } - return nil + return canonicalizer.canonicalPathKey(for: resolved) } private func absolutePath(for token: String) -> String? { diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactScopeTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactScopeTests.swift index 0a6c8b7e40e1..94961173f317 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactScopeTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactScopeTests.swift @@ -23,6 +23,30 @@ struct ChatArtifactScopeTests { #expect(scope.canonicalFilePath(for: "/safe/dir/nested/image.png") == nil) } + @Test("subtree mode allows nested descendants and nested directory listings") + func subtreeAllowsNestedDescendants() { + let scope = scope( + referenced: ["/safe/dir"], + directories: ["/safe/dir", "/safe/dir/nested"], + accessMode: .subtree + ) + #expect(scope.canonicalFilePath(for: "/safe/dir/nested/image.png") == "/safe/dir/nested/image.png") + #expect(scope.canonicalDirectoryListPath(for: "/safe/dir/nested") == "/safe/dir/nested") + } + + @Test("one-level mode preserves legacy file and list authorization") + func oneLevelParity() { + let scope = scope( + referenced: ["/safe/dir"], + directories: ["/safe/dir", "/safe/dir/nested"], + accessMode: .oneLevel + ) + #expect(scope.canonicalFilePath(for: "/safe/dir/image.png") == "/safe/dir/image.png") + #expect(scope.canonicalFilePath(for: "/safe/dir/nested/image.png") == nil) + #expect(scope.canonicalDirectoryListPath(for: "/safe/dir") == "/safe/dir") + #expect(scope.canonicalDirectoryListPath(for: "/safe/dir/nested") == nil) + } + @Test("denies parent traversal escape") func deniesParentTraversalEscape() { let scope = scope(referenced: ["/safe/dir"], directories: ["/safe/dir"]) @@ -39,6 +63,30 @@ struct ChatArtifactScopeTests { #expect(scope.canonicalFilePath(for: "/safe/dir/link") == nil) } + @Test("subtree mode denies a symlink escape before containment comparison") + func subtreeDeniesSymlinkEscape() { + let scope = scope( + referenced: ["/safe/dir"], + directories: ["/safe/dir"], + symlinks: ["/safe/dir/nested/link": "/outside/secret.txt"], + accessMode: .subtree + ) + #expect(scope.canonicalFilePath(for: "/safe/dir/nested/link") == nil) + } + + @Test("subtree mode uniformly denies existing and missing paths outside scope") + func subtreeUniformOutsideDenial() { + let scope = scope( + referenced: ["/safe/dir"], + directories: ["/safe/dir", "/outside/existing"], + accessMode: .subtree + ) + #expect(scope.canonicalFilePath(for: "/outside/existing/file.txt") == nil) + #expect(scope.canonicalFilePath(for: "/outside/missing/file.txt") == nil) + #expect(scope.canonicalDirectoryListPath(for: "/outside/existing") == nil) + #expect(scope.canonicalDirectoryListPath(for: "/outside/missing") == nil) + } + @Test("denies relative path") func deniesRelativePath() { let scope = scope(referenced: ["/safe/file.txt"]) @@ -64,10 +112,12 @@ struct ChatArtifactScopeTests { private func scope( referenced: Set, directories: Set = [], - symlinks: [String: String] = [:] + symlinks: [String: String] = [:], + accessMode: ChatArtifactScope.DirectoryAccessMode = .oneLevel ) -> ChatArtifactScope { ChatArtifactScope( referencedPaths: referenced, + directoryAccessMode: accessMode, resolver: FakeResolver(directories: directories, symlinks: symlinks) ) } diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift index 3256f40ec0b0..62d5f2ec64aa 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift @@ -52,6 +52,51 @@ struct TerminalArtifactScopeTests { #expect(scope.canonicalPath(for: "/safe/project/src/main.swift") == "/safe/project/src/main.swift") } + @Test("subtree mode authorizes descendants of a visible directory") + func subtreeDirectoryAuthorization() { + let scope = scope( + text: "ls /safe/project", + accessMode: .subtree + ) + #expect(scope.canonicalPath(for: "/safe/project/src/main.swift") == "/safe/project/src/main.swift") + #expect(scope.canonicalDirectoryListPath(for: "/safe/project/src") == "/safe/project/src") + } + + @Test("one-level mode preserves visible-directory parity") + func oneLevelDirectoryAuthorization() { + let scope = scope( + text: "ls /safe/project", + accessMode: .oneLevel + ) + #expect(scope.canonicalPath(for: "/safe/project/readme.md") == "/safe/project/readme.md") + #expect(scope.canonicalPath(for: "/safe/project/src/main.swift") == nil) + #expect(scope.canonicalDirectoryListPath(for: "/safe/project") == "/safe/project") + #expect(scope.canonicalDirectoryListPath(for: "/safe/project/src") == nil) + } + + @Test("subtree mode denies a symlink escape from a visible directory") + func subtreeDeniesSymlinkEscape() { + let scope = scope( + text: "ls /safe/project", + files: ["/outside/secret.txt"], + symlinks: ["/safe/project/src/link": "/outside/secret.txt"], + accessMode: .subtree + ) + #expect(scope.canonicalPath(for: "/safe/project/src/link") == nil) + } + + @Test("subtree mode uniformly denies existing and missing paths outside visible scope") + func subtreeUniformOutsideDenial() { + let scope = scope( + text: "ls /safe/project", + files: ["/outside/existing.txt"], + accessMode: .subtree + ) + #expect(scope.canonicalPath(for: "/outside/existing.txt") == nil) + #expect(scope.canonicalPath(for: "/outside/missing.txt") == nil) + #expect(scope.canonicalDirectoryListPath(for: "/outside/existing") == nil) + #expect(scope.canonicalDirectoryListPath(for: "/outside/missing") == nil) + } @Test("visible scan deduplicates canonical identities in first-seen order") func visibleScanCanonicalIdentityDeduplication() { let canonicalizer = ChatArtifactPathCanonicalizer { path in @@ -100,12 +145,14 @@ struct TerminalArtifactScopeTests { "/safe/project/notes/todo.md", ], directories: Set = ["/safe", "/safe/project", "/safe/project/src", "/safe/project/notes"], - symlinks: [String: String] = [:] + symlinks: [String: String] = [:], + accessMode: ChatArtifactScope.DirectoryAccessMode = .oneLevel ) -> TerminalArtifactScope { TerminalArtifactScope( terminalText: text, workingDirectory: workingDirectory, - resolver: FakeResolver(files: files, directories: directories, symlinks: symlinks) + resolver: FakeResolver(files: files, directories: directories, symlinks: symlinks), + directoryAccessMode: accessMode ) } diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/MobileCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/MobileCatalogSection.swift index c5ecc170589f..ff939bdd5625 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/MobileCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/MobileCatalogSection.swift @@ -2,6 +2,13 @@ import Foundation /// Mobile integration settings for pairing and syncing with cmux on iOS. public struct MobileCatalogSection: SettingCatalogSection { + /// Folder paths that iOS may access after a chat or terminal references a directory. + public let artifactFolderAccess = DefaultsKey( + id: "mobile.artifactFolderAccess", + defaultValue: .subtree, + userDefaultsKey: "mobile.artifactFolderAccess" + ) + /// Mac-side iOS pairing host. Release defaults OFF so macOS never asks for /// Local Network permission until the user opts in from Settings. DEBUG /// (dev) builds default ON so a dev Mac advertises its attach route without a diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/MobileArtifactFolderAccess.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/MobileArtifactFolderAccess.swift new file mode 100644 index 000000000000..f4f39e83f5c9 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/MobileArtifactFolderAccess.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Mac-side authorization policy for folders browsed from cmux on iOS. +public enum MobileArtifactFolderAccess: String, CaseIterable, Sendable, SettingCodable { + /// Allow referenced directories and every canonical descendant. + case subtree + /// Preserve legacy access: immediate children for file operations and no + /// recursive directory listing. + case oneLevel +} diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/MobileArtifactFolderAccessTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/MobileArtifactFolderAccessTests.swift new file mode 100644 index 000000000000..f7415a2df2f7 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/MobileArtifactFolderAccessTests.swift @@ -0,0 +1,17 @@ +import Testing + +@testable import CmuxSettings + +@Suite("Mobile artifact folder access") +struct MobileArtifactFolderAccessTests { + @Test("defaults to subtree access") + func defaultValue() { + #expect(SettingCatalog().mobile.artifactFolderAccess.defaultValue == .subtree) + } + + @Test("one-level value round-trips through settings storage") + func oneLevelRoundTrip() { + let encoded = MobileArtifactFolderAccess.oneLevel.encodeForUserDefaults() + #expect(MobileArtifactFolderAccess.decodeFromUserDefaults(encoded) == .oneLevel) + } +} diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/SettingCatalogTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/SettingCatalogTests.swift index dfb2f4985e0a..05828e11ba5f 100644 --- a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/SettingCatalogTests.swift +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/SettingCatalogTests.swift @@ -92,6 +92,7 @@ struct SettingCatalogTests { #expect(ids.contains("paneBorderColor")) #expect(ids.contains("activePaneBorderColor")) #expect(ids.contains("mobile.iOSPairingHost.enabled")) + #expect(ids.contains("mobile.artifactFolderAccess")) #expect(ids.contains("automation.socketControlMode")) #expect(ids.contains("automation.socketPassword")) } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index 22f6b7705d2a..52b899a9f4de 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -189,6 +189,14 @@ extension Array where Element == CuratedSettingEntry { .init(section: .mobile, id: "iOSPairingHost", title: "iOS Pairing", synonyms: "ios iphone ipad mobile pairing local network permission sync"), .init(section: .mobile, id: "iOSPairingPort", title: String(localized: "settings.mobile.port", defaultValue: "Pairing Port"), synonyms: "mobile.iOSPairingHost.port ios iphone mobile pairing port tcp listener firewall conflict"), .init(section: .mobile, id: "iOSPairingDisplayName", title: String(localized: "settings.mobile.displayName", defaultValue: "Display Name"), synonyms: "mobile.iOSPairingHost.displayName ios iphone mobile pairing display name mac hostname device label"), + .init( + section: .mobile, + id: "artifactFolderAccess", + title: String(localized: "settings.mobile.artifactFolderAccess", defaultValue: "Folder Access"), + detailText: String(localized: "settings.mobile.artifactFolderAccess.subtitleSubtree", defaultValue: "Lets iOS browse any item inside a folder referenced by chat or visible in a terminal."), + paths: ["mobile.artifactFolderAccess"], + synonyms: "ios iphone ipad mobile files folders directory subtree one level authorization security" + ), // Custom Sidebars .init(section: .customSidebars, id: "enabled", title: String(localized: "settings.customSidebars.enabled", defaultValue: "Show Custom Sidebars"), synonyms: "custom sidebars enable show vibe swift json interpreted picker beta"), diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/MobileSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/MobileSection.swift index 744dce3c079f..1e9c3fbccab5 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/MobileSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/MobileSection.swift @@ -11,6 +11,7 @@ public struct MobileSection: View { @State private var iOSPairingHost: DefaultsValueModel @State private var port: DefaultsValueModel @State private var displayName: DefaultsValueModel + @State private var artifactFolderAccess: DefaultsValueModel @State private var status: MobilePairingStatusModel /// The user's in-progress port edit, or `nil` when the field should track @@ -45,6 +46,10 @@ public struct MobileSection: View { _iOSPairingHost = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.mobile.iOSPairingHost)) _port = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.mobile.iOSPairingPort)) _displayName = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.mobile.iOSPairingDisplayName)) + _artifactFolderAccess = State(initialValue: DefaultsValueModel( + store: defaultsStore, + key: catalog.mobile.artifactFolderAccess + )) _status = State(initialValue: MobilePairingStatusModel(hostActions: hostActions)) self.hostActions = hostActions } @@ -78,6 +83,8 @@ public struct MobileSection: View { boundPortStatusRow SettingsCardDivider() displayNameRow + SettingsCardDivider() + artifactFolderAccessRow if iOSPairingHost.current { SettingsCardDivider() diagnostics @@ -96,6 +103,7 @@ public struct MobileSection: View { iOSPairingHost, port, displayName, + artifactFolderAccess, status, ] models.forEach { $0.startObserving() } @@ -278,6 +286,52 @@ public struct MobileSection: View { } } + @ViewBuilder + private var artifactFolderAccessRow: some View { + SettingsCardRow( + configurationReview: .json("mobile.artifactFolderAccess"), + String(localized: "settings.mobile.artifactFolderAccess", defaultValue: "Folder Access"), + subtitle: artifactFolderAccessSubtitle + ) { + Picker( + "", + selection: Binding( + get: { artifactFolderAccess.current }, + set: { artifactFolderAccess.set($0) } + ) + ) { + Text(String( + localized: "settings.mobile.artifactFolderAccess.subtree", + defaultValue: "Entire Subtree" + )) + .tag(MobileArtifactFolderAccess.subtree) + Text(String( + localized: "settings.mobile.artifactFolderAccess.oneLevel", + defaultValue: "One Level" + )) + .tag(MobileArtifactFolderAccess.oneLevel) + } + .labelsHidden() + .pickerStyle(.menu) + .accessibilityIdentifier("SettingsMobileArtifactFolderAccessPicker") + } + } + + private var artifactFolderAccessSubtitle: String { + switch artifactFolderAccess.current { + case .subtree: + String( + localized: "settings.mobile.artifactFolderAccess.subtitleSubtree", + defaultValue: "Lets iOS browse any item inside a folder referenced by chat or visible in a terminal." + ) + case .oneLevel: + String( + localized: "settings.mobile.artifactFolderAccess.subtitleOneLevel", + defaultValue: "Limits iOS to immediate children of referenced or visible folders." + ) + } + } + /// Read-only connection count and the reachable routes the phone can use. @ViewBuilder private var diagnostics: some View { diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift index 2fea18222458..826aa5ed61b8 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -81,6 +81,7 @@ struct SettingsRowAnchorResolutionTests { "canvas.snappingEnabled", "customSidebars.renderer", "fileEditor.wordWrap", + "mobile.artifactFolderAccess", "notifications.agentIdleReminder", "notifications.agentPermissionPrompt", "notifications.agentTurnComplete", diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 9a13395d3799..56ab2948432f 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -161985,6 +161985,91 @@ } } }, + "settings.mobile.artifactFolderAccess": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Folder Access" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダアクセス" + } + } + } + }, + "settings.mobile.artifactFolderAccess.oneLevel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "One Level" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1 階層" + } + } + } + }, + "settings.mobile.artifactFolderAccess.subtree": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Entire Subtree" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サブツリー全体" + } + } + } + }, + "settings.mobile.artifactFolderAccess.subtitleOneLevel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Limits iOS to immediate children of referenced or visible folders." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "iOS からのアクセスを、参照された、または表示中のフォルダの直下の項目に制限します。" + } + } + } + }, + "settings.mobile.artifactFolderAccess.subtitleSubtree": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lets iOS browse any item inside a folder referenced by chat or visible in a terminal." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チャットで参照された、またはターミナルに表示されたフォルダ内のすべての項目を iOS から参照できるようにします。" + } + } + } + }, "settings.mobile.connections": { "extractionState": "manual", "localizations": { @@ -174673,6 +174758,23 @@ } } }, + "settings.search.alias.setting.mobile.artifactFolderAccess": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "mobile ios iphone files folders directory subtree one level authorization security" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モバイル ios iphone ファイル フォルダ ディレクトリ サブツリー 1 階層 認証 セキュリティ" + } + } + } + }, "settings.search.alias.setting.mobile.iOSPairingDisplayName": { "extractionState": "manual", "localizations": { diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index 86a5346f29e7..92248fe8198a 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -466,6 +466,7 @@ extension CmuxSettingsFileStore { "browser.insecureHttpHostsAllowedInEmbeddedBrowser", "browser.showImportHintOnBlankTabs", "browser.reactGrabVersion", + "mobile.artifactFolderAccess", "markdown.fontSize", "markdown.fontFamily", "markdown.maxWidth", diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index fc6d61cd376c..6cb8529faa93 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -208,6 +208,11 @@ extension CmuxSettingsFileStore { "reactGrabVersion": ReactGrabSettings.defaultVersion, ], ], + [ + "mobile": [ + "artifactFolderAccess": SettingCatalog().mobile.artifactFolderAccess.defaultValue.rawValue, + ], + ], [ "markdown": [ "fontSize": Int(MarkdownFontSizeSettings.defaultPointSize), diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index f7ecbc0180b9..0c72ff1c0984 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -377,6 +377,9 @@ final class CmuxSettingsFileStore { if let browserSection = root["browser"] as? [String: Any] { parseBrowserSection(browserSection, sourcePath: sourcePath, snapshot: &snapshot) } + if let mobileSection = root["mobile"] as? [String: Any] { + parseMobileSection(mobileSection, sourcePath: sourcePath, snapshot: &snapshot) + } if let markdownSection = root["markdown"] as? [String: Any] { parseMarkdownSection(markdownSection, sourcePath: sourcePath, snapshot: &snapshot) } @@ -938,6 +941,21 @@ final class CmuxSettingsFileStore { applyNormalizedStringArraySettings(BrowserSettingsFileMapping.stringArraySettings, from: section, sourcePath: sourcePath, snapshot: &snapshot) } + private func parseMobileSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + guard section.keys.contains("artifactFolderAccess") else { return } + guard let raw = jsonString(section["artifactFolderAccess"]), + let value = MobileArtifactFolderAccess(rawValue: raw) else { + logInvalid("mobile.artifactFolderAccess", sourcePath: sourcePath) + return + } + let key = SettingCatalog().mobile.artifactFolderAccess + snapshot.managedUserDefaults[key.userDefaultsKey] = .string(value.rawValue) + } + private func parseWorkspaceGroupsSection( _ section: [String: Any], sourcePath: String, diff --git a/Sources/Mobile/AgentChat/AgentChatArtifactIndex.swift b/Sources/Mobile/AgentChat/AgentChatArtifactIndex.swift index ce789a171a51..a5d6e73c717e 100644 --- a/Sources/Mobile/AgentChat/AgentChatArtifactIndex.swift +++ b/Sources/Mobile/AgentChat/AgentChatArtifactIndex.swift @@ -5,7 +5,6 @@ import Foundation actor AgentChatArtifactIndex { struct Snapshot: Sendable { let referencedPaths: Set - let scope: ChatArtifactScope let artifacts: [ChatArtifactIndexedReference] let generation: String } @@ -65,7 +64,8 @@ actor AgentChatArtifactIndex { transcriptPath: String, workingDirectory: String?, requestedPath: String, - operation: Operation + operation: Operation, + directoryAccessMode: ChatArtifactScope.DirectoryAccessMode ) async throws -> CanonicalPathResult { let snapshot = try await snapshot( sessionID: sessionID, @@ -78,11 +78,16 @@ actor AgentChatArtifactIndex { return .canonicalizationFailed } let canonicalPath: String? + let scope = ChatArtifactScope( + referencedPaths: snapshot.referencedPaths, + directoryAccessMode: directoryAccessMode, + resolver: resolver + ) switch operation { case .file: - canonicalPath = snapshot.scope.canonicalFilePath(for: requestedPath) + canonicalPath = scope.canonicalFilePath(for: requestedPath) case .list: - canonicalPath = snapshot.scope.canonicalDirectoryListPath(for: requestedPath) + canonicalPath = scope.canonicalDirectoryListPath(for: requestedPath) } return canonicalPath.map(CanonicalPathResult.success) ?? .notInSet } @@ -123,10 +128,6 @@ actor AgentChatArtifactIndex { let referencedPaths = Set(artifacts.map(\.path)) return Snapshot( referencedPaths: referencedPaths, - scope: ChatArtifactScope( - referencedPaths: referencedPaths, - resolver: ChatArtifactScope.FoundationResolver() - ), artifacts: artifacts, generation: generation ) diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index c609753b015d..11daef7dab63 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -132,6 +132,7 @@ enum SettingsSearchAliasIndex { "mobile:iOSPairingHost": localized("settings.search.alias.setting.mobile.iOSPairingHost", defaultValue: "ios iphone ipad mobile pairing local network permission sync"), "mobile:iOSPairingPort": localized("settings.search.alias.setting.mobile.iOSPairingPort", defaultValue: "mobile ios iphone pairing port tcp listener firewall conflict bind"), "mobile:iOSPairingDisplayName": localized("settings.search.alias.setting.mobile.iOSPairingDisplayName", defaultValue: "mobile ios iphone pairing display name mac hostname device label"), + "mobile:artifactFolderAccess": localized("settings.search.alias.setting.mobile.artifactFolderAccess", defaultValue: "mobile ios iphone files folders directory subtree one level authorization security"), "automation:socket-mode": localized("settings.search.alias.setting.automation.socket-mode", defaultValue: "automation.socketControlMode api socket unix domain control server auth allow password disabled"), "automation:socket-password": localized("settings.search.alias.setting.automation.socket-password", defaultValue: "automation.socketPassword auth token credential secret password access key"), "automation:claude-code": localized("settings.search.alias.setting.automation.claude-code", defaultValue: "automation.claudeCodeIntegration claude code hooks agent integration status notifications"), diff --git a/Sources/TerminalController+MobileChatArtifacts.swift b/Sources/TerminalController+MobileChatArtifacts.swift index b394f5e231fe..a1ca554fdf58 100644 --- a/Sources/TerminalController+MobileChatArtifacts.swift +++ b/Sources/TerminalController+MobileChatArtifacts.swift @@ -1,4 +1,5 @@ import CmuxAgentChat +import CmuxSettings import Foundation private enum TerminalControllerChatArtifactIndexProvider { @@ -244,7 +245,8 @@ extension TerminalController { transcriptPath: transcriptPath, workingDirectory: record.workingDirectory, requestedPath: requestedPath, - operation: operation.indexOperation + operation: operation.indexOperation, + directoryAccessMode: mobileArtifactDirectoryAccessMode() ) switch pathResult { case .success(let canonicalPath): @@ -268,6 +270,22 @@ extension TerminalController { } } + /// Resolves the persisted mobile folder setting into the shared scope policy. + func mobileArtifactDirectoryAccessMode( + defaults: UserDefaults = .standard + ) -> ChatArtifactScope.DirectoryAccessMode { + let key = SettingCatalog().mobile.artifactFolderAccess + let setting = MobileArtifactFolderAccess.decodeFromUserDefaults( + defaults.object(forKey: key.userDefaultsKey) + ) ?? key.defaultValue + switch setting { + case .subtree: + return .subtree + case .oneLevel: + return .oneLevel + } + } + private func debugLogMobileChatArtifactDenial(code: String, reason: String, path: String) { #if DEBUG cmuxDebugLog("mobile.chat.artifact.deny code=\(code) reason=\(reason) path=\(path)") diff --git a/Sources/TerminalController+MobileTerminalArtifacts.swift b/Sources/TerminalController+MobileTerminalArtifacts.swift index 8d5850e68d04..0e195f697788 100644 --- a/Sources/TerminalController+MobileTerminalArtifacts.swift +++ b/Sources/TerminalController+MobileTerminalArtifacts.swift @@ -171,6 +171,7 @@ extension TerminalController { data: nil )) } + let directoryAccessMode = mobileArtifactDirectoryAccessMode() return v2MainSync { () -> TerminalArtifactContextResolution in guard let resolved = mobileResolveWorkspaceAndSurface(params: params, requireTerminal: true), let resolvedSurfaceID = resolved.surfaceId, @@ -196,7 +197,8 @@ extension TerminalController { terminalText: terminalText, workingDirectory: workingDirectory, requestedPath: v2RawString(params, "path"), - sessionID: sessionID + sessionID: sessionID, + directoryAccessMode: directoryAccessMode )) } } @@ -282,17 +284,20 @@ private struct TerminalArtifactReadContext: Sendable { private let workingDirectory: String? let requestedPath: String? let sessionID: String? + private let directoryAccessMode: ChatArtifactScope.DirectoryAccessMode init( terminalText: String, workingDirectory: String?, requestedPath: String?, - sessionID: String? + sessionID: String?, + directoryAccessMode: ChatArtifactScope.DirectoryAccessMode ) { self.terminalText = terminalText self.workingDirectory = workingDirectory self.requestedPath = requestedPath self.sessionID = sessionID + self.directoryAccessMode = directoryAccessMode } func scan() -> TerminalArtifactScanResponse { @@ -300,7 +305,8 @@ private struct TerminalArtifactReadContext: Sendable { let scope = TerminalArtifactScope( terminalText: terminalText, workingDirectory: workingDirectory, - resolver: ChatArtifactScope.FoundationResolver() + resolver: ChatArtifactScope.FoundationResolver(), + directoryAccessMode: directoryAccessMode ) let artifacts = scope.artifactPaths(limit: 200).compactMap { path -> TerminalArtifactReference? in guard let stat = try? reader.stat(path: path) else { return nil } @@ -325,7 +331,8 @@ private struct TerminalArtifactReadContext: Sendable { let scope = TerminalArtifactScope( terminalText: terminalText, workingDirectory: workingDirectory, - resolver: ChatArtifactScope.FoundationResolver() + resolver: ChatArtifactScope.FoundationResolver(), + directoryAccessMode: directoryAccessMode ) guard let canonicalPath = scope.canonicalPath(for: requestedPath) else { throw Error.forbidden diff --git a/docs/configuration.md b/docs/configuration.md index 9b48e10dbf10..921256b446a4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,6 +2,23 @@ Global app preferences live in `~/.config/cmux/cmux.json`. +## `mobile.artifactFolderAccess` + +Controls which files and folders cmux on iOS may browse after a chat references a directory or a directory path appears in a terminal. + +```json +{ + "mobile": { + "artifactFolderAccess": "subtree" + } +} +``` + +- `subtree` (default): authorize the referenced directory and its full subtree. +- `oneLevel`: preserve the previous rule, authorizing only immediate children and listing only the referenced directory itself. + +Authorization compares canonical paths after resolving symlinks. A symlink inside an authorized folder cannot grant access to a target outside that folder. + ## `paneBorderColor` and `activePaneBorderColor` Customize split-workspace pane boundaries controlled by cmux. diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 0e9938aabfbc..7fb4fd7f7bcf 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1425,6 +1425,20 @@ } } }, + "mobile": { + "title": "mobile", + "description": "Mac-side settings for cmux on iOS.", + "type": "object", + "additionalProperties": false, + "properties": { + "artifactFolderAccess": { + "type": "string", + "enum": ["subtree", "oneLevel"], + "default": "subtree", + "description": "Controls whether a referenced or terminal-visible directory authorizes its full canonical subtree or only immediate children." + } + } + }, "markdown": { "title": "markdown", "description": "Built-in markdown viewer settings.", From 08f2bbe5bd1f142f181a80ee70ab242a0caaa829 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 13:13:45 -0700 Subject: [PATCH 06/92] feat: add terminal artifact folder listing --- .../Artifacts/ArtifactByteReader.swift | 19 +++++-- .../ChatArtifactDirectoryListing.swift | 28 +++++++++- .../Source/ChatEventSource.swift | 5 ++ .../ArtifactByteReaderTests.swift | 51 ++++++++++++++++++ .../TerminalArtifactScopeTests.swift | 6 +++ .../TerminalWireCodableTests.swift | 20 +++++++ .../Artifacts/ChatArtifactLoader.swift | 19 +++++-- .../ChatArtifactLoaderTests.swift | 54 +++++++++++++++++++ .../MobileChatEventSource.swift | 34 +++++++++++- .../MobileShellComposite+AgentChat.swift | 4 +- .../MobileShellComposite+Capabilities.swift | 8 +++ .../MobileShellComposite.swift | 2 + .../MobileShellWorkspaceCapabilityTests.swift | 17 ++++++ .../WorkspaceDetailView.swift | 8 +++ .../MobileHostService+Capabilities.swift | 2 + ...obileHostService+TicketAuthorization.swift | 3 +- ...alController+MobileTerminalArtifacts.swift | 40 ++++++++++++++ 17 files changed, 306 insertions(+), 14 deletions(-) create mode 100644 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift index e0b2b34b8ac0..ea701a7bcb3c 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift @@ -7,6 +7,9 @@ import UniformTypeIdentifiers /// Authorization is intentionally outside this type. Callers must scope-check the /// requested path before invoking these methods. public struct ArtifactByteReader: Sendable { + /// Maximum immediate children returned by one directory-list request. + public static let maximumDirectoryEntryCount = 500 + /// Filesystem/decoder failures surfaced by artifact RPC handlers. public enum Error: Swift.Error, Sendable { /// The scoped path no longer exists or cannot be statted. @@ -96,7 +99,8 @@ public struct ArtifactByteReader: Sendable { ) } - /// Lists up to 500 immediate children for an already-authorized directory. + /// Lists up to ``maximumDirectoryEntryCount`` immediate children for an + /// already-authorized directory. public func list(path: String) throws -> ChatArtifactDirectoryListing { let stat = try stat(path: path) guard stat.isDirectory else { throw Error.fileNotFound } @@ -106,9 +110,11 @@ public struct ArtifactByteReader: Sendable { includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [] ) - let listed = try entries - .sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } - .prefix(500) + let sortedEntries = entries.sorted { + $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending + } + let listed = try sortedEntries + .prefix(Self.maximumDirectoryEntryCount) .map { entry -> ChatArtifactDirectoryEntry in let values = try entry.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey]) let isDirectory = values.isDirectory ?? false @@ -119,7 +125,10 @@ public struct ArtifactByteReader: Sendable { kind: kind(path: entry.path, isDirectory: isDirectory) ) } - return ChatArtifactDirectoryListing(entries: listed) + return ChatArtifactDirectoryListing( + entries: listed, + isTruncated: sortedEntries.count > Self.maximumDirectoryEntryCount + ) } /// Infers preview category from a path extension and directory flag. diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactDirectoryListing.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactDirectoryListing.swift index bfa595dea863..ba0f572766bc 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactDirectoryListing.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactDirectoryListing.swift @@ -2,11 +2,35 @@ public struct ChatArtifactDirectoryListing: Sendable, Equatable, Codable { /// Directory entries sorted by name. public let entries: [ChatArtifactDirectoryEntry] + /// Whether additional immediate children were omitted by the server cap. + public let isTruncated: Bool /// Creates a directory listing. /// - /// - Parameter entries: Directory entries sorted by name. - public init(entries: [ChatArtifactDirectoryEntry]) { + /// - Parameters: + /// - entries: Directory entries sorted by name. + /// - isTruncated: Whether the server omitted additional entries. + public init(entries: [ChatArtifactDirectoryEntry], isTruncated: Bool = false) { self.entries = entries + self.isTruncated = isTruncated + } + + private enum CodingKeys: String, CodingKey { + case entries + case isTruncated = "is_truncated" + } + + /// Decodes listings from current and legacy hosts. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + entries = try container.decode([ChatArtifactDirectoryEntry].self, forKey: .entries) + isTruncated = try container.decodeIfPresent(Bool.self, forKey: .isTruncated) ?? false + } + + /// Encodes the capped-list metadata for mobile clients. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(entries, forKey: .entries) + try container.encode(isTruncated, forKey: .isTruncated) } } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/ChatEventSource.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/ChatEventSource.swift index 3d993df9e5b7..a943ff834a2b 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/ChatEventSource.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/ChatEventSource.swift @@ -9,6 +9,8 @@ import Foundation public protocol ChatEventSource: Sendable { /// Whether this source supports Mac-hosted artifact preview RPCs. var supportsArtifacts: Bool { get } + /// Whether this source supports recursive artifact folder browsing. + var supportsArtifactFolders: Bool { get } /// Fetches a page of transcript history for a session. /// @@ -108,6 +110,9 @@ public extension ChatEventSource { /// Unsupported-by-default artifact capability for fixtures and previews. var supportsArtifacts: Bool { false } + /// Unsupported-by-default recursive artifact folder capability. + var supportsArtifactFolders: Bool { false } + /// Unsupported-by-default artifact stat implementation. func artifactStat(sessionID: String, path: String) async throws -> ChatArtifactStat { throw ChatArtifactError.unsupported diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift new file mode 100644 index 000000000000..60de78342a8d --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@Suite("ArtifactByteReader directory listing") +struct ArtifactByteReaderTests { + @Test("directory listings cap at 500 and report truncation") + func listCap() throws { + try withTemporaryDirectory { directory in + for index in 0...ArtifactByteReader.maximumDirectoryEntryCount { + let path = directory.appendingPathComponent(String(format: "item-%03d.txt", index)) + #expect(FileManager.default.createFile(atPath: path.path, contents: Data())) + } + + let listing = try ArtifactByteReader().list(path: directory.path) + + #expect(listing.entries.count == ArtifactByteReader.maximumDirectoryEntryCount) + #expect(listing.isTruncated) + #expect(listing.entries.first?.name == "item-000.txt") + #expect(listing.entries.last?.name == "item-499.txt") + } + } + + @Test("listing a file keeps the existing file-not-found semantic") + func listingFile() throws { + try withTemporaryDirectory { directory in + let file = directory.appendingPathComponent("artifact.txt") + #expect(FileManager.default.createFile(atPath: file.path, contents: Data("hello".utf8))) + + do { + _ = try ArtifactByteReader().list(path: file.path) + Issue.record("listing a file should fail") + } catch ArtifactByteReader.Error.fileNotFound { + // Expected wire semantic. + } catch { + Issue.record("unexpected error: \(error)") + } + } + } + + private func withTemporaryDirectory( + _ operation: (URL) throws -> Void + ) throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-artifact-list-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + try operation(directory) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift index 62d5f2ec64aa..d74055974224 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift @@ -97,6 +97,12 @@ struct TerminalArtifactScopeTests { #expect(scope.canonicalDirectoryListPath(for: "/outside/existing") == nil) #expect(scope.canonicalDirectoryListPath(for: "/outside/missing") == nil) } + + @Test("an exact visible file reaches the reader for list file-not-found semantics") + func exactVisibleFileMayReachListReader() { + let scope = scope(text: "cat /safe/project/src/main.swift") + #expect(scope.canonicalDirectoryListPath(for: "/safe/project/src/main.swift") == "/safe/project/src/main.swift") + } @Test("visible scan deduplicates canonical identities in first-seen order") func visibleScanCanonicalIdentityDeduplication() { let canonicalizer = ChatArtifactPathCanonicalizer { path in diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalWireCodableTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalWireCodableTests.swift index f405a0cab506..2cd70a64d72c 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalWireCodableTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalWireCodableTests.swift @@ -137,4 +137,24 @@ struct TerminalWireCodableTests { let newerResponse = try ChatWireCoding().decode(TerminalArtifactScanResponse.self, from: newer) #expect(newerResponse.sessionArtifactTotal == 9) } + + @Test("terminal directory listing round-trips cap metadata and decodes legacy payloads") + func terminalDirectoryListingRoundTrip() throws { + let listing = ChatArtifactDirectoryListing( + entries: [ + ChatArtifactDirectoryEntry(name: "Sources", isDirectory: true, size: 0, kind: .directory), + ChatArtifactDirectoryEntry(name: "README.md", isDirectory: false, size: 42, kind: .text), + ], + isTruncated: true + ) + let coding = ChatWireCoding() + let data = try coding.encode(listing) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(object["is_truncated"] as? Bool == true) + #expect(try coding.decode(ChatArtifactDirectoryListing.self, from: data) == listing) + + let legacy = Data(#"{"entries":[]}"#.utf8) + #expect(try coding.decode(ChatArtifactDirectoryListing.self, from: legacy).isTruncated == false) + } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift index 8a4e6347ffad..b953d14ec9e3 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift @@ -77,6 +77,8 @@ public enum ChatArtifactLoaderScope: Hashable, Sendable { /// Value-type closure bundle for Mac-hosted artifact operations. public struct ChatArtifactLoader: Sendable { public let supportsArtifacts: Bool + /// Whether directory stat results may route into a navigable folder browser. + public let supportsDirectoryBrowsing: Bool public let scope: ChatArtifactLoaderScope private let statHandler: @Sendable (_ path: String) async throws -> ChatArtifactStat @@ -90,6 +92,7 @@ public struct ChatArtifactLoader: Sendable { public init( supportsArtifacts: Bool = false, + supportsDirectoryBrowsing: Bool = false, scope: ChatArtifactLoaderScope = .unsupported, cache: ChatArtifactThumbnailCache = ChatArtifactThumbnailCache(), stat: @escaping @Sendable (_ path: String) async throws -> ChatArtifactStat = { _ in @@ -109,6 +112,7 @@ public struct ChatArtifactLoader: Sendable { } ) { self.supportsArtifacts = supportsArtifacts + self.supportsDirectoryBrowsing = supportsDirectoryBrowsing self.scope = scope self.thumbnailCache = cache statHandler = stat @@ -124,6 +128,7 @@ public struct ChatArtifactLoader: Sendable { ) { self.init( supportsArtifacts: source.supportsArtifacts, + supportsDirectoryBrowsing: source.supportsArtifactFolders, scope: .chat(sessionID: sessionID), cache: cache, stat: { path in @@ -149,22 +154,27 @@ public struct ChatArtifactLoader: Sendable { terminalWorkspaceID: String, terminalSurfaceID: String, supportsArtifacts: Bool, + supportsDirectoryBrowsing: Bool = false, cache: ChatArtifactThumbnailCache = ChatArtifactThumbnailCache(), stat: @escaping @Sendable (_ path: String) async throws -> ChatArtifactStat, fetch: @escaping @Sendable ( _ path: String, _ progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)? ) async throws -> Data, - thumbnail: @escaping @Sendable (_ path: String, _ maxDimension: Int) async throws -> ChatArtifactThumbnail + thumbnail: @escaping @Sendable (_ path: String, _ maxDimension: Int) async throws -> ChatArtifactThumbnail, + list: @escaping @Sendable (_ path: String) async throws -> ChatArtifactDirectoryListing = { _ in + throw ChatArtifactError.unsupported + } ) { self.init( supportsArtifacts: supportsArtifacts, + supportsDirectoryBrowsing: supportsDirectoryBrowsing, scope: .terminal(workspaceID: terminalWorkspaceID, surfaceID: terminalSurfaceID), cache: cache, stat: stat, fetch: fetch, thumbnail: thumbnail, - list: { _ in throw ChatArtifactError.unsupported } + list: list ) } @@ -209,7 +219,10 @@ public struct ChatArtifactLoader: Sendable { } public func list(path: String) async throws -> ChatArtifactDirectoryListing { - try await listHandler(path) + guard supportsDirectoryBrowsing else { + throw ChatArtifactError.unsupported + } + return try await listHandler(path) } private func thumbnailCacheKey( diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift index 2f078480e938..3ae23d515f23 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift @@ -44,6 +44,9 @@ struct ChatArtifactLoaderTests { }, thumbnail: { path, maxDimension in try await terminalSource.thumbnail(path: path, maxDimension: maxDimension) + }, + list: { path in + try await terminalSource.list(path: path) } ) @@ -55,6 +58,45 @@ struct ChatArtifactLoaderTests { #expect(await terminalSource.thumbnailRequestCount() == 1) #expect(try await terminalLoader.stat(path: "/tmp/image.png").kind == .image) } + + @Test func terminalListCapabilityRoutesAndFailsClosedWithoutCallingHandler() async throws { + let source = CountingTerminalArtifactSource() + let supported = ChatArtifactLoader( + terminalWorkspaceID: "workspace-1", + terminalSurfaceID: "surface-1", + supportsArtifacts: true, + supportsDirectoryBrowsing: true, + stat: { path in try await source.stat(path: path) }, + fetch: { path, progress in try await source.fetch(path: path, progress: progress) }, + thumbnail: { path, dimension in + try await source.thumbnail(path: path, maxDimension: dimension) + }, + list: { path in try await source.list(path: path) } + ) + let unsupported = ChatArtifactLoader( + terminalWorkspaceID: "workspace-1", + terminalSurfaceID: "surface-1", + supportsArtifacts: true, + stat: { path in try await source.stat(path: path) }, + fetch: { path, progress in try await source.fetch(path: path, progress: progress) }, + thumbnail: { path, dimension in + try await source.thumbnail(path: path, maxDimension: dimension) + }, + list: { path in try await source.list(path: path) } + ) + + #expect(try await supported.list(path: "/tmp/folder").entries.first?.name == "child.txt") + #expect(await source.listRequestCount() == 1) + do { + _ = try await unsupported.list(path: "/tmp/folder") + Issue.record("unsupported loader should not issue a list request") + } catch let error as ChatArtifactError { + #expect(error == .unsupported) + } catch { + Issue.record("unexpected error: \(error)") + } + #expect(await source.listRequestCount() == 1) + } } private actor CountingArtifactSource: ChatEventSource { @@ -115,11 +157,16 @@ private actor CountingArtifactSource: ChatEventSource { private actor CountingTerminalArtifactSource { private var thumbnails = 0 + private var lists = 0 func thumbnailRequestCount() -> Int { thumbnails } + func listRequestCount() -> Int { + lists + } + func stat(path: String) async throws -> ChatArtifactStat { ChatArtifactStat( exists: true, @@ -142,4 +189,11 @@ private actor CountingTerminalArtifactSource { thumbnails += 1 return ChatArtifactThumbnail(data: Data([7, 8, 9]), pixelWidth: maxDimension, pixelHeight: maxDimension) } + + func list(path: String) async throws -> ChatArtifactDirectoryListing { + lists += 1 + return ChatArtifactDirectoryListing(entries: [ + ChatArtifactDirectoryEntry(name: "child.txt", isDirectory: false, size: 1, kind: .text), + ]) + } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift index a9dc11990456..d163060293ad 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift @@ -14,6 +14,10 @@ public actor MobileChatEventSource: ChatEventSource { private let client: MobileCoreRPCClient private let coding = ChatWireCoding() public nonisolated let supportsArtifacts: Bool + /// Whether the connected Mac supports recursive chat folder browsing. + public nonisolated let supportsArtifactFolders: Bool + /// Whether the connected Mac supports terminal-scoped directory listing. + public nonisolated let supportsTerminalArtifactList: Bool /// Whether the connected Mac supports session-wide artifact gallery pages. public nonisolated let supportsArtifactGallery: Bool @@ -23,11 +27,15 @@ public actor MobileChatEventSource: ChatEventSource { public init( client: MobileCoreRPCClient, supportsArtifacts: Bool = false, - supportsArtifactGallery: Bool = false + supportsArtifactGallery: Bool = false, + supportsArtifactFolders: Bool = false, + supportsTerminalArtifactList: Bool = false ) { self.client = client self.supportsArtifacts = supportsArtifacts self.supportsArtifactGallery = supportsArtifactGallery + self.supportsArtifactFolders = supportsArtifactFolders + self.supportsTerminalArtifactList = supportsTerminalArtifactList } /// Lists chat-capable agent sessions the Mac knows about. @@ -297,7 +305,10 @@ public actor MobileChatEventSource: ChatEventSource { } public func artifactList(sessionID: String, path: String) async throws -> ChatArtifactDirectoryListing { - try await artifactCall( + guard supportsArtifactFolders else { + throw ChatArtifactError.unsupported + } + return try await artifactCall( method: "mobile.chat.artifact.list", params: [ "session_id": sessionID, @@ -434,6 +445,25 @@ public actor MobileChatEventSource: ChatEventSource { ) } + /// Lists immediate entries in a terminal-visible artifact directory. + public func terminalArtifactList( + workspaceID: String, + surfaceID: String, + path: String + ) async throws -> ChatArtifactDirectoryListing { + guard supportsTerminalArtifactList else { + throw ChatArtifactError.unsupported + } + return try await artifactCall( + method: "mobile.terminal.artifact.list", + params: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "path": path, + ] + ) + } + private func artifactCall( method: String, params: [String: Any] diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift index cc88d4bd2944..b2f349e5d06c 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift @@ -29,7 +29,9 @@ extension MobileShellComposite { return MobileChatEventSource( client: client, supportsArtifacts: supportsChatArtifacts, - supportsArtifactGallery: supportsChatArtifactGallery + supportsArtifactGallery: supportsChatArtifactGallery, + supportsArtifactFolders: supportsChatArtifactFolders, + supportsTerminalArtifactList: supportsTerminalArtifactList ) } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift index 085d49319d25..fed616fab326 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift @@ -17,6 +17,14 @@ extension MobileShellComposite { public var supportsChatArtifactGallery: Bool { supportedHostCapabilities.contains(Self.chatArtifactGalleryCapability) } + /// Whether the Mac supports recursive chat artifact folder browsing. + public var supportsChatArtifactFolders: Bool { + supportedHostCapabilities.contains(Self.chatArtifactFoldersCapability) + } /// Whether the Mac supports terminal artifact scan/stat/fetch/thumbnail RPCs. public var supportsTerminalArtifacts: Bool { supportedHostCapabilities.contains(Self.terminalArtifactCapability) } + /// Whether the Mac supports terminal-scoped directory listing. + public var supportsTerminalArtifactList: Bool { + supportedHostCapabilities.contains(Self.terminalArtifactListCapability) + } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 2dab0da394f2..fe04b17f94d1 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -96,7 +96,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { static let workspaceCreateInGroupCapability = "workspace.create_in_group.v1", workspaceGroupCreateCapability = "workspace.group_create.v1" static let chatArtifactCapability = "chat.artifact.v1" static let chatArtifactGalleryCapability = "chat.artifact.gallery.v1" + static let chatArtifactFoldersCapability = "chat.artifact.folders.v1" static let terminalArtifactCapability = "terminal.artifact.v1" + static let terminalArtifactListCapability = "terminal.artifact.list.v1" static let dogfoodFeedbackCapability = "dogfood.v1" static let workspaceGroupsCapability = "workspace.groups.v1" private static let terminalOutputCapabilityTimeoutNanoseconds: UInt64 = 750_000_000 diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift index a3ca5d8c3c45..125327bc523f 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift @@ -6,6 +6,23 @@ import Testing @MainActor @Suite struct MobileShellWorkspaceCapabilityTests { + @Test func artifactFolderCapabilitiesFailClosedForOlderHosts() { + let store = MobileShellComposite.preview() + store.supportedHostCapabilities = [ + "chat.artifact.v1", + "terminal.artifact.v1", + ] + #expect(!store.supportsChatArtifactFolders) + #expect(!store.supportsTerminalArtifactList) + + store.supportedHostCapabilities.formUnion([ + "chat.artifact.folders.v1", + "terminal.artifact.list.v1", + ]) + #expect(store.supportsChatArtifactFolders) + #expect(store.supportsTerminalArtifactList) + } + @Test func workspaceMutationCapabilitiesAreVersionAndTicketGated() async throws { let oldMac = try await connectedStore(capabilities: [ "events.v1", diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 147b90cee246..5785ba031eca 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -293,6 +293,7 @@ struct WorkspaceDetailView: View { terminalWorkspaceID: workspaceID, terminalSurfaceID: surfaceID, supportsArtifacts: store.supportsTerminalArtifacts, + supportsDirectoryBrowsing: store.supportsTerminalArtifactList, cache: terminalArtifactThumbnailCache, stat: { path in try await source.terminalArtifactStat( @@ -316,6 +317,13 @@ struct WorkspaceDetailView: View { path: path, maxDimension: maxDimension ) + }, + list: { path in + try await source.terminalArtifactList( + workspaceID: workspaceID, + surfaceID: surfaceID, + path: path + ) } ) } diff --git a/Sources/Mobile/MobileHostService+Capabilities.swift b/Sources/Mobile/MobileHostService+Capabilities.swift index 6303d0d6a008..477ea010f6db 100644 --- a/Sources/Mobile/MobileHostService+Capabilities.swift +++ b/Sources/Mobile/MobileHostService+Capabilities.swift @@ -25,6 +25,7 @@ extension MobileHostService { "terminal.replay.v1", "terminal.viewport.v1", "terminal.artifact.v1", + "terminal.artifact.list.v1", "workspace.actions.v1", "workspace.read_state.v1", "workspace.close.v1", @@ -33,6 +34,7 @@ extension MobileHostService { "workspace.group_create.v1", "workspace.create_in_group.v1", "chat.artifact.v1", + "chat.artifact.folders.v1", "chat.artifact.gallery.v1", "dogfood.v1", // The workspace list carries group sections (group_id per workspace + diff --git a/Sources/Mobile/MobileHostService+TicketAuthorization.swift b/Sources/Mobile/MobileHostService+TicketAuthorization.swift index 3057f37b1b68..f9ca09fbfb8e 100644 --- a/Sources/Mobile/MobileHostService+TicketAuthorization.swift +++ b/Sources/Mobile/MobileHostService+TicketAuthorization.swift @@ -71,7 +71,8 @@ extension MobileHostService { "mobile.terminal.artifact.scan", "mobile.terminal.artifact.stat", "mobile.terminal.artifact.fetch", - "mobile.terminal.artifact.thumbnail": + "mobile.terminal.artifact.thumbnail", + "mobile.terminal.artifact.list": return ticketTerminalAuthorizationError( authorization: authorization, workspaceSelection: workspaceSelection.value, diff --git a/Sources/TerminalController+MobileTerminalArtifacts.swift b/Sources/TerminalController+MobileTerminalArtifacts.swift index 0e195f697788..e3d860a54c01 100644 --- a/Sources/TerminalController+MobileTerminalArtifacts.swift +++ b/Sources/TerminalController+MobileTerminalArtifacts.swift @@ -33,6 +33,8 @@ extension TerminalController { return await v2MobileTerminalArtifactFetch(params: params) case "mobile.terminal.artifact.thumbnail": return await v2MobileTerminalArtifactThumbnail(params: params) + case "mobile.terminal.artifact.list": + return await v2MobileTerminalArtifactList(params: params) default: return .err(code: "method_not_found", message: "Unknown mobile terminal artifact method", data: nil) } @@ -151,6 +153,28 @@ extension TerminalController { } } + func v2MobileTerminalArtifactList(params: [String: Any]) async -> V2CallResult { + let resolution = mobileTerminalArtifactContext(params: params, requiresPath: true) + guard case .success(let context) = resolution else { + return resolution.failureResult + } + do { + let listing = try await Task.detached(priority: .utility) { + try context.authorizedDirectoryList { reader, canonicalPath in + try reader.list(path: canonicalPath) + } + }.value + return TerminalArtifactWire.result(listing) + } catch TerminalArtifactReadContext.Error.forbidden { + debugLogMobileTerminalArtifactDenial(op: "list", path: context.requestedPath) + return mobileTerminalArtifactError(.forbidden, path: context.requestedPath) + } catch ArtifactByteReader.Error.fileNotFound { + return mobileTerminalArtifactError(.fileNotFound, path: context.requestedPath) + } catch { + return mobileTerminalArtifactError(.fileNotFound, path: context.requestedPath) + } + } + private func mobileTerminalArtifactContext( params: [String: Any], requiresPath: Bool, @@ -339,6 +363,22 @@ private struct TerminalArtifactReadContext: Sendable { } return try operation(ArtifactByteReader(), canonicalPath) } + + func authorizedDirectoryList( + _ operation: (ArtifactByteReader, String) throws -> T + ) throws -> T { + guard let requestedPath else { throw Error.forbidden } + let scope = TerminalArtifactScope( + terminalText: terminalText, + workingDirectory: workingDirectory, + resolver: ChatArtifactScope.FoundationResolver(), + directoryAccessMode: directoryAccessMode + ) + guard let canonicalPath = scope.canonicalDirectoryListPath(for: requestedPath) else { + throw Error.forbidden + } + return try operation(ArtifactByteReader(), canonicalPath) + } } private struct TerminalArtifactWire { From 6a55ff2669484982a41901fd925bdcf4a8729670 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 13:18:02 -0700 Subject: [PATCH 07/92] feat: route artifacts through recursive folder browser --- .../Artifacts/ChatArtifactFolderView.swift | 125 ++++---- .../ChatArtifactViewerRouteView.swift | 301 ++++++++++++++++++ .../Artifacts/ChatArtifactViewerSheet.swift | 277 +--------------- .../Resources/Localizable.xcstrings | 42 ++- .../Transcript/ChatBlockDetailSheetView.swift | 39 +-- .../ChatArtifactLoaderTests.swift | 14 + 6 files changed, 435 insertions(+), 363 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift index 696ef86e628a..d0de3a2eaf67 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift @@ -9,33 +9,17 @@ import AppKit struct ChatArtifactFolderView: View { let path: String + let scope: ChatArtifactViewerScope + let onDone: () -> Void @Environment(\.chatArtifactLoader) private var loader - @Environment(\.dismiss) private var dismiss @State private var state: LoadState = .loading - @State private var selectedFile: ChatArtifactPathSelection? var body: some View { - NavigationStack { - content - .navigationTitle(displayName) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(String(localized: "chat.artifact.done", defaultValue: "Done", bundle: .module)) { - dismiss() - } - } - } - } - .task(id: path) { - await load() - } - .sheet(item: $selectedFile) { selection in - ChatArtifactViewerSheet(path: selection.path) - } + content + .task(id: path) { + await load() + } } @ViewBuilder @@ -44,15 +28,39 @@ struct ChatArtifactFolderView: View { case .loading: ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) - case .entries(let entries): - if entries.isEmpty { - Text(String(localized: "chat.artifact.folder.empty", defaultValue: "No files", bundle: .module)) - .font(.subheadline) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List(entries) { entry in - row(entry) + case .listing(let listing): + VStack(spacing: 0) { + breadcrumb + Divider() + if listing.entries.isEmpty { + Text(String(localized: "chat.artifact.folder.empty", defaultValue: "No items", bundle: .module)) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + Section { + ForEach(listing.entries) { entry in + NavigationLink { + ChatArtifactViewerRouteView( + path: childPath(named: entry.name), + scope: scope, + onDone: onDone + ) + } label: { + rowLabel(entry) + } + } + } footer: { + if listing.isTruncated { + Text(String( + localized: "chat.artifact.folder.showing_first_500", + defaultValue: "Showing first 500 items", + bundle: .module + )) + } + } + } } } case .failed: @@ -74,32 +82,33 @@ struct ChatArtifactFolderView: View { } } - private func row(_ entry: ChatArtifactDirectoryEntry) -> some View { - Button { - guard !entry.isDirectory else { return } - selectedFile = ChatArtifactPathSelection(path: childPath(named: entry.name)) - } label: { - HStack(spacing: 10) { - ChatArtifactFolderThumbnail(path: childPath(named: entry.name), entry: entry) - VStack(alignment: .leading, spacing: 2) { - Text(entry.name) - .lineLimit(1) - .truncationMode(.middle) - if !entry.isDirectory { - Text(ByteCountFormatter.string(fromByteCount: entry.size, countStyle: .file)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - Spacer(minLength: 8) - if entry.isDirectory { - Image(systemName: "folder") + private var breadcrumb: some View { + Text(parentPath) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .accessibilityLabel(Text(verbatim: path)) + } + + private func rowLabel(_ entry: ChatArtifactDirectoryEntry) -> some View { + HStack(spacing: 10) { + ChatArtifactFolderThumbnail(path: childPath(named: entry.name), entry: entry) + VStack(alignment: .leading, spacing: 2) { + Text(entry.name) + .lineLimit(1) + .truncationMode(.middle) + if !entry.isDirectory { + Text(ByteCountFormatter.string(fromByteCount: entry.size, countStyle: .file)) + .font(.caption) .foregroundStyle(.secondary) } } + Spacer(minLength: 8) } - .buttonStyle(.plain) - .disabled(entry.isDirectory) } private func load() async { @@ -107,7 +116,7 @@ struct ChatArtifactFolderView: View { do { let listing = try await loader.list(path: path) guard !Task.isCancelled else { return } - await MainActor.run { state = .entries(listing.entries) } + await MainActor.run { state = .listing(listing) } } catch { await MainActor.run { state = .failed } } @@ -117,13 +126,15 @@ struct ChatArtifactFolderView: View { (path as NSString).appendingPathComponent(name) } - private var displayName: String { - URL(fileURLWithPath: path).lastPathComponent + private var parentPath: String { + guard path != "/" else { return "/" } + let parent = (path as NSString).deletingLastPathComponent + return parent.isEmpty ? "/" : parent } private enum LoadState: Equatable { case loading - case entries([ChatArtifactDirectoryEntry]) + case listing(ChatArtifactDirectoryListing) case failed } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift new file mode 100644 index 000000000000..2d29aeb4414f --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -0,0 +1,301 @@ +import CmuxAgentChat +import SwiftUI + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// The shared stat-driven route for every artifact entry point and folder level. +struct ChatArtifactViewerRouteView: View { + let path: String + let scope: ChatArtifactViewerScope + let onDone: () -> Void + + @Environment(\.chatArtifactLoader) private var loader + @State private var state: LoadState = .loading(fetched: 0, total: nil) + + var body: some View { + content + .navigationTitle(displayName) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "chat.artifact.done", defaultValue: "Done", bundle: .module)) { + onDone() + } + } + } + .task(id: path) { + await load() + } + } + + @ViewBuilder + private var content: some View { + switch state { + case .loading(let fetched, let total): + VStack(spacing: 12) { + ProgressView(value: progressValue(fetched: fetched, total: total)) + .progressViewStyle(.linear) + .frame(maxWidth: 220) + Text(String(localized: "chat.artifact.loading", defaultValue: "Loading preview", bundle: .module)) + .font(.subheadline) + .foregroundStyle(.secondary) + if fetched > 0 || total != nil { + Text(verbatim: progressText(fetched: fetched, total: total)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + case .folder: + ChatArtifactFolderView(path: path, scope: scope, onDone: onDone) + case .image(let data): + artifactImage(data: data) + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + case .text(let text): + #if canImport(UIKit) + ChatArtifactTextView(text: text) + #else + ScrollView { + Text(text) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + #endif + case .binary(let stat): + unavailableView( + title: String(localized: "chat.artifact.preview_unavailable.title", defaultValue: "Preview unavailable", bundle: .module), + message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module), + detail: formattedSize(stat.size) + ) + case .tooLarge(let limit): + unavailableView( + title: String(localized: "chat.artifact.too_large.title", defaultValue: "File too large to preview", bundle: .module), + message: tooLargeMessage(limit: limit) + ) + case .unsupportedMedia: + unavailableView( + title: String(localized: "chat.artifact.preview_unavailable.title", defaultValue: "Preview unavailable", bundle: .module), + message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module), + detail: nil + ) + case .fileMissing: + unavailableView( + title: String(localized: "chat.artifact.file_missing.title", defaultValue: "File not found", bundle: .module), + message: String(localized: "chat.artifact.file_missing.message", defaultValue: "The file is no longer available on your Mac.", bundle: .module), + retry: false + ) + case .macUnreachable: + unavailableView( + title: String(localized: "chat.artifact.mac_unreachable.title", defaultValue: "Mac unreachable", bundle: .module), + message: String(localized: "chat.artifact.mac_unreachable.message", defaultValue: "Check the connection to your Mac and try again.", bundle: .module), + retry: true + ) + case .forbidden: + unavailableView( + title: String(localized: "chat.artifact.forbidden.title", defaultValue: "Preview unavailable", bundle: .module), + message: forbiddenMessage, + retry: false + ) + } + } + + private func load() async { + await MainActor.run { + state = .loading(fetched: 0, total: nil) + } + do { + let stat = try await loader.stat(path: path) + guard !stat.isDirectory else { + await MainActor.run { + state = ChatArtifactViewerRouting.showsFolder( + stat: stat, + supportsDirectoryBrowsing: loader.supportsDirectoryBrowsing + ) ? .folder : .binary(stat: stat) + } + return + } + guard stat.size <= ChatArtifactTransferPolicy.defaultPolicy.maxPreviewBytes else { + await MainActor.run { + state = .tooLarge(limit: ChatArtifactTransferPolicy.defaultPolicy.maxPreviewBytes) + } + return + } + let data = try await loader.fetch(path: path) { fetched, total in + Task { @MainActor in + state = .loading(fetched: fetched, total: total) + } + } + guard !Task.isCancelled else { return } + switch stat.kind { + case .image: + await MainActor.run { state = .image(data: data) } + case .text: + if let text = String(data: data, encoding: .utf8) { + await MainActor.run { state = .text(text: text) } + } else { + await MainActor.run { state = .binary(stat: stat) } + } + case .binary, .directory: + await MainActor.run { state = .binary(stat: stat) } + } + } catch { + await MainActor.run { + state = LoadState(error: error) + } + } + } + + private func unavailableView( + title: String, + message: String, + detail: String? = nil, + retry: Bool = false + ) -> some View { + VStack(spacing: 10) { + Text(title) + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + if let detail { + Text(detail) + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + } + if retry { + Button { + Task { await load() } + } label: { + Label( + String(localized: "chat.artifact.retry", defaultValue: "Retry", bundle: .module), + systemImage: "arrow.clockwise" + ) + } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + @ViewBuilder + private func artifactImage(data: Data) -> some View { + #if canImport(UIKit) + if let image = UIImage(data: data) { + Image(uiImage: image) + .resizable() + } else { + Color.clear + } + #elseif canImport(AppKit) + if let image = NSImage(data: data) { + Image(nsImage: image) + .resizable() + } else { + Color.clear + } + #else + Color.clear + #endif + } + + private var displayName: String { + URL(fileURLWithPath: path).lastPathComponent + } + + private var forbiddenMessage: String { + switch scope { + case .chat: + String( + localized: "chat.artifact.forbidden.message", + defaultValue: "This file was not referenced by the conversation.", + bundle: .module + ) + case .terminal: + String( + localized: "chat.artifact.forbidden.terminal_message", + defaultValue: "This file isn't visible in the current terminal view.", + bundle: .module + ) + } + } + + private func progressValue(fetched: Int64, total: Int64?) -> Double? { + guard let total, total > 0 else { return nil } + return Double(fetched) / Double(total) + } + + private func progressText(fetched: Int64, total: Int64?) -> String { + if let total { + return "\(formattedSize(fetched)) / \(formattedSize(total))" + } + return formattedSize(fetched) + } + + private func formattedSize(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } + + private func tooLargeMessage(limit: Int64) -> String { + let format = String( + localized: "chat.artifact.too_large.message", + defaultValue: "This preview is limited to %@.", + bundle: .module + ) + return String.localizedStringWithFormat(format, formattedSize(limit)) + } + + private enum LoadState: Equatable { + case loading(fetched: Int64, total: Int64?) + case folder + case image(data: Data) + case text(text: String) + case binary(stat: ChatArtifactStat) + case tooLarge(limit: Int64) + case unsupportedMedia + case fileMissing + case macUnreachable + case forbidden + + init(error: any Error) { + guard let artifactError = error as? ChatArtifactError else { + self = .macUnreachable + return + } + switch artifactError { + case .fileNotFound: + self = .fileMissing + case .forbidden: + self = .forbidden + case .macUnreachable, .unavailable, .unsupported, .sessionNotFound, .invalidParams: + self = .macUnreachable + case .unsupportedMedia: + self = .unsupportedMedia + case .tooLarge(let limitBytes): + self = .tooLarge(limit: limitBytes) + } + } + } +} + +enum ChatArtifactViewerRouting { + static func showsFolder( + stat: ChatArtifactStat, + supportsDirectoryBrowsing: Bool + ) -> Bool { + stat.isDirectory && supportsDirectoryBrowsing + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerSheet.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerSheet.swift index b3d7704880ea..aff399f12818 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerSheet.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerSheet.swift @@ -1,20 +1,13 @@ -import CmuxAgentChat import SwiftUI -#if canImport(UIKit) -import UIKit -#elseif canImport(AppKit) -import AppKit -#endif - +/// A navigation container for one Mac-hosted artifact path. public struct ChatArtifactViewerSheet: View { let path: String let scope: ChatArtifactViewerScope - - @Environment(\.chatArtifactLoader) private var loader @Environment(\.dismiss) private var dismiss - @State private var state: LoadState = .loading(fetched: 0, total: nil) + /// Creates an artifact viewer that routes files and folders through the + /// same stat-driven navigation path. public init(path: String, scope: ChatArtifactViewerScope = .chat) { self.path = path self.scope = scope @@ -22,268 +15,8 @@ public struct ChatArtifactViewerSheet: View { public var body: some View { NavigationStack { - content - .navigationTitle(displayName) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(String(localized: "chat.artifact.done", defaultValue: "Done", bundle: .module)) { - dismiss() - } - } - } - } - .task(id: path) { - await load() - } - } - - @ViewBuilder - private var content: some View { - switch state { - case .loading(let fetched, let total): - VStack(spacing: 12) { - ProgressView(value: progressValue(fetched: fetched, total: total)) - .progressViewStyle(.linear) - .frame(maxWidth: 220) - Text(String(localized: "chat.artifact.loading", defaultValue: "Loading preview", bundle: .module)) - .font(.subheadline) - .foregroundStyle(.secondary) - if fetched > 0 || total != nil { - Text(verbatim: progressText(fetched: fetched, total: total)) - .font(.caption.monospacedDigit()) - .foregroundStyle(.tertiary) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - case .image(let data): - artifactImage(data: data) - .scaledToFit() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - case .text(let text): - #if canImport(UIKit) - ChatArtifactTextView(text: text) - #else - ScrollView { - Text(text) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - } - #endif - case .binary(let stat): - unavailableView( - title: String(localized: "chat.artifact.preview_unavailable.title", defaultValue: "Preview unavailable", bundle: .module), - message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module), - detail: formattedSize(stat.size) - ) - case .tooLarge(let limit): - unavailableView( - title: String(localized: "chat.artifact.too_large.title", defaultValue: "File too large to preview", bundle: .module), - message: tooLargeMessage(limit: limit) - ) - case .unsupportedMedia: - unavailableView( - title: String(localized: "chat.artifact.preview_unavailable.title", defaultValue: "Preview unavailable", bundle: .module), - message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module), - detail: nil - ) - case .fileMissing: - unavailableView( - title: String(localized: "chat.artifact.file_missing.title", defaultValue: "File not found", bundle: .module), - message: String(localized: "chat.artifact.file_missing.message", defaultValue: "The file is no longer available on your Mac.", bundle: .module), - retry: false - ) - case .macUnreachable: - unavailableView( - title: String(localized: "chat.artifact.mac_unreachable.title", defaultValue: "Mac unreachable", bundle: .module), - message: String(localized: "chat.artifact.mac_unreachable.message", defaultValue: "Check the connection to your Mac and try again.", bundle: .module), - retry: true - ) - case .forbidden: - unavailableView( - title: String(localized: "chat.artifact.forbidden.title", defaultValue: "Preview unavailable", bundle: .module), - message: forbiddenMessage, - retry: false - ) - } - } - - private func load() async { - await MainActor.run { - state = .loading(fetched: 0, total: nil) - } - do { - let stat = try await loader.stat(path: path) - guard !stat.isDirectory else { - await MainActor.run { state = .binary(stat: stat) } - return - } - guard stat.size <= ChatArtifactTransferPolicy.defaultPolicy.maxPreviewBytes else { - await MainActor.run { - state = .tooLarge(limit: ChatArtifactTransferPolicy.defaultPolicy.maxPreviewBytes) - } - return - } - let data = try await loader.fetch(path: path) { fetched, total in - Task { @MainActor in - state = .loading(fetched: fetched, total: total) - } - } - guard !Task.isCancelled else { return } - switch stat.kind { - case .image: - await MainActor.run { state = .image(data: data) } - case .text: - if let text = String(data: data, encoding: .utf8) { - await MainActor.run { state = .text(text: text) } - } else { - await MainActor.run { state = .binary(stat: stat) } - } - case .binary, .directory: - await MainActor.run { state = .binary(stat: stat) } - } - } catch { - await MainActor.run { - state = LoadState(error: error) - } - } - } - - private func unavailableView( - title: String, - message: String, - detail: String? = nil, - retry: Bool = false - ) -> some View { - VStack(spacing: 10) { - Text(title) - .font(.headline) - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - if let detail { - Text(detail) - .font(.caption.monospacedDigit()) - .foregroundStyle(.tertiary) - } - if retry { - Button { - Task { await load() } - } label: { - Label( - String(localized: "chat.artifact.retry", defaultValue: "Retry", bundle: .module), - systemImage: "arrow.clockwise" - ) - } - .buttonStyle(.borderedProminent) - .padding(.top, 4) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - } - - @ViewBuilder - private func artifactImage(data: Data) -> some View { - #if canImport(UIKit) - if let image = UIImage(data: data) { - Image(uiImage: image) - .resizable() - } else { - Color.clear - } - #elseif canImport(AppKit) - if let image = NSImage(data: data) { - Image(nsImage: image) - .resizable() - } else { - Color.clear - } - #else - Color.clear - #endif - } - - private var displayName: String { - URL(fileURLWithPath: path).lastPathComponent - } - - private var forbiddenMessage: String { - switch scope { - case .chat: - String( - localized: "chat.artifact.forbidden.message", - defaultValue: "This file was not referenced by the conversation.", - bundle: .module - ) - case .terminal: - String( - localized: "chat.artifact.forbidden.terminal_message", - defaultValue: "This file isn't visible in the current terminal view.", - bundle: .module - ) - } - } - - private func progressValue(fetched: Int64, total: Int64?) -> Double? { - guard let total, total > 0 else { return nil } - return Double(fetched) / Double(total) - } - - private func progressText(fetched: Int64, total: Int64?) -> String { - if let total { - return "\(formattedSize(fetched)) / \(formattedSize(total))" - } - return formattedSize(fetched) - } - - private func formattedSize(_ bytes: Int64) -> String { - ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) - } - - private func tooLargeMessage(limit: Int64) -> String { - let format = String( - localized: "chat.artifact.too_large.message", - defaultValue: "This preview is limited to %@.", - bundle: .module - ) - return String.localizedStringWithFormat(format, formattedSize(limit)) - } - - private enum LoadState: Equatable { - case loading(fetched: Int64, total: Int64?) - case image(data: Data) - case text(text: String) - case binary(stat: ChatArtifactStat) - case tooLarge(limit: Int64) - case unsupportedMedia - case fileMissing - case macUnreachable - case forbidden - - init(error: any Error) { - guard let artifactError = error as? ChatArtifactError else { - self = .macUnreachable - return - } - switch artifactError { - case .fileNotFound: - self = .fileMissing - case .forbidden: - self = .forbidden - case .macUnreachable, .unavailable, .unsupported, .sessionNotFound, .invalidParams: - self = .macUnreachable - case .unsupportedMedia: - self = .unsupportedMedia - case .tooLarge(let limitBytes): - self = .tooLarge(limit: limitBytes) + ChatArtifactViewerRouteView(path: path, scope: scope) { + dismiss() } } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index f4167436e5f6..441c518e0f12 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -24,13 +24,13 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Referenced Files" + "value" : "Referenced Items" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "参照ファイル" + "value" : "参照項目" } } } @@ -109,13 +109,13 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "No files" + "value" : "No items" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ファイルがありません" + "value" : "項目がありません" } } } @@ -137,6 +137,23 @@ } } }, + "chat.artifact.folder.showing_first_500" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Showing first 500 items" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "最初の 500 項目を表示しています" + } + } + } + }, "chat.artifact.forbidden.message" : { "extractionState" : "manual", "localizations" : { @@ -205,6 +222,23 @@ } } }, + "chat.artifact.open_item" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open item" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "項目を開く" + } + } + } + }, "chat.artifact.mac_unreachable.message" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatBlockDetailSheetView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatBlockDetailSheetView.swift index 4cb98712e714..cbad4ef5abe4 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatBlockDetailSheetView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatBlockDetailSheetView.swift @@ -109,7 +109,7 @@ private struct ChatBlockDetailArtifactActions: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "chat.artifact.actions.title", defaultValue: "Referenced Files", bundle: .module)) + Text(String(localized: "chat.artifact.actions.title", defaultValue: "Referenced Items", bundle: .module)) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) ForEach(deduplicatedPaths, id: \.self) { path in @@ -131,10 +131,7 @@ private struct ChatBlockDetailArtifactActions: View { private struct ChatBlockDetailArtifactActionRow: View { let path: String - @Environment(\.chatArtifactLoader) private var loader - @State private var stat: ChatArtifactStat? @State private var selectedArtifact: ChatArtifactPathSelection? - @State private var selectedFolder: ChatArtifactPathSelection? var body: some View { HStack(spacing: 8) { @@ -150,38 +147,20 @@ private struct ChatBlockDetailArtifactActionRow: View { .truncationMode(.middle) } Spacer(minLength: 8) - if stat?.isDirectory == true { - Button { - selectedFolder = ChatArtifactPathSelection(path: path) - } label: { - Label( - String(localized: "chat.artifact.browse_folder", defaultValue: "Browse folder", bundle: .module), - systemImage: "folder" - ) - } - .labelStyle(.iconOnly) - } else { - Button { - selectedArtifact = ChatArtifactPathSelection(path: path) - } label: { - Label( - String(localized: "chat.artifact.view_file", defaultValue: "View file", bundle: .module), - systemImage: "doc.text.magnifyingglass" - ) - } - .labelStyle(.iconOnly) + Button { + selectedArtifact = ChatArtifactPathSelection(path: path) + } label: { + Label( + String(localized: "chat.artifact.open_item", defaultValue: "Open item", bundle: .module), + systemImage: "doc.text.magnifyingglass" + ) } + .labelStyle(.iconOnly) } .padding(10) .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 8)) - .task(id: path) { - stat = try? await loader.stat(path: path) - } .sheet(item: $selectedArtifact) { selection in ChatArtifactViewerSheet(path: selection.path) } - .sheet(item: $selectedFolder) { selection in - ChatArtifactFolderView(path: selection.path) - } } } diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift index 3ae23d515f23..28f6e64000ac 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift @@ -5,6 +5,20 @@ import Testing @testable import CmuxAgentChatUI struct ChatArtifactLoaderTests { + @Test func directoryRoutingRequiresTheNewFolderCapability() { + let directory = ChatArtifactStat( + exists: true, + isDirectory: true, + size: 0, + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .directory, + mimeType: nil + ) + + #expect(ChatArtifactViewerRouting.showsFolder(stat: directory, supportsDirectoryBrowsing: true)) + #expect(!ChatArtifactViewerRouting.showsFolder(stat: directory, supportsDirectoryBrowsing: false)) + } + @Test func thumbnailCacheReusesSamePathAndDimension() async throws { let source = CountingArtifactSource() let loader = ChatArtifactLoader( From d79547577ec0056529de20ecfd35bb0fff4a6ca9 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 13:25:03 -0700 Subject: [PATCH 08/92] feat: include folders in artifact galleries --- .../ChatArtifactGalleryBuilder.swift | 111 ++++++++++++++++++ .../Artifacts/ChatArtifactGalleryItem.swift | 14 +++ .../Artifacts/ChatArtifactGalleryPage.swift | 19 +++ .../ChatArtifactGalleryTests.swift | 103 ++++++++++++++++ .../MobileChatEventSource.swift | 9 +- .../Resources/Localizable.xcstrings | 34 ++++++ .../TerminalArtifactFilesSheet.swift | 9 +- .../TerminalArtifactGalleryDisplayItem.swift | 6 + .../TerminalArtifactGalleryItemView.swift | 20 +++- .../AgentChatArtifactGalleryBuilder.swift | 89 +------------- ...rminalController+MobileChatArtifacts.swift | 4 +- ...alController+MobileTerminalArtifacts.swift | 9 +- 12 files changed, 329 insertions(+), 98 deletions(-) create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift new file mode 100644 index 000000000000..c7b098843391 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift @@ -0,0 +1,111 @@ +import Foundation + +/// Builds stat-enriched, append-only pages from one transcript index snapshot. +public struct ChatArtifactGalleryBuilder: Sendable { + /// Creates a gallery page builder. + public init() {} + + /// Builds one sectioned or flat search page. + /// + /// - Parameters: + /// - sessionID: Session represented by the artifact index. + /// - items: De-duplicated transcript artifact references. + /// - generation: Stable snapshot generation carried by page cursors. + /// - cursor: Position after which the referenced section continues. + /// - pageSize: Maximum referenced entries to include. + /// - query: Optional basename or path search. + /// - includeDirectories: Whether directory references are eligible for + /// rows. This defaults to `false` for clients without folder capability. + /// - Returns: One gallery page with filesystem metadata. + public func page( + sessionID: String, + items: [ChatArtifactIndexedReference], + generation: String, + cursor: ChatArtifactGalleryCursor?, + pageSize: Int, + query: String?, + includeDirectories: Bool = false + ) -> ChatArtifactGalleryPage { + let ordering = ChatArtifactGalleryOrdering() + let eligibleItems = eligibleItems(items, includeDirectories: includeDirectories) + let normalizedQuery = query?.trimmingCharacters(in: .whitespacesAndNewlines) + let isSearch = normalizedQuery?.isEmpty == false + let candidates: [ChatArtifactIndexedReference] + if let normalizedQuery, !normalizedQuery.isEmpty { + candidates = ordering.search(eligibleItems, query: normalizedQuery) + } else { + candidates = ordering.sorted(eligibleItems.filter { $0.provenance == .referenced }) + } + let remaining = ordering.items(candidates, strictlyAfter: cursor) + let pageReferences = Array(remaining.prefix(pageSize)) + let nextCursor: String? + if remaining.count > pageReferences.count, let last = pageReferences.last { + nextCursor = try? ChatArtifactGalleryCursor( + generation: generation, + seq: last.lastReferencedSeq, + path: last.path + ).token() + } else { + nextCursor = nil + } + + let includeCompleteSections = cursor == nil && !isSearch + let created = includeCompleteSections + ? statItems(ordering.sorted(eligibleItems.filter { $0.provenance == .created })) + : [] + let attached = includeCompleteSections + ? statItems(ordering.sorted(eligibleItems.filter { $0.provenance == .attached })) + : [] + return ChatArtifactGalleryPage( + sessionID: sessionID, + created: created, + attached: attached, + referenced: statItems(pageReferences), + referencedTotal: candidates.count, + nextCursor: nextCursor, + generation: generation + ) + } + + private func eligibleItems( + _ items: [ChatArtifactIndexedReference], + includeDirectories: Bool + ) -> [ChatArtifactIndexedReference] { + guard !includeDirectories else { return items } + let reader = ArtifactByteReader() + return items.filter { reference in + (try? reader.stat(path: reference.path).isDirectory) != true + } + } + + private func statItems( + _ references: [ChatArtifactIndexedReference] + ) -> [ChatArtifactGalleryItem] { + let reader = ArtifactByteReader() + return references.map { reference in + do { + let stat = try reader.stat(path: reference.path) + let listing = stat.isDirectory ? try? reader.list(path: reference.path) : nil + return ChatArtifactGalleryItem( + path: reference.path, + kind: stat.kind, + displayName: URL(fileURLWithPath: reference.path).lastPathComponent, + size: stat.size, + modifiedAt: stat.modifiedAt, + exists: stat.exists, + childCount: listing?.entries.count, + childCountIsCapped: listing?.isTruncated ?? false, + provenance: reference.provenance + ) + } catch { + return ChatArtifactGalleryItem( + path: reference.path, + kind: reader.kind(path: reference.path, isDirectory: false), + displayName: URL(fileURLWithPath: reference.path).lastPathComponent, + exists: false, + provenance: reference.provenance + ) + } + } + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryItem.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryItem.swift index 4259de2f73f2..8faa746a0cd5 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryItem.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryItem.swift @@ -14,6 +14,10 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl public let modifiedAt: Date? /// Whether the path existed when this page was served. public let exists: Bool + /// Immediate child count for a directory, when listing succeeded. + public let childCount: Int? + /// Whether ``childCount`` is the listing cap rather than the exact count. + public let childCountIsCapped: Bool /// Transcript provenance after precedence-based de-duplication. public let provenance: ChatArtifactProvenance @@ -28,6 +32,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl size: Int64? = nil, modifiedAt: Date? = nil, exists: Bool = true, + childCount: Int? = nil, + childCountIsCapped: Bool = false, provenance: ChatArtifactProvenance = .referenced ) { self.path = path @@ -36,6 +42,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl self.size = size self.modifiedAt = modifiedAt self.exists = exists + self.childCount = childCount + self.childCountIsCapped = childCountIsCapped self.provenance = provenance } @@ -46,6 +54,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl case size case modifiedAt = "modified_at" case exists + case childCount = "child_count" + case childCountIsCapped = "child_count_is_capped" case provenance } @@ -62,6 +72,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl modifiedAt = try? container.decode(Date.self, forKey: .modifiedAt) } exists = (try? container.decode(Bool.self, forKey: .exists)) ?? true + childCount = try? container.decode(Int.self, forKey: .childCount) + childCountIsCapped = (try? container.decode(Bool.self, forKey: .childCountIsCapped)) ?? false provenance = (try? container.decode(ChatArtifactProvenance.self, forKey: .provenance)) ?? .referenced } @@ -75,6 +87,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl try container.encode(modifiedAt.timeIntervalSince1970, forKey: .modifiedAt) } try container.encode(exists, forKey: .exists) + try container.encodeIfPresent(childCount, forKey: .childCount) + try container.encode(childCountIsCapped, forKey: .childCountIsCapped) try container.encode(provenance, forKey: .provenance) } } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift index 35ab59b8903e..69c8a790de88 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift @@ -54,4 +54,23 @@ public struct ChatArtifactGalleryPage: Sendable, Equatable, Codable { nextCursor = try? container.decode(String.self, forKey: .nextCursor) generation = (try? container.decode(String.self, forKey: .generation)) ?? "" } + + /// Returns a compatibility view with directory rows removed. + /// + /// New clients use this when connected to a host that does not advertise + /// folder gallery support. + public func excludingDirectories() -> ChatArtifactGalleryPage { + let filteredCreated = created.filter { $0.kind != .directory } + let filteredAttached = attached.filter { $0.kind != .directory } + let filteredReferenced = referenced.filter { $0.kind != .directory } + return ChatArtifactGalleryPage( + sessionID: sessionID, + created: filteredCreated, + attached: filteredAttached, + referenced: filteredReferenced, + referencedTotal: max(0, referencedTotal - (referenced.count - filteredReferenced.count)), + nextCursor: nextCursor, + generation: generation + ) + } } diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift index a08b0242a6cf..384f180aeeb0 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift @@ -13,6 +13,8 @@ struct ChatArtifactGalleryTests { size: 42, modifiedAt: Date(timeIntervalSince1970: 123), exists: false, + childCount: 500, + childCountIsCapped: true, provenance: .created ) let page = ChatArtifactGalleryPage( @@ -28,6 +30,9 @@ struct ChatArtifactGalleryTests { #expect(json["session_id"] as? String == "session-1") #expect(json["referenced_total"] as? Int == 7) #expect(json["next_cursor"] as? String == "cursor") + let created = try #require(json["created"] as? [[String: Any]]) + #expect(created.first?["child_count"] as? Int == 500) + #expect(created.first?["child_count_is_capped"] as? Bool == true) #expect(try coding.decode(ChatArtifactGalleryPage.self, from: data) == page) let scan = TerminalArtifactScanResponse(artifacts: [], sessionID: "session-1") @@ -41,6 +46,8 @@ struct ChatArtifactGalleryTests { let itemData = Data(#"{"path":"/tmp/old.txt","kind":"text","display_name":"old.txt"}"#.utf8) let item = try coding.decode(ChatArtifactGalleryItem.self, from: itemData) #expect(item.exists) + #expect(item.childCount == nil) + #expect(!item.childCountIsCapped) #expect(item.provenance == .referenced) let scanData = Data(#"{"artifacts":[]}"#.utf8) @@ -50,6 +57,102 @@ struct ChatArtifactGalleryTests { #expect(scan.artifacts.isEmpty) } + @Test("directory rows and child counts require folder capability") + func directoryRowsAreCapabilityGated() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-gallery-\(UUID().uuidString)", isDirectory: true) + let folder = root.appendingPathComponent("folder", isDirectory: true) + let nested = folder.appendingPathComponent("nested", isDirectory: true) + let file = root.appendingPathComponent("notes.txt") + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + #expect(FileManager.default.createFile( + atPath: folder.appendingPathComponent("child.txt").path, + contents: Data() + )) + #expect(FileManager.default.createFile(atPath: file.path, contents: Data())) + defer { try? FileManager.default.removeItem(at: root) } + + let references = [ + ChatArtifactIndexedReference( + path: folder.path, + provenance: .referenced, + lastReferencedSeq: 2 + ), + ChatArtifactIndexedReference( + path: file.path, + provenance: .referenced, + lastReferencedSeq: 1 + ), + ] + let builder = ChatArtifactGalleryBuilder() + let legacy = builder.page( + sessionID: "session", + items: references, + generation: "generation", + cursor: nil, + pageSize: 10, + query: nil + ) + #expect(legacy.referenced.map(\.path) == [file.path]) + #expect(legacy.referencedTotal == 1) + + let folders = builder.page( + sessionID: "session", + items: references, + generation: "generation", + cursor: nil, + pageSize: 10, + query: nil, + includeDirectories: true + ) + #expect(folders.referenced.map(\.path) == [folder.path, file.path]) + let folderItem = try #require(folders.referenced.first) + #expect(folderItem.kind == .directory) + #expect(folderItem.childCount == 2) + #expect(!folderItem.childCountIsCapped) + + let oldHostPage = ChatArtifactGalleryPage( + sessionID: "session", + created: [folderItem], + referenced: [folderItem], + referencedTotal: 1 + ).excludingDirectories() + #expect(oldHostPage.created.isEmpty) + #expect(oldHostPage.referenced.isEmpty) + #expect(oldHostPage.referencedTotal == 0) + } + + @Test("directory child counts stop at the listing cap") + func directoryChildCountCap() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-gallery-cap-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + for index in 0...ArtifactByteReader.maximumDirectoryEntryCount { + #expect(FileManager.default.createFile( + atPath: root.appendingPathComponent("item-\(index)").path, + contents: Data() + )) + } + + let page = ChatArtifactGalleryBuilder().page( + sessionID: "session", + items: [ChatArtifactIndexedReference( + path: root.path, + provenance: .referenced, + lastReferencedSeq: 1 + )], + generation: "generation", + cursor: nil, + pageSize: 10, + query: nil, + includeDirectories: true + ) + let folder = try #require(page.referenced.first) + #expect(folder.childCount == ArtifactByteReader.maximumDirectoryEntryCount) + #expect(folder.childCountIsCapped) + } + @Test("count-only scan matches every Session section without stat filtering") func sessionCountScan() throws { let records = [ diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift index d163060293ad..ac0e1a654e04 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift @@ -341,10 +341,14 @@ public actor MobileChatEventSource: ChatEventSource { if let query, !query.isEmpty { params["query"] = query } - return try await artifactCall( + if supportsArtifactFolders { + params["include_directories"] = true + } + let page: ChatArtifactGalleryPage = try await artifactCall( method: "mobile.chat.artifact.gallery", params: params ) + return supportsArtifactFolders ? page : page.excludingDirectories() } /// Scans file references rendered by one terminal surface. @@ -373,6 +377,9 @@ public actor MobileChatEventSource: ChatEventSource { if countOnly { params["count_only"] = true } + if supportsTerminalArtifactList { + params["include_directories"] = true + } return try await artifactCall( method: "mobile.terminal.artifact.scan", params: params diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings index f992b8a15def..beebb31dec4b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings @@ -70,6 +70,40 @@ } } }, + "terminal.artifact.gallery.child_count" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "^[%lld item](inflect: true)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld項目" + } + } + } + }, + "terminal.artifact.gallery.child_count_capped" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld+ items" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld項目以上" + } + } + } + }, "terminal.artifact.gallery.empty" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift index a08526de2f22..b4a41822340a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift @@ -106,7 +106,9 @@ struct TerminalArtifactFilesSheet: View { visibleOnly: true ) guard !Task.isCancelled else { return } - let files = response.artifacts.filter { $0.kind != .directory } + let files = source.supportsTerminalArtifactList + ? response.artifacts + : response.artifacts.filter { $0.kind != .directory } inViewState = .loaded(files) guard source.supportsArtifactGallery, let resolvedSessionID = response.sessionID?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -139,7 +141,10 @@ struct TerminalArtifactFilesSheet: View { visibleOnly: true ) guard !Task.isCancelled else { return } - inViewState = .loaded(response.artifacts.filter { $0.kind != .directory }) + let files = source.supportsTerminalArtifactList + ? response.artifacts + : response.artifacts.filter { $0.kind != .directory } + inViewState = .loaded(files) } catch is CancellationError { return } catch { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryDisplayItem.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryDisplayItem.swift index 7a0e1ff8437e..a2c09e655249 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryDisplayItem.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryDisplayItem.swift @@ -10,6 +10,8 @@ struct TerminalArtifactGalleryDisplayItem: Identifiable, Equatable { let size: Int64? let modifiedAt: Date? let exists: Bool + let childCount: Int? + let childCountIsCapped: Bool let subtitle: String? var id: String { path } @@ -21,6 +23,8 @@ struct TerminalArtifactGalleryDisplayItem: Identifiable, Equatable { size = reference.size modifiedAt = reference.modifiedAt exists = true + childCount = nil + childCountIsCapped = false subtitle = nil } @@ -31,6 +35,8 @@ struct TerminalArtifactGalleryDisplayItem: Identifiable, Equatable { size = galleryItem.size modifiedAt = galleryItem.modifiedAt exists = galleryItem.exists + childCount = galleryItem.childCount + childCountIsCapped = galleryItem.childCountIsCapped self.subtitle = subtitle } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift index 335dedf09fc2..5f9bc9564a03 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift @@ -162,10 +162,13 @@ struct TerminalArtifactGalleryItemView: View { private var metadataText: String? { var components: [String] = [] + if artifact.kind == .directory, let childCount = artifact.childCount { + components.append(childCountText(childCount)) + } if let modifiedAt = artifact.modifiedAt { components.append(modifiedAt.formatted(date: .abbreviated, time: .omitted)) } - if let size = artifact.size { + if artifact.kind != .directory, let size = artifact.size { components.append(ByteCountFormatter.string( fromByteCount: max(0, size), countStyle: .file @@ -174,6 +177,21 @@ struct TerminalArtifactGalleryItemView: View { return components.isEmpty ? nil : components.joined(separator: " · ") } + private func childCountText(_ childCount: Int) -> String { + if artifact.childCountIsCapped { + return String( + localized: "terminal.artifact.gallery.child_count_capped", + defaultValue: "\(childCount)+ items", + bundle: .module + ) + } + return String( + localized: "terminal.artifact.gallery.child_count", + defaultValue: "^[\(childCount) item](inflect: true)", + bundle: .module + ) + } + private var detailText: String? { artifact.subtitle ?? metadataText } diff --git a/Sources/Mobile/AgentChat/AgentChatArtifactGalleryBuilder.swift b/Sources/Mobile/AgentChat/AgentChatArtifactGalleryBuilder.swift index d1d885c119fb..ac5b645bce4e 100644 --- a/Sources/Mobile/AgentChat/AgentChatArtifactGalleryBuilder.swift +++ b/Sources/Mobile/AgentChat/AgentChatArtifactGalleryBuilder.swift @@ -1,90 +1,3 @@ import CmuxAgentChat -import Foundation -/// Builds stat-enriched, append-only pages from one transcript index snapshot. -struct AgentChatArtifactGalleryBuilder: Sendable { - /// Creates a gallery page builder. - init() {} - - /// Builds one sectioned or flat search page. - func page( - sessionID: String, - items: [ChatArtifactIndexedReference], - generation: String, - cursor: ChatArtifactGalleryCursor?, - pageSize: Int, - query: String? - ) -> ChatArtifactGalleryPage { - let ordering = ChatArtifactGalleryOrdering() - let normalizedQuery = query?.trimmingCharacters(in: .whitespacesAndNewlines) - let isSearch = normalizedQuery?.isEmpty == false - let candidates: [ChatArtifactIndexedReference] - if let normalizedQuery, !normalizedQuery.isEmpty { - candidates = ordering.search(items, query: normalizedQuery) - } else { - candidates = ordering.sorted(items.filter { $0.provenance == .referenced }) - } - let remaining = ordering.items(candidates, strictlyAfter: cursor) - let pageReferences = Array(remaining.prefix(pageSize)) - let nextCursor: String? - if remaining.count > pageReferences.count, let last = pageReferences.last { - nextCursor = try? ChatArtifactGalleryCursor( - generation: generation, - seq: last.lastReferencedSeq, - path: last.path - ).token() - } else { - nextCursor = nil - } - - let includeCompleteSections = cursor == nil && !isSearch - let created = includeCompleteSections - ? statItems(ordering.sorted(items.filter { $0.provenance == .created })) - : [] - let attached = includeCompleteSections - ? statItems(ordering.sorted(items.filter { $0.provenance == .attached })) - : [] - return ChatArtifactGalleryPage( - sessionID: sessionID, - created: created, - attached: attached, - referenced: statItems(pageReferences), - referencedTotal: candidates.count, - nextCursor: nextCursor, - generation: generation - ) - } - - private func statItems( - _ references: [ChatArtifactIndexedReference] - ) -> [ChatArtifactGalleryItem] { - let reader = ArtifactByteReader() - return references.map { reference in - do { - let stat = try reader.stat(path: reference.path) - return ChatArtifactGalleryItem( - path: reference.path, - kind: stat.kind, - displayName: URL(fileURLWithPath: reference.path).lastPathComponent, - size: stat.size, - modifiedAt: stat.modifiedAt, - exists: stat.exists, - provenance: reference.provenance - ) - } catch { - #if DEBUG - cmuxDebugLog( - "mobile.chat.artifact.gallery unavailable reason=stat-failed path=\(reference.path)" - ) - #endif - return ChatArtifactGalleryItem( - path: reference.path, - kind: reader.kind(path: reference.path, isDirectory: false), - displayName: URL(fileURLWithPath: reference.path).lastPathComponent, - exists: false, - provenance: reference.provenance - ) - } - } - } -} +typealias AgentChatArtifactGalleryBuilder = ChatArtifactGalleryBuilder diff --git a/Sources/TerminalController+MobileChatArtifacts.swift b/Sources/TerminalController+MobileChatArtifacts.swift index a1ca554fdf58..35ff109d13a5 100644 --- a/Sources/TerminalController+MobileChatArtifacts.swift +++ b/Sources/TerminalController+MobileChatArtifacts.swift @@ -42,6 +42,7 @@ extension TerminalController { } let pageSize = min(max(v2Int(params, "page_size") ?? 60, 1), 100) let query = v2RawString(params, "query") + let includeDirectories = v2Bool(params, "include_directories") ?? false let page = await Task.detached(priority: .utility) { AgentChatArtifactGalleryBuilder().page( sessionID: indexedSession.sessionID, @@ -49,7 +50,8 @@ extension TerminalController { generation: indexedSession.snapshot.generation, cursor: cursor, pageSize: pageSize, - query: query + query: query, + includeDirectories: includeDirectories ) }.value var payload = ChatArtifactWire.payload(page) ?? [:] diff --git a/Sources/TerminalController+MobileTerminalArtifacts.swift b/Sources/TerminalController+MobileTerminalArtifacts.swift index e3d860a54c01..e2d446d51f99 100644 --- a/Sources/TerminalController+MobileTerminalArtifacts.swift +++ b/Sources/TerminalController+MobileTerminalArtifacts.swift @@ -43,6 +43,7 @@ extension TerminalController { func v2MobileTerminalArtifactScan(params: [String: Any]) async -> V2CallResult { let visibleOnly = v2Bool(params, "visible_only") ?? false let countOnly = v2Bool(params, "count_only") ?? false + let includeDirectories = v2Bool(params, "include_directories") ?? false let resolution = mobileTerminalArtifactContext( params: params, requiresPath: false, @@ -76,7 +77,7 @@ extension TerminalController { } } let response = await Task.detached(priority: .utility) { - context.scan() + context.scan(includeDirectories: includeDirectories) }.value return TerminalArtifactWire.result(response) } @@ -324,7 +325,7 @@ private struct TerminalArtifactReadContext: Sendable { self.directoryAccessMode = directoryAccessMode } - func scan() -> TerminalArtifactScanResponse { + func scan(includeDirectories: Bool) -> TerminalArtifactScanResponse { let reader = ArtifactByteReader() let scope = TerminalArtifactScope( terminalText: terminalText, @@ -334,9 +335,7 @@ private struct TerminalArtifactReadContext: Sendable { ) let artifacts = scope.artifactPaths(limit: 200).compactMap { path -> TerminalArtifactReference? in guard let stat = try? reader.stat(path: path) else { return nil } - // Terminal gallery v1 is file-only. Tap-to-path bypasses scan, so - // folder navigation and a smarter high-count strategy can be deferred. - guard !stat.isDirectory else { return nil } + guard includeDirectories || !stat.isDirectory else { return nil } return TerminalArtifactReference( path: path, kind: stat.kind, From 6247776c80be8867d1cc0bd843ca0be1b887bccb Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 13:56:25 -0700 Subject: [PATCH 09/92] fix: scope folder routing predicate onto ChatArtifactStat The caseless ChatArtifactViewerRouting namespace enum fails the iOS package-conventions lint; extend the receiver instead. Co-Authored-By: Claude Fable 5 --- .../Artifacts/ChatArtifactViewerRouteView.swift | 13 +++++-------- .../ChatArtifactLoaderTests.swift | 4 ++-- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index 2d29aeb4414f..b607f265320b 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -118,8 +118,7 @@ struct ChatArtifactViewerRouteView: View { let stat = try await loader.stat(path: path) guard !stat.isDirectory else { await MainActor.run { - state = ChatArtifactViewerRouting.showsFolder( - stat: stat, + state = stat.showsFolder( supportsDirectoryBrowsing: loader.supportsDirectoryBrowsing ) ? .folder : .binary(stat: stat) } @@ -291,11 +290,9 @@ struct ChatArtifactViewerRouteView: View { } } -enum ChatArtifactViewerRouting { - static func showsFolder( - stat: ChatArtifactStat, - supportsDirectoryBrowsing: Bool - ) -> Bool { - stat.isDirectory && supportsDirectoryBrowsing +extension ChatArtifactStat { + /// Whether this artifact routes to the recursive folder browser. + func showsFolder(supportsDirectoryBrowsing: Bool) -> Bool { + isDirectory && supportsDirectoryBrowsing } } diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift index 28f6e64000ac..e4a20d201f69 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift @@ -15,8 +15,8 @@ struct ChatArtifactLoaderTests { mimeType: nil ) - #expect(ChatArtifactViewerRouting.showsFolder(stat: directory, supportsDirectoryBrowsing: true)) - #expect(!ChatArtifactViewerRouting.showsFolder(stat: directory, supportsDirectoryBrowsing: false)) + #expect(directory.showsFolder(supportsDirectoryBrowsing: true)) + #expect(!directory.showsFolder(supportsDirectoryBrowsing: false)) } @Test func thumbnailCacheReusesSamePathAndDimension() async throws { From 95d3482ed2293285a4d4cba24523f12d851af6a6 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 13:56:25 -0700 Subject: [PATCH 10/92] fix: keep budget-capped files from growing Move the terminal artifact RPCs to MobileChatEventSource+TerminalArtifacts so MobileChatEventSource stays under the 500-line tracking threshold, move the two new capability constants next to their computed properties in MobileShellComposite+Capabilities, and move parseMobileSection plus parseMarkdownSection into KeyboardShortcutSettingsFileStore+SectionParsers so the hard-capped store and composite files end at or below their base line counts. Co-Authored-By: Claude Fable 5 --- ...ileChatEventSource+TerminalArtifacts.swift | 126 ++++++++++++++++++ .../MobileChatEventSource.swift | 122 +---------------- .../MobileShellComposite+Capabilities.swift | 3 + .../MobileShellComposite.swift | 2 - ...tcutSettingsFileStore+SectionParsers.swift | 53 +++++++- .../KeyboardShortcutSettingsFileStore.swift | 53 +------- 6 files changed, 183 insertions(+), 176 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift new file mode 100644 index 000000000000..403245fbb995 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift @@ -0,0 +1,126 @@ +public import CmuxAgentChat +public import Foundation + +/// Terminal-scoped artifact RPCs, extracted from `MobileChatEventSource.swift`, +/// which sits at its file-length budget. +extension MobileChatEventSource { + /// Scans file references rendered by one terminal surface. + /// + /// - Parameters: + /// - workspaceID: Workspace containing the terminal. + /// - surfaceID: Terminal surface to scan. + /// - visibleOnly: Whether to scan only the rendered viewport. The default + /// keeps the existing visible-screen-plus-scrollback behavior. + /// - countOnly: Whether to skip terminal items and return only the bound + /// session's complete gallery count when supported. + /// - Returns: Capped file references detected by the Mac. + public func terminalArtifactScan( + workspaceID: String, + surfaceID: String, + visibleOnly: Bool = false, + countOnly: Bool = false + ) async throws -> TerminalArtifactScanResponse { + var params: [String: Any] = [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + ] + if visibleOnly { + params["visible_only"] = true + } + if countOnly { + params["count_only"] = true + } + if supportsTerminalArtifactList { + params["include_directories"] = true + } + return try await artifactCall( + method: "mobile.terminal.artifact.scan", + params: params + ) + } + + public func terminalArtifactStat( + workspaceID: String, + surfaceID: String, + path: String + ) async throws -> ChatArtifactStat { + try await artifactCall( + method: "mobile.terminal.artifact.stat", + params: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "path": path, + ] + ) + } + + public func terminalArtifactFetch( + workspaceID: String, + surfaceID: String, + path: String, + progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)? + ) async throws -> Data { + var offset: Int64 = 0 + var result = Data() + while true { + let chunk: ChatArtifactChunk = try await artifactCall( + method: "mobile.terminal.artifact.fetch", + params: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "path": path, + "offset": offset, + "length": ChatArtifactTransferPolicy.defaultPolicy.maxRawChunkBytes, + ] + ) + if result.isEmpty, chunk.totalSize > 0, chunk.totalSize <= Int64(Int.max) { + result.reserveCapacity(Int(chunk.totalSize)) + } + result.append(chunk.data) + offset = chunk.offset + Int64(chunk.data.count) + progress?(offset, chunk.totalSize) + if chunk.eof { + return result + } + guard !chunk.data.isEmpty else { + throw ChatArtifactError.macUnreachable + } + } + } + + public func terminalArtifactThumbnail( + workspaceID: String, + surfaceID: String, + path: String, + maxDimension: Int + ) async throws -> ChatArtifactThumbnail { + try await artifactCall( + method: "mobile.terminal.artifact.thumbnail", + params: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "path": path, + "max_dimension": maxDimension, + ] + ) + } + + /// Lists immediate entries in a terminal-visible artifact directory. + public func terminalArtifactList( + workspaceID: String, + surfaceID: String, + path: String + ) async throws -> ChatArtifactDirectoryListing { + guard supportsTerminalArtifactList else { + throw ChatArtifactError.unsupported + } + return try await artifactCall( + method: "mobile.terminal.artifact.list", + params: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "path": path, + ] + ) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift index ac0e1a654e04..80276a5b2b33 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift @@ -351,127 +351,7 @@ public actor MobileChatEventSource: ChatEventSource { return supportsArtifactFolders ? page : page.excludingDirectories() } - /// Scans file references rendered by one terminal surface. - /// - /// - Parameters: - /// - workspaceID: Workspace containing the terminal. - /// - surfaceID: Terminal surface to scan. - /// - visibleOnly: Whether to scan only the rendered viewport. The default - /// keeps the existing visible-screen-plus-scrollback behavior. - /// - countOnly: Whether to skip terminal items and return only the bound - /// session's complete gallery count when supported. - /// - Returns: Capped file references detected by the Mac. - public func terminalArtifactScan( - workspaceID: String, - surfaceID: String, - visibleOnly: Bool = false, - countOnly: Bool = false - ) async throws -> TerminalArtifactScanResponse { - var params: [String: Any] = [ - "workspace_id": workspaceID, - "surface_id": surfaceID, - ] - if visibleOnly { - params["visible_only"] = true - } - if countOnly { - params["count_only"] = true - } - if supportsTerminalArtifactList { - params["include_directories"] = true - } - return try await artifactCall( - method: "mobile.terminal.artifact.scan", - params: params - ) - } - - public func terminalArtifactStat( - workspaceID: String, - surfaceID: String, - path: String - ) async throws -> ChatArtifactStat { - try await artifactCall( - method: "mobile.terminal.artifact.stat", - params: [ - "workspace_id": workspaceID, - "surface_id": surfaceID, - "path": path, - ] - ) - } - - public func terminalArtifactFetch( - workspaceID: String, - surfaceID: String, - path: String, - progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)? - ) async throws -> Data { - var offset: Int64 = 0 - var result = Data() - while true { - let chunk: ChatArtifactChunk = try await artifactCall( - method: "mobile.terminal.artifact.fetch", - params: [ - "workspace_id": workspaceID, - "surface_id": surfaceID, - "path": path, - "offset": offset, - "length": ChatArtifactTransferPolicy.defaultPolicy.maxRawChunkBytes, - ] - ) - if result.isEmpty, chunk.totalSize > 0, chunk.totalSize <= Int64(Int.max) { - result.reserveCapacity(Int(chunk.totalSize)) - } - result.append(chunk.data) - offset = chunk.offset + Int64(chunk.data.count) - progress?(offset, chunk.totalSize) - if chunk.eof { - return result - } - guard !chunk.data.isEmpty else { - throw ChatArtifactError.macUnreachable - } - } - } - - public func terminalArtifactThumbnail( - workspaceID: String, - surfaceID: String, - path: String, - maxDimension: Int - ) async throws -> ChatArtifactThumbnail { - try await artifactCall( - method: "mobile.terminal.artifact.thumbnail", - params: [ - "workspace_id": workspaceID, - "surface_id": surfaceID, - "path": path, - "max_dimension": maxDimension, - ] - ) - } - - /// Lists immediate entries in a terminal-visible artifact directory. - public func terminalArtifactList( - workspaceID: String, - surfaceID: String, - path: String - ) async throws -> ChatArtifactDirectoryListing { - guard supportsTerminalArtifactList else { - throw ChatArtifactError.unsupported - } - return try await artifactCall( - method: "mobile.terminal.artifact.list", - params: [ - "workspace_id": workspaceID, - "surface_id": surfaceID, - "path": path, - ] - ) - } - - private func artifactCall( + func artifactCall( method: String, params: [String: Any] ) async throws -> T { diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift index fed616fab326..2236aa9d4edd 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift @@ -1,4 +1,7 @@ extension MobileShellComposite { + static let chatArtifactFoldersCapability = "chat.artifact.folders.v1" + static let terminalArtifactListCapability = "terminal.artifact.list.v1" + /// Whether the Mac supports workspace close requests. public var supportsWorkspaceCloseActions: Bool { supportedHostCapabilities.contains(Self.workspaceCloseCapability) } /// Whether the Mac supports workspace move/reorder requests. diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index fe04b17f94d1..2dab0da394f2 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -96,9 +96,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { static let workspaceCreateInGroupCapability = "workspace.create_in_group.v1", workspaceGroupCreateCapability = "workspace.group_create.v1" static let chatArtifactCapability = "chat.artifact.v1" static let chatArtifactGalleryCapability = "chat.artifact.gallery.v1" - static let chatArtifactFoldersCapability = "chat.artifact.folders.v1" static let terminalArtifactCapability = "terminal.artifact.v1" - static let terminalArtifactListCapability = "terminal.artifact.list.v1" static let dogfoodFeedbackCapability = "dogfood.v1" static let workspaceGroupsCapability = "workspace.groups.v1" private static let terminalOutputCapabilityTimeoutNanoseconds: UInt64 = 750_000_000 diff --git a/Sources/KeyboardShortcutSettingsFileStore+SectionParsers.swift b/Sources/KeyboardShortcutSettingsFileStore+SectionParsers.swift index 8911ba8abefb..1d935a5c829c 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+SectionParsers.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+SectionParsers.swift @@ -1,7 +1,7 @@ import CmuxSettings import Foundation -/// Settings-file section parsers for file editor, file explorer, and sidebar workspace-todo options, extracted from `KeyboardShortcutSettingsFileStore.swift`, which sits at its file-length budget. +/// Settings-file section parsers for file editor, file explorer, markdown, mobile, and sidebar workspace-todo options, extracted from `KeyboardShortcutSettingsFileStore.swift`, which sits at its file-length budget. extension CmuxSettingsFileStore { func parseFileEditorSection( _ section: [String: Any], @@ -53,4 +53,55 @@ extension CmuxSettingsFileStore { logInvalid("sidebar.beta.workspaceTodos", sourcePath: sourcePath) } } + + func parseMarkdownSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + // Accept numeric doubles (e.g. 15 or 15.0) and round to integer points, + // matching the integer `markdown.fontSize` catalog/UI representation. + if let value = jsonDouble(section["fontSize"]) { + if value >= MarkdownFontSizeSettings.minimumPointSize, + value <= MarkdownFontSizeSettings.maximumPointSize { + snapshot.managedUserDefaults[MarkdownFontSizeSettings.key] = .int(Int(value.rounded())) + } else { + logInvalid("markdown.fontSize", sourcePath: sourcePath) + } + } else if section.keys.contains("fontSize") { + logInvalid("markdown.fontSize", sourcePath: sourcePath) + } + + if let value = jsonString(section["fontFamily"]) { + snapshot.managedUserDefaults[MarkdownFontFamily.key] = .string(MarkdownFontFamily.normalized(value)) + } else if section.keys.contains("fontFamily") { + logInvalid("markdown.fontFamily", sourcePath: sourcePath) + } + + if let value = jsonDouble(section["maxWidth"]) { + if value >= MarkdownMaxWidthSettings.minimumCSSPixels, + value <= MarkdownMaxWidthSettings.maximumCSSPixels { + snapshot.managedUserDefaults[MarkdownMaxWidthSettings.key] = .int(Int(value.rounded())) + } else { + logInvalid("markdown.maxWidth", sourcePath: sourcePath) + } + } else if section.keys.contains("maxWidth") { + logInvalid("markdown.maxWidth", sourcePath: sourcePath) + } + } + + func parseMobileSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + guard section.keys.contains("artifactFolderAccess") else { return } + guard let raw = jsonString(section["artifactFolderAccess"]), + let value = MobileArtifactFolderAccess(rawValue: raw) else { + logInvalid("mobile.artifactFolderAccess", sourcePath: sourcePath) + return + } + let key = SettingCatalog().mobile.artifactFolderAccess + snapshot.managedUserDefaults[key.userDefaultsKey] = .string(value.rawValue) + } } diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 0c72ff1c0984..b8e885334967 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -634,42 +634,6 @@ final class CmuxSettingsFileStore { } } - private func parseMarkdownSection( - _ section: [String: Any], - sourcePath: String, - snapshot: inout ResolvedSettingsSnapshot - ) { - // Accept numeric doubles (e.g. 15 or 15.0) and round to integer points, - // matching the integer `markdown.fontSize` catalog/UI representation. - if let value = jsonDouble(section["fontSize"]) { - if value >= MarkdownFontSizeSettings.minimumPointSize, - value <= MarkdownFontSizeSettings.maximumPointSize { - snapshot.managedUserDefaults[MarkdownFontSizeSettings.key] = .int(Int(value.rounded())) - } else { - logInvalid("markdown.fontSize", sourcePath: sourcePath) - } - } else if section.keys.contains("fontSize") { - logInvalid("markdown.fontSize", sourcePath: sourcePath) - } - - if let value = jsonString(section["fontFamily"]) { - snapshot.managedUserDefaults[MarkdownFontFamily.key] = .string(MarkdownFontFamily.normalized(value)) - } else if section.keys.contains("fontFamily") { - logInvalid("markdown.fontFamily", sourcePath: sourcePath) - } - - if let value = jsonDouble(section["maxWidth"]) { - if value >= MarkdownMaxWidthSettings.minimumCSSPixels, - value <= MarkdownMaxWidthSettings.maximumCSSPixels { - snapshot.managedUserDefaults[MarkdownMaxWidthSettings.key] = .int(Int(value.rounded())) - } else { - logInvalid("markdown.maxWidth", sourcePath: sourcePath) - } - } else if section.keys.contains("maxWidth") { - logInvalid("markdown.maxWidth", sourcePath: sourcePath) - } - } - private func parseSidebarSection( _ section: [String: Any], sourcePath: String, @@ -941,21 +905,6 @@ final class CmuxSettingsFileStore { applyNormalizedStringArraySettings(BrowserSettingsFileMapping.stringArraySettings, from: section, sourcePath: sourcePath, snapshot: &snapshot) } - private func parseMobileSection( - _ section: [String: Any], - sourcePath: String, - snapshot: inout ResolvedSettingsSnapshot - ) { - guard section.keys.contains("artifactFolderAccess") else { return } - guard let raw = jsonString(section["artifactFolderAccess"]), - let value = MobileArtifactFolderAccess(rawValue: raw) else { - logInvalid("mobile.artifactFolderAccess", sourcePath: sourcePath) - return - } - let key = SettingCatalog().mobile.artifactFolderAccess - snapshot.managedUserDefaults[key.userDefaultsKey] = .string(value.rawValue) - } - private func parseWorkspaceGroupsSection( _ section: [String: Any], sourcePath: String, @@ -1795,7 +1744,7 @@ final class CmuxSettingsFileStore { return number.intValue } - private func jsonDouble(_ rawValue: Any?) -> Double? { + func jsonDouble(_ rawValue: Any?) -> Double? { guard let number = rawValue as? NSNumber else { return nil } guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } return number.doubleValue From 10bfcc70d1ba159660e994964c19886cea77489d Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 13:45:02 -0700 Subject: [PATCH 11/92] refactor: share progressive artifact fetch loop --- .../Source/ChatEventSource.swift | 34 +++++++ .../MobileArtifactChunkFetchLoop.swift | 44 +++++++++ ...ileChatEventSource+TerminalArtifacts.swift | 63 +++++++------ .../MobileChatEventSource.swift | 67 ++++++++----- .../MobileArtifactChunkFetchLoopTests.swift | 93 +++++++++++++++++++ .../MobileArtifactChunkScript.swift | 29 ++++++ 6 files changed, 279 insertions(+), 51 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactChunkFetchLoop.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkScript.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/ChatEventSource.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/ChatEventSource.swift index a943ff834a2b..e0c914390635 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/ChatEventSource.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/ChatEventSource.swift @@ -84,6 +84,22 @@ public protocol ChatEventSource: Sendable { progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)? ) async throws -> Data + /// Streams raw chunks for a referenced artifact path. + /// + /// Implementations call `onChunk` in byte-offset order and await it before + /// requesting the next chunk, so cancellation and consumer backpressure + /// remain part of the fetch task. + /// + /// - Parameters: + /// - sessionID: The session whose transcript referenced the path. + /// - path: Absolute Mac host path. + /// - onChunk: Structured callback for each fetched chunk. + func artifactFetch( + sessionID: String, + path: String, + onChunk: @escaping @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws + /// Fetches a JPEG thumbnail for a referenced image artifact. /// /// - Parameters: @@ -127,6 +143,24 @@ public extension ChatEventSource { throw ChatArtifactError.unsupported } + /// Whole-file fallback for sources that have not adopted chunk streaming. + func artifactFetch( + sessionID: String, + path: String, + onChunk: @escaping @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws { + let data = try await artifactFetch(sessionID: sessionID, path: path, progress: nil) + try Task.checkCancellation() + try await onChunk( + ChatArtifactChunk( + data: data, + offset: 0, + totalSize: Int64(data.count), + eof: true + ) + ) + } + /// Unsupported-by-default artifact thumbnail implementation. func artifactThumbnail( sessionID: String, diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactChunkFetchLoop.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactChunkFetchLoop.swift new file mode 100644 index 000000000000..4a5cabb3bc4c --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactChunkFetchLoop.swift @@ -0,0 +1,44 @@ +internal import CmuxAgentChat +internal import Foundation + +/// Runs the offset-driven artifact fetch protocol for chat and terminal scopes. +struct MobileArtifactChunkFetchLoop: Sendable { + /// Fetches chunks in sequence until EOF while preserving consumer backpressure. + /// + /// - Parameters: + /// - collectsData: Whether to return a contiguous copy of all chunk data. + /// - progress: Optional byte-progress callback. + /// - fetchChunk: Fetches the chunk beginning at the requested byte offset. + /// - onChunk: Optionally consumes each response before the next request begins. + /// - Returns: All fetched bytes when `collectsData` is true; otherwise empty data. + func run( + collectsData: Bool, + progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)?, + fetchChunk: @Sendable (_ offset: Int64) async throws -> ChatArtifactChunk, + onChunk: (@Sendable (_ chunk: ChatArtifactChunk) async throws -> Void)? + ) async throws -> Data { + var offset: Int64 = 0 + var result = Data() + while true { + try Task.checkCancellation() + let chunk = try await fetchChunk(offset) + try Task.checkCancellation() + + if collectsData { + if result.isEmpty, chunk.totalSize > 0, chunk.totalSize <= Int64(Int.max) { + result.reserveCapacity(Int(chunk.totalSize)) + } + result.append(chunk.data) + } + offset = chunk.offset + Int64(chunk.data.count) + progress?(offset, chunk.totalSize) + try await onChunk?(chunk) + if chunk.eof { + return result + } + guard !chunk.data.isEmpty else { + throw ChatArtifactError.macUnreachable + } + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift index 403245fbb995..90daa63a22d3 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift @@ -60,32 +60,43 @@ extension MobileChatEventSource { path: String, progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)? ) async throws -> Data { - var offset: Int64 = 0 - var result = Data() - while true { - let chunk: ChatArtifactChunk = try await artifactCall( - method: "mobile.terminal.artifact.fetch", - params: [ - "workspace_id": workspaceID, - "surface_id": surfaceID, - "path": path, - "offset": offset, - "length": ChatArtifactTransferPolicy.defaultPolicy.maxRawChunkBytes, - ] - ) - if result.isEmpty, chunk.totalSize > 0, chunk.totalSize <= Int64(Int.max) { - result.reserveCapacity(Int(chunk.totalSize)) - } - result.append(chunk.data) - offset = chunk.offset + Int64(chunk.data.count) - progress?(offset, chunk.totalSize) - if chunk.eof { - return result - } - guard !chunk.data.isEmpty else { - throw ChatArtifactError.macUnreachable - } - } + try await fetchArtifactChunks( + method: "mobile.terminal.artifact.fetch", + stringParams: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "path": path, + ], + collectsData: true, + progress: progress, + onChunk: nil + ) + } + + /// Streams terminal-scoped artifact chunks without accumulating a second copy. + /// + /// - Parameters: + /// - workspaceID: Workspace containing the terminal surface. + /// - surfaceID: Terminal surface whose visible paths authorize the fetch. + /// - path: Absolute Mac host path. + /// - onChunk: Structured callback for each fetched chunk. + public func terminalArtifactFetch( + workspaceID: String, + surfaceID: String, + path: String, + onChunk: @escaping @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws { + _ = try await fetchArtifactChunks( + method: "mobile.terminal.artifact.fetch", + stringParams: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "path": path, + ], + collectsData: false, + progress: nil, + onChunk: onChunk + ) } public func terminalArtifactThumbnail( diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift index 80276a5b2b33..f904424906b4 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift @@ -262,31 +262,27 @@ public actor MobileChatEventSource: ChatEventSource { path: String, progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)? ) async throws -> Data { - var offset: Int64 = 0 - var result = Data() - while true { - let chunk: ChatArtifactChunk = try await artifactCall( - method: "mobile.chat.artifact.fetch", - params: [ - "session_id": sessionID, - "path": path, - "offset": offset, - "length": ChatArtifactTransferPolicy.defaultPolicy.maxRawChunkBytes, - ] - ) - if result.isEmpty, chunk.totalSize > 0, chunk.totalSize <= Int64(Int.max) { - result.reserveCapacity(Int(chunk.totalSize)) - } - result.append(chunk.data) - offset = chunk.offset + Int64(chunk.data.count) - progress?(offset, chunk.totalSize) - if chunk.eof { - return result - } - guard !chunk.data.isEmpty else { - throw ChatArtifactError.macUnreachable - } - } + try await fetchArtifactChunks( + method: "mobile.chat.artifact.fetch", + stringParams: ["session_id": sessionID, "path": path], + collectsData: true, + progress: progress, + onChunk: nil + ) + } + + public func artifactFetch( + sessionID: String, + path: String, + onChunk: @escaping @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws { + _ = try await fetchArtifactChunks( + method: "mobile.chat.artifact.fetch", + stringParams: ["session_id": sessionID, "path": path], + collectsData: false, + progress: nil, + onChunk: onChunk + ) } public func artifactThumbnail( @@ -364,6 +360,27 @@ public actor MobileChatEventSource: ChatEventSource { } } + func fetchArtifactChunks( + method: String, + stringParams: [String: String], + collectsData: Bool, + progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)?, + onChunk: (@Sendable (ChatArtifactChunk) async throws -> Void)? + ) async throws -> Data { + let loop = MobileArtifactChunkFetchLoop() + return try await loop.run( + collectsData: collectsData, + progress: progress + ) { offset in + var params: [String: Any] = stringParams + params["offset"] = offset + params["length"] = ChatArtifactTransferPolicy.defaultPolicy.maxRawChunkBytes + return try await self.artifactCall(method: method, params: params) + } onChunk: { chunk in + try await onChunk?(chunk) + } + } + private nonisolated static func artifactError(from error: any Error) -> ChatArtifactError { guard let connectionError = error as? MobileShellConnectionError else { return .macUnreachable diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift new file mode 100644 index 000000000000..d78b2c06ccd0 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift @@ -0,0 +1,93 @@ +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxMobileShell + +@Suite +struct MobileArtifactChunkFetchLoopTests { + @Test + func sequencesOffsetsAndStopsAtEOF() async throws { + let first = ChatArtifactChunk( + data: Data("abc".utf8), + offset: 0, + totalSize: 5, + eof: false + ) + let last = ChatArtifactChunk( + data: Data("de".utf8), + offset: 3, + totalSize: 5, + eof: true + ) + let script = MobileArtifactChunkScript(chunks: [first, last]) + + _ = try await MobileArtifactChunkFetchLoop().run( + collectsData: false, + progress: nil + ) { offset in + try await script.fetch(offset: offset) + } onChunk: { chunk in + await script.record(chunk) + } + + let snapshot = await script.snapshot() + #expect(snapshot.requestedOffsets == [0, 3]) + #expect(snapshot.deliveredChunks == [first, last]) + } + + @Test + func acceptsEmptyEOFChunk() async throws { + let emptyEOF = ChatArtifactChunk( + data: Data(), + offset: 0, + totalSize: 0, + eof: true + ) + let script = MobileArtifactChunkScript(chunks: [emptyEOF]) + + _ = try await MobileArtifactChunkFetchLoop().run( + collectsData: false, + progress: nil + ) { offset in + try await script.fetch(offset: offset) + } onChunk: { chunk in + await script.record(chunk) + } + + let snapshot = await script.snapshot() + #expect(snapshot.requestedOffsets == [0]) + #expect(snapshot.deliveredChunks == [emptyEOF]) + } + + @Test + func rejectsEmptyNonEOFChunkAsMacUnreachable() async { + let stalled = ChatArtifactChunk( + data: Data(), + offset: 0, + totalSize: 8, + eof: false + ) + let script = MobileArtifactChunkScript(chunks: [stalled]) + + do { + _ = try await MobileArtifactChunkFetchLoop().run( + collectsData: false, + progress: nil + ) { offset in + try await script.fetch(offset: offset) + } onChunk: { chunk in + await script.record(chunk) + } + Issue.record("empty non-EOF chunk should fail") + } catch let error as ChatArtifactError { + #expect(error == .macUnreachable) + } catch { + Issue.record("unexpected error: \(error)") + } + + let snapshot = await script.snapshot() + #expect(snapshot.requestedOffsets == [0]) + #expect(snapshot.deliveredChunks == [stalled]) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkScript.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkScript.swift new file mode 100644 index 000000000000..81eb303cd62f --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkScript.swift @@ -0,0 +1,29 @@ +import CmuxAgentChat +import Foundation + +/// Deterministic chunk source and delivery recorder for fetch-loop tests. +actor MobileArtifactChunkScript { + private let chunksByOffset: [Int64: ChatArtifactChunk] + private var requestedOffsets: [Int64] = [] + private var deliveredChunks: [ChatArtifactChunk] = [] + + init(chunks: [ChatArtifactChunk]) { + chunksByOffset = Dictionary(uniqueKeysWithValues: chunks.map { ($0.offset, $0) }) + } + + func fetch(offset: Int64) throws -> ChatArtifactChunk { + requestedOffsets.append(offset) + guard let chunk = chunksByOffset[offset] else { + throw ChatArtifactError.fileNotFound + } + return chunk + } + + func record(_ chunk: ChatArtifactChunk) { + deliveredChunks.append(chunk) + } + + func snapshot() -> (requestedOffsets: [Int64], deliveredChunks: [ChatArtifactChunk]) { + (requestedOffsets, deliveredChunks) + } +} From 14e153f85693b97f1e4ccb045d39f74789ab4995 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 13:45:23 -0700 Subject: [PATCH 12/92] perf: render artifact text with TextKit 1 --- .../CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift index 0adc257e12cf..bd1da271e74f 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift @@ -7,7 +7,12 @@ struct ChatArtifactTextView: UIViewRepresentable { let text: String func makeUIView(context: Context) -> UITextView { - let textView = UITextView() + // A default UITextView uses TextKit 2 on modern iOS. Large artifacts + // then synchronously create layout fragments during fast scrolling. + // Opt into the TextKit 1 stack so non-contiguous glyph layout remains + // genuinely lazy instead of entering TextKit 2 compatibility mode. + let textView = UITextView(usingTextLayoutManager: false) + textView.layoutManager.allowsNonContiguousLayout = true textView.isEditable = false textView.isSelectable = true textView.isScrollEnabled = true From f0fc4caa442a065207d5ab2fbf5aa4af98b31fed Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 13:54:59 -0700 Subject: [PATCH 13/92] feat: stream artifact text progressively --- .../Source/ChatEventSource.swift | 4 +- .../ChatArtifactDataAccumulator.swift | 17 ++ .../Artifacts/ChatArtifactLoader.swift | 66 +++++++ .../Artifacts/ChatArtifactTextView.swift | 53 +++++- .../ChatArtifactTextViewCoordinator.swift | 9 + .../Artifacts/ChatArtifactViewerModel.swift | 145 +++++++++++++++ .../ChatArtifactViewerRouteView.swift | 176 ++++++++---------- .../Artifacts/ChatArtifactViewerState.swift | 16 ++ .../Artifacts/UTF8ChunkAssembler.swift | 121 ++++++++++++ .../Artifacts/UTF8ChunkAssemblerError.swift | 4 + .../Artifacts/UTF8ChunkDecoder.swift | 11 ++ .../Resources/Localizable.xcstrings | 19 +- .../ChatArtifactViewerModelTests.swift | 156 ++++++++++++++++ .../ControlledArtifactStream.swift | 78 ++++++++ .../UTF8ChunkAssemblerTests.swift | 46 +++++ .../MobileArtifactChunkFetchLoop.swift | 4 +- ...ileChatEventSource+TerminalArtifacts.swift | 4 +- .../MobileChatEventSource.swift | 8 +- .../WorkspaceDetailView.swift | 8 + 19 files changed, 827 insertions(+), 118 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactDataAccumulator.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkAssembler.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkAssemblerError.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkDecoder.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ControlledArtifactStream.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/UTF8ChunkAssemblerTests.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/ChatEventSource.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/ChatEventSource.swift index e0c914390635..c38721378437 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/ChatEventSource.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/ChatEventSource.swift @@ -97,7 +97,7 @@ public protocol ChatEventSource: Sendable { func artifactFetch( sessionID: String, path: String, - onChunk: @escaping @Sendable (ChatArtifactChunk) async throws -> Void + onChunk: @Sendable (ChatArtifactChunk) async throws -> Void ) async throws /// Fetches a JPEG thumbnail for a referenced image artifact. @@ -147,7 +147,7 @@ public extension ChatEventSource { func artifactFetch( sessionID: String, path: String, - onChunk: @escaping @Sendable (ChatArtifactChunk) async throws -> Void + onChunk: @Sendable (ChatArtifactChunk) async throws -> Void ) async throws { let data = try await artifactFetch(sessionID: sessionID, path: path, progress: nil) try Task.checkCancellation() diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactDataAccumulator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactDataAccumulator.swift new file mode 100644 index 000000000000..7e4952b9a3fa --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactDataAccumulator.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Accumulates non-text artifact chunks away from the main actor. +actor ChatArtifactDataAccumulator { + private var data = Data() + + func append(_ chunk: Data, totalSize: Int64) { + if data.isEmpty, totalSize > 0, totalSize <= Int64(Int.max) { + data.reserveCapacity(Int(totalSize)) + } + data.append(chunk) + } + + func value() -> Data { + data + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift index b953d14ec9e3..697ac8e7d744 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift @@ -86,10 +86,27 @@ public struct ChatArtifactLoader: Sendable { _ path: String, _ progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)? ) async throws -> Data + private let streamHandler: @Sendable ( + _ path: String, + _ onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws -> Void private let thumbnailHandler: @Sendable (_ path: String, _ maxDimension: Int) async throws -> ChatArtifactThumbnail private let listHandler: @Sendable (_ path: String) async throws -> ChatArtifactDirectoryListing private let thumbnailCache: ChatArtifactThumbnailCache + /// Creates a closure-backed artifact loader. + /// + /// - Parameters: + /// - supportsArtifacts: Whether artifact operations are available. + /// - supportsDirectoryBrowsing: Whether directory stat results may be listed. + /// - scope: Cache and authorization namespace for this loader. + /// - cache: Thumbnail cache shared by rows and viewers. + /// - stat: Metadata operation for an absolute host path. + /// - fetch: Whole-file operation retained for image and compatibility callers. + /// - stream: Optional structured chunk operation; defaults to one callback + /// containing the result of `fetch`. + /// - thumbnail: Thumbnail operation for image artifacts. + /// - list: Immediate-directory listing operation. public init( supportsArtifacts: Bool = false, supportsDirectoryBrowsing: Bool = false, @@ -104,6 +121,10 @@ public struct ChatArtifactLoader: Sendable { ) async throws -> Data = { _, _ in throw ChatArtifactError.unsupported }, + stream: (@Sendable ( + _ path: String, + _ onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws -> Void)? = nil, thumbnail: @escaping @Sendable (_ path: String, _ maxDimension: Int) async throws -> ChatArtifactThumbnail = { _, _ in throw ChatArtifactError.unsupported }, @@ -117,6 +138,18 @@ public struct ChatArtifactLoader: Sendable { self.thumbnailCache = cache statHandler = stat fetchHandler = fetch + streamHandler = stream ?? { path, onChunk in + let data = try await fetch(path, nil) + try Task.checkCancellation() + try await onChunk( + ChatArtifactChunk( + data: data, + offset: 0, + totalSize: Int64(data.count), + eof: true + ) + ) + } thumbnailHandler = thumbnail listHandler = list } @@ -137,6 +170,9 @@ public struct ChatArtifactLoader: Sendable { fetch: { path, progress in try await source.artifactFetch(sessionID: sessionID, path: path, progress: progress) }, + stream: { path, onChunk in + try await source.artifactFetch(sessionID: sessionID, path: path, onChunk: onChunk) + }, thumbnail: { path, maxDimension in try await source.artifactThumbnail( sessionID: sessionID, @@ -150,6 +186,19 @@ public struct ChatArtifactLoader: Sendable { ) } + /// Creates a terminal-scoped closure-backed artifact loader. + /// + /// - Parameters: + /// - terminalWorkspaceID: Workspace containing the terminal surface. + /// - terminalSurfaceID: Terminal surface authorizing visible paths. + /// - supportsArtifacts: Whether terminal artifact operations are available. + /// - supportsDirectoryBrowsing: Whether terminal directory listing is available. + /// - cache: Thumbnail cache shared by rows and viewers. + /// - stat: Metadata operation for an absolute host path. + /// - fetch: Whole-file compatibility operation. + /// - stream: Optional structured chunk operation. + /// - thumbnail: Thumbnail operation for image artifacts. + /// - list: Immediate-directory listing operation. public init( terminalWorkspaceID: String, terminalSurfaceID: String, @@ -161,6 +210,10 @@ public struct ChatArtifactLoader: Sendable { _ path: String, _ progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)? ) async throws -> Data, + stream: (@Sendable ( + _ path: String, + _ onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws -> Void)? = nil, thumbnail: @escaping @Sendable (_ path: String, _ maxDimension: Int) async throws -> ChatArtifactThumbnail, list: @escaping @Sendable (_ path: String) async throws -> ChatArtifactDirectoryListing = { _ in throw ChatArtifactError.unsupported @@ -173,6 +226,7 @@ public struct ChatArtifactLoader: Sendable { cache: cache, stat: stat, fetch: fetch, + stream: stream, thumbnail: thumbnail, list: list ) @@ -193,6 +247,18 @@ public struct ChatArtifactLoader: Sendable { try await fetchHandler(path, progress) } + /// Streams artifact chunks without requiring a contiguous whole-file copy. + /// + /// - Parameters: + /// - path: Absolute Mac host path. + /// - onChunk: Structured callback awaited for each chunk in byte order. + public func stream( + path: String, + onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws { + try await streamHandler(path, onChunk) + } + public func thumbnail( path: String, maxDimension: Int, diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift index bd1da271e74f..085fcb04ee3f 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift @@ -4,7 +4,12 @@ import UIKit /// Displays large artifact text without asking SwiftUI to lay out one monolithic `Text` view. struct ChatArtifactTextView: UIViewRepresentable { - let text: String + let documentID: String + let chunks: [String] + + func makeCoordinator() -> ChatArtifactTextViewCoordinator { + ChatArtifactTextViewCoordinator() + } func makeUIView(context: Context) -> UITextView { // A default UITextView uses TextKit 2 on modern iOS. Large artifacts @@ -24,13 +29,53 @@ struct ChatArtifactTextView: UIViewRepresentable { ) textView.textContainerInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) textView.textContainer.lineFragmentPadding = 0 - textView.text = text return textView } func updateUIView(_ textView: UITextView, context: Context) { - guard textView.text != text else { return } - textView.text = text + let isNewDocument = context.coordinator.documentID != documentID + if isNewDocument { + textView.textStorage.setAttributedString(NSAttributedString()) + textView.selectedRange = NSRange(location: 0, length: 0) + context.coordinator.documentID = documentID + context.coordinator.appliedChunkCount = 0 + } + + guard context.coordinator.appliedChunkCount <= chunks.count else { + context.coordinator.appliedChunkCount = 0 + textView.textStorage.setAttributedString(NSAttributedString()) + } + + let font = textView.font ?? UIFont.monospacedSystemFont( + ofSize: UIFont.preferredFont(forTextStyle: .body).pointSize, + weight: .regular + ) + let textColor = textView.textColor ?? UIColor.label + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: textColor, + ] + while context.coordinator.appliedChunkCount < chunks.count { + let chunk = chunks[context.coordinator.appliedChunkCount] + let contentOffset = textView.contentOffset + let selection = textView.selectedRange + textView.textStorage.beginEditing() + textView.textStorage.append(NSAttributedString(string: chunk, attributes: attributes)) + textView.textStorage.endEditing() + textView.selectedRange = selection + textView.setContentOffset(contentOffset, animated: false) + context.coordinator.appliedChunkCount += 1 + } + + if isNewDocument { + textView.setContentOffset( + CGPoint( + x: -textView.adjustedContentInset.left, + y: -textView.adjustedContentInset.top + ), + animated: false + ) + } } } #endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift new file mode 100644 index 000000000000..b13a04510e16 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift @@ -0,0 +1,9 @@ +#if canImport(UIKit) +import Foundation + +/// Tracks which streamed chunks have been applied to one text-storage instance. +final class ChatArtifactTextViewCoordinator { + var documentID: String? + var appliedChunkCount = 0 +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift new file mode 100644 index 000000000000..41b145c49e29 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift @@ -0,0 +1,145 @@ +import CmuxAgentChat +import Foundation +import Observation + +/// Main-actor state machine for one progressively loaded artifact path. +@Observable +@MainActor +final class ChatArtifactViewerModel { + private(set) var state: ChatArtifactViewerState = .loading + private(set) var textChunks: [String] = [] + private(set) var fetchedBytes: Int64 = 0 + private(set) var totalBytes: Int64? + private(set) var textReachedEOF = false + private(set) var activePath: String? + + var renderedText: String { + textChunks.joined() + } + + func load(path: String, loader: ChatArtifactLoader) async { + reset(for: path) + var stat: ChatArtifactStat? + do { + let loadedStat = try await loader.stat(path: path) + try Task.checkCancellation() + guard path == activePath else { return } + stat = loadedStat + + guard !loadedStat.isDirectory else { + state = loadedStat.showsFolder( + supportsDirectoryBrowsing: loader.supportsDirectoryBrowsing + ) ? .folder : .binary(stat: loadedStat) + return + } + + let limit = ChatArtifactTransferPolicy.defaultPolicy.maxPreviewBytes + guard loadedStat.size <= limit else { + state = .tooLarge(actualSize: loadedStat.size, limit: limit) + return + } + + switch loadedStat.kind { + case .text: + try await streamText(path: path, loader: loader) + case .image, .binary, .directory: + try await loadNonText(path: path, stat: loadedStat, loader: loader) + } + } catch is CancellationError { + return + } catch is UTF8ChunkAssemblerError { + guard path == activePath, let stat else { return } + textChunks = [] + state = .binary(stat: stat) + } catch { + guard !Task.isCancelled, path == activePath else { return } + state = Self.state(for: error, stat: stat) + } + } + + private func reset(for path: String) { + activePath = path + state = .loading + textChunks = [] + fetchedBytes = 0 + totalBytes = nil + textReachedEOF = false + } + + private func streamText(path: String, loader: ChatArtifactLoader) async throws { + let decoder = UTF8ChunkDecoder() + try await loader.stream(path: path) { chunk in + try Task.checkCancellation() + let decoded = try await decoder.decode(chunk.data, eof: chunk.eof) + try Task.checkCancellation() + await self.receiveText(decoded, chunk: chunk, path: path) + } + } + + private func receiveText(_ text: String, chunk: ChatArtifactChunk, path: String) { + guard path == activePath else { return } + if !text.isEmpty { + textChunks.append(text) + } + updateProgress(for: chunk) + textReachedEOF = chunk.eof + state = .text + } + + private func loadNonText( + path: String, + stat: ChatArtifactStat, + loader: ChatArtifactLoader + ) async throws { + let accumulator = ChatArtifactDataAccumulator() + try await loader.stream(path: path) { chunk in + try Task.checkCancellation() + await accumulator.append(chunk.data, totalSize: chunk.totalSize) + await self.receiveNonTextProgress(chunk: chunk, path: path) + } + try Task.checkCancellation() + guard path == activePath else { return } + let data = await accumulator.value() + switch stat.kind { + case .image: + state = .image(data: data) + case .text: + break + case .binary, .directory: + state = .binary(stat: stat) + } + } + + private func receiveNonTextProgress(chunk: ChatArtifactChunk, path: String) { + guard path == activePath else { return } + updateProgress(for: chunk) + } + + private func updateProgress(for chunk: ChatArtifactChunk) { + totalBytes = chunk.totalSize + fetchedBytes = chunk.eof + ? chunk.totalSize + : chunk.offset + Int64(chunk.data.count) + } + + private static func state( + for error: any Error, + stat: ChatArtifactStat? + ) -> ChatArtifactViewerState { + guard let artifactError = error as? ChatArtifactError else { + return .macUnreachable + } + switch artifactError { + case .fileNotFound: + return .fileMissing + case .forbidden: + return .forbidden + case .macUnreachable, .unavailable, .unsupported, .sessionNotFound, .invalidParams: + return .macUnreachable + case .unsupportedMedia: + return .unsupportedMedia + case .tooLarge(let limitBytes): + return .tooLarge(actualSize: stat?.size, limit: limitBytes) + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index b607f265320b..f8d518eef6ba 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -14,7 +14,8 @@ struct ChatArtifactViewerRouteView: View { let onDone: () -> Void @Environment(\.chatArtifactLoader) private var loader - @State private var state: LoadState = .loading(fetched: 0, total: nil) + @State private var model = ChatArtifactViewerModel() + @State private var retryGeneration = 0 var body: some View { content @@ -29,26 +30,36 @@ struct ChatArtifactViewerRouteView: View { } } } - .task(id: path) { - await load() + .task(id: "\(path)\u{0}\(retryGeneration)") { + await model.load(path: path, loader: loader) } } @ViewBuilder private var content: some View { - switch state { - case .loading(let fetched, let total): + switch model.state { + case .loading: VStack(spacing: 12) { - ProgressView(value: progressValue(fetched: fetched, total: total)) - .progressViewStyle(.linear) - .frame(maxWidth: 220) + ProgressView( + value: progressValue( + fetched: model.fetchedBytes, + total: model.totalBytes + ) + ) + .progressViewStyle(.linear) + .frame(maxWidth: 220) Text(String(localized: "chat.artifact.loading", defaultValue: "Loading preview", bundle: .module)) .font(.subheadline) .foregroundStyle(.secondary) - if fetched > 0 || total != nil { - Text(verbatim: progressText(fetched: fetched, total: total)) - .font(.caption.monospacedDigit()) - .foregroundStyle(.tertiary) + if model.fetchedBytes > 0 || model.totalBytes != nil { + Text( + verbatim: progressText( + fetched: model.fetchedBytes, + total: model.totalBytes + ) + ) + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -60,28 +71,33 @@ struct ChatArtifactViewerRouteView: View { .scaledToFit() .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() - case .text(let text): - #if canImport(UIKit) - ChatArtifactTextView(text: text) - #else - ScrollView { - Text(text) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() + case .text: + VStack(spacing: 0) { + if !model.textReachedEOF { + streamingProgressHeader + } + #if canImport(UIKit) + ChatArtifactTextView(documentID: path, chunks: model.textChunks) + #else + ScrollView { + Text(model.renderedText) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + #endif } - #endif case .binary(let stat): unavailableView( title: String(localized: "chat.artifact.preview_unavailable.title", defaultValue: "Preview unavailable", bundle: .module), message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module), detail: formattedSize(stat.size) ) - case .tooLarge(let limit): + case .tooLarge(let actualSize, let limit): unavailableView( title: String(localized: "chat.artifact.too_large.title", defaultValue: "File too large to preview", bundle: .module), - message: tooLargeMessage(limit: limit) + message: tooLargeMessage(actualSize: actualSize, limit: limit) ) case .unsupportedMedia: unavailableView( @@ -110,49 +126,23 @@ struct ChatArtifactViewerRouteView: View { } } - private func load() async { - await MainActor.run { - state = .loading(fetched: 0, total: nil) - } - do { - let stat = try await loader.stat(path: path) - guard !stat.isDirectory else { - await MainActor.run { - state = stat.showsFolder( - supportsDirectoryBrowsing: loader.supportsDirectoryBrowsing - ) ? .folder : .binary(stat: stat) - } - return - } - guard stat.size <= ChatArtifactTransferPolicy.defaultPolicy.maxPreviewBytes else { - await MainActor.run { - state = .tooLarge(limit: ChatArtifactTransferPolicy.defaultPolicy.maxPreviewBytes) - } - return - } - let data = try await loader.fetch(path: path) { fetched, total in - Task { @MainActor in - state = .loading(fetched: fetched, total: total) - } - } - guard !Task.isCancelled else { return } - switch stat.kind { - case .image: - await MainActor.run { state = .image(data: data) } - case .text: - if let text = String(data: data, encoding: .utf8) { - await MainActor.run { state = .text(text: text) } - } else { - await MainActor.run { state = .binary(stat: stat) } - } - case .binary, .directory: - await MainActor.run { state = .binary(stat: stat) } - } - } catch { - await MainActor.run { - state = LoadState(error: error) - } + private var streamingProgressHeader: some View { + HStack(spacing: 10) { + ProgressView( + value: progressValue( + fetched: model.fetchedBytes, + total: model.totalBytes + ) + ) + .progressViewStyle(.linear) + Text(verbatim: progressText(fetched: model.fetchedBytes, total: model.totalBytes)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + .fixedSize() } + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(.bar) } private func unavailableView( @@ -175,7 +165,7 @@ struct ChatArtifactViewerRouteView: View { } if retry { Button { - Task { await load() } + retryGeneration += 1 } label: { Label( String(localized: "chat.artifact.retry", defaultValue: "Retry", bundle: .module), @@ -248,45 +238,25 @@ struct ChatArtifactViewerRouteView: View { ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) } - private func tooLargeMessage(limit: Int64) -> String { + private func tooLargeMessage(actualSize: Int64?, limit: Int64) -> String { + guard let actualSize else { + let format = String( + localized: "chat.artifact.too_large.limit_message", + defaultValue: "This preview is limited to %@.", + bundle: .module + ) + return String.localizedStringWithFormat(format, formattedSize(limit)) + } let format = String( localized: "chat.artifact.too_large.message", - defaultValue: "This preview is limited to %@.", + defaultValue: "This file is %@; previews are limited to %@.", bundle: .module ) - return String.localizedStringWithFormat(format, formattedSize(limit)) - } - - private enum LoadState: Equatable { - case loading(fetched: Int64, total: Int64?) - case folder - case image(data: Data) - case text(text: String) - case binary(stat: ChatArtifactStat) - case tooLarge(limit: Int64) - case unsupportedMedia - case fileMissing - case macUnreachable - case forbidden - - init(error: any Error) { - guard let artifactError = error as? ChatArtifactError else { - self = .macUnreachable - return - } - switch artifactError { - case .fileNotFound: - self = .fileMissing - case .forbidden: - self = .forbidden - case .macUnreachable, .unavailable, .unsupported, .sessionNotFound, .invalidParams: - self = .macUnreachable - case .unsupportedMedia: - self = .unsupportedMedia - case .tooLarge(let limitBytes): - self = .tooLarge(limit: limitBytes) - } - } + return String.localizedStringWithFormat( + format, + formattedSize(actualSize), + formattedSize(limit) + ) } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift new file mode 100644 index 000000000000..2f7b63942fb3 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift @@ -0,0 +1,16 @@ +import CmuxAgentChat +import Foundation + +/// Renderable states for one stat-driven artifact path. +enum ChatArtifactViewerState: Equatable { + case loading + case folder + case image(data: Data) + case text + case binary(stat: ChatArtifactStat) + case tooLarge(actualSize: Int64?, limit: Int64) + case unsupportedMedia + case fileMissing + case macUnreachable + case forbidden +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkAssembler.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkAssembler.swift new file mode 100644 index 000000000000..88e7cfbe47a4 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkAssembler.swift @@ -0,0 +1,121 @@ +import Foundation + +/// Incrementally decodes UTF-8 while retaining only an incomplete trailing scalar. +struct UTF8ChunkAssembler: Sendable { + private var pendingBytes = Data() + + /// Appends raw bytes and returns the complete Unicode scalars now available. + /// + /// - Parameters: + /// - data: The next contiguous bytes in the stream. + /// - eof: Whether these are the final bytes in the stream. + /// - Returns: Decoded text excluding any buffered incomplete trailing scalar. + /// - Throws: ``UTF8ChunkAssemblerError/invalidEncoding`` for malformed UTF-8 + /// or an incomplete scalar at EOF. + mutating func append(_ data: Data, eof: Bool) throws -> String { + var bytes = [UInt8]() + bytes.reserveCapacity(pendingBytes.count + data.count) + bytes.append(contentsOf: pendingBytes) + bytes.append(contentsOf: data) + pendingBytes.removeAll(keepingCapacity: true) + + var index = 0 + var validEnd = bytes.count + while index < bytes.count { + let lead = bytes[index] + if lead <= 0x7F { + index += 1 + continue + } + + let scalarLength = try Self.scalarLength(for: lead) + let availableLength = bytes.count - index + if availableLength < scalarLength { + try Self.validateAvailableScalarPrefix( + bytes, + start: index, + availableLength: availableLength + ) + guard !eof else { + throw UTF8ChunkAssemblerError.invalidEncoding + } + validEnd = index + pendingBytes.append(contentsOf: bytes[index...]) + break + } + + try Self.validateCompleteScalar(bytes, start: index, length: scalarLength) + index += scalarLength + } + + guard validEnd > 0 else { return "" } + return String(decoding: bytes[.. Int { + switch lead { + case 0xC2...0xDF: + return 2 + case 0xE0...0xEF: + return 3 + case 0xF0...0xF4: + return 4 + default: + throw UTF8ChunkAssemblerError.invalidEncoding + } + } + + private static func validateAvailableScalarPrefix( + _ bytes: [UInt8], + start: Int, + availableLength: Int + ) throws { + guard availableLength > 1 else { return } + try validateSecondByte(bytes[start + 1], lead: bytes[start]) + if availableLength > 2 { + for offset in 2.. 2 { + for offset in 2.. Bool { + (0x80...0xBF).contains(byte) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkAssemblerError.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkAssemblerError.swift new file mode 100644 index 000000000000..82ceb80b89d1 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkAssemblerError.swift @@ -0,0 +1,4 @@ +/// A byte stream cannot be assembled into valid UTF-8 text. +enum UTF8ChunkAssemblerError: Error, Equatable { + case invalidEncoding +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkDecoder.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkDecoder.swift new file mode 100644 index 000000000000..e9e6672d77da --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkDecoder.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Owns one UTF-8 assembler while an artifact stream crosses actor boundaries. +actor UTF8ChunkDecoder { + private var assembler = UTF8ChunkAssembler() + + /// Decodes the next raw chunk without moving byte scanning onto the main actor. + func decode(_ data: Data, eof: Bool) throws -> String { + try assembler.append(data, eof: eof) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index 441c518e0f12..46348e9cb56d 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -324,7 +324,7 @@ } } }, - "chat.artifact.too_large.message" : { + "chat.artifact.too_large.limit_message" : { "extractionState" : "manual", "localizations" : { "en" : { @@ -341,6 +341,23 @@ } } }, + "chat.artifact.too_large.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This file is %@; previews are limited to %@." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このファイルのサイズは%@です。プレビューの上限は%@です。" + } + } + } + }, "chat.artifact.too_large.title" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift new file mode 100644 index 000000000000..22e1af6f0a4b --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift @@ -0,0 +1,156 @@ +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite +struct ChatArtifactViewerModelTests { + @Test + @MainActor + func exposesFirstChunkBeforeEOFAndCompletesProgress() async throws { + let firstData = Data("first 漢".utf8) + let lastData = Data("🙂 last".utf8) + let totalSize = Int64(firstData.count + lastData.count) + let stream = ControlledArtifactStream(chunks: [ + ChatArtifactChunk( + data: firstData, + offset: 0, + totalSize: totalSize, + eof: false + ), + ChatArtifactChunk( + data: lastData, + offset: Int64(firstData.count), + totalSize: totalSize, + eof: true + ), + ]) + let loader = Self.loader(totalSize: totalSize) { _, onChunk in + try await stream.fetch(onChunk: onChunk) + } + let model = ChatArtifactViewerModel() + + let loadTask = Task { + await model.load(path: "/tmp/progressive.txt", loader: loader) + } + await stream.waitUntilFirstChunkDelivered() + + #expect(model.state == .text) + #expect(model.renderedText == "first 漢") + #expect(!model.textReachedEOF) + #expect(model.fetchedBytes == Int64(firstData.count)) + #expect(model.totalBytes == totalSize) + + await stream.resume() + await loadTask.value + + #expect(model.renderedText == "first 漢🙂 last") + #expect(model.textReachedEOF) + #expect(model.fetchedBytes == totalSize) + #expect(model.totalBytes == totalSize) + } + + @Test + @MainActor + func pathChangeCancellationStopsThePreviousStream() async { + let firstPathData = Data("old".utf8) + let staleData = Data(" stale".utf8) + let staleTotalSize = Int64(firstPathData.count + staleData.count) + let blockedStream = ControlledArtifactStream(chunks: [ + ChatArtifactChunk( + data: firstPathData, + offset: 0, + totalSize: staleTotalSize, + eof: false + ), + ChatArtifactChunk( + data: staleData, + offset: Int64(firstPathData.count), + totalSize: staleTotalSize, + eof: true + ), + ]) + let newData = Data("new path".utf8) + let loader = Self.loader(totalSize: Int64(newData.count)) { path, onChunk in + if path == "/tmp/old.txt" { + try await blockedStream.fetch(onChunk: onChunk) + return + } + try await onChunk( + ChatArtifactChunk( + data: newData, + offset: 0, + totalSize: Int64(newData.count), + eof: true + ) + ) + } + let model = ChatArtifactViewerModel() + + let oldTask = Task { + await model.load(path: "/tmp/old.txt", loader: loader) + } + await blockedStream.waitUntilFirstChunkDelivered() + oldTask.cancel() + let newTask = Task { + await model.load(path: "/tmp/new.txt", loader: loader) + } + + await blockedStream.waitUntilCancelled() + await oldTask.value + await newTask.value + + #expect(model.activePath == "/tmp/new.txt") + #expect(model.renderedText == "new path") + #expect(model.textReachedEOF) + } + + @Test + @MainActor + func tooLargeStateRetainsActualFileSize() async { + let limit = ChatArtifactTransferPolicy.defaultPolicy.maxPreviewBytes + let actualSize = limit + 42 + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: actualSize, + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .text, + mimeType: "text/plain" + ) + } + ) + let model = ChatArtifactViewerModel() + + await model.load(path: "/tmp/too-large.txt", loader: loader) + + #expect(model.state == .tooLarge(actualSize: actualSize, limit: limit)) + } + + private static func loader( + totalSize: Int64, + stream: @escaping @Sendable ( + _ path: String, + _ onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws -> Void + ) -> ChatArtifactLoader { + ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: totalSize, + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .text, + mimeType: "text/plain" + ) + }, + stream: stream + ) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ControlledArtifactStream.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ControlledArtifactStream.swift new file mode 100644 index 000000000000..f5abd935b0b1 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ControlledArtifactStream.swift @@ -0,0 +1,78 @@ +import CmuxAgentChat +import Foundation + +/// Suspends after its first chunk so viewer tests can inspect and cancel mid-stream. +actor ControlledArtifactStream { + private let chunks: [ChatArtifactChunk] + private let resumeStream: AsyncStream + private let resumeContinuation: AsyncStream.Continuation + private var firstChunkDelivered = false + private var cancelled = false + private var firstChunkWaiters: [CheckedContinuation] = [] + private var cancellationWaiters: [CheckedContinuation] = [] + + init(chunks: [ChatArtifactChunk]) { + self.chunks = chunks + let pair = AsyncStream.makeStream() + resumeStream = pair.stream + resumeContinuation = pair.continuation + } + + func fetch( + onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws { + defer { + if Task.isCancelled { + markCancelled() + } + } + + for (index, chunk) in chunks.enumerated() { + try await onChunk(chunk) + if index == chunks.startIndex { + markFirstChunkDelivered() + } + if index == chunks.startIndex, chunks.count > 1 { + var iterator = resumeStream.makeAsyncIterator() + _ = await iterator.next() + try Task.checkCancellation() + } + } + } + + func resume() { + resumeContinuation.yield() + } + + func waitUntilFirstChunkDelivered() async { + guard !firstChunkDelivered else { return } + await withCheckedContinuation { continuation in + firstChunkWaiters.append(continuation) + } + } + + func waitUntilCancelled() async { + guard !cancelled else { return } + await withCheckedContinuation { continuation in + cancellationWaiters.append(continuation) + } + } + + private func markFirstChunkDelivered() { + firstChunkDelivered = true + let waiters = firstChunkWaiters + firstChunkWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } + + private func markCancelled() { + cancelled = true + let waiters = cancellationWaiters + cancellationWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/UTF8ChunkAssemblerTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/UTF8ChunkAssemblerTests.swift new file mode 100644 index 000000000000..37fcd588e679 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/UTF8ChunkAssemblerTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite +struct UTF8ChunkAssemblerTests { + @Test + func preservesCJKAndEmojiAcrossEveryByteBoundary() throws { + let expected = "start 漢字🙂終わり end" + let data = Data(expected.utf8) + + for boundary in 1.. Void)?, fetchChunk: @Sendable (_ offset: Int64) async throws -> ChatArtifactChunk, - onChunk: (@Sendable (_ chunk: ChatArtifactChunk) async throws -> Void)? + onChunk: @Sendable (_ chunk: ChatArtifactChunk) async throws -> Void ) async throws -> Data { var offset: Int64 = 0 var result = Data() @@ -32,7 +32,7 @@ struct MobileArtifactChunkFetchLoop: Sendable { } offset = chunk.offset + Int64(chunk.data.count) progress?(offset, chunk.totalSize) - try await onChunk?(chunk) + try await onChunk(chunk) if chunk.eof { return result } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift index 90daa63a22d3..3b2fdeb07b4c 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift @@ -69,7 +69,7 @@ extension MobileChatEventSource { ], collectsData: true, progress: progress, - onChunk: nil + onChunk: { _ in } ) } @@ -84,7 +84,7 @@ extension MobileChatEventSource { workspaceID: String, surfaceID: String, path: String, - onChunk: @escaping @Sendable (ChatArtifactChunk) async throws -> Void + onChunk: @Sendable (ChatArtifactChunk) async throws -> Void ) async throws { _ = try await fetchArtifactChunks( method: "mobile.terminal.artifact.fetch", diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift index f904424906b4..eab3cbc862ce 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift @@ -267,14 +267,14 @@ public actor MobileChatEventSource: ChatEventSource { stringParams: ["session_id": sessionID, "path": path], collectsData: true, progress: progress, - onChunk: nil + onChunk: { _ in } ) } public func artifactFetch( sessionID: String, path: String, - onChunk: @escaping @Sendable (ChatArtifactChunk) async throws -> Void + onChunk: @Sendable (ChatArtifactChunk) async throws -> Void ) async throws { _ = try await fetchArtifactChunks( method: "mobile.chat.artifact.fetch", @@ -365,7 +365,7 @@ public actor MobileChatEventSource: ChatEventSource { stringParams: [String: String], collectsData: Bool, progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)?, - onChunk: (@Sendable (ChatArtifactChunk) async throws -> Void)? + onChunk: @Sendable (ChatArtifactChunk) async throws -> Void ) async throws -> Data { let loop = MobileArtifactChunkFetchLoop() return try await loop.run( @@ -377,7 +377,7 @@ public actor MobileChatEventSource: ChatEventSource { params["length"] = ChatArtifactTransferPolicy.defaultPolicy.maxRawChunkBytes return try await self.artifactCall(method: method, params: params) } onChunk: { chunk in - try await onChunk?(chunk) + try await onChunk(chunk) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 5785ba031eca..125fc628517b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -310,6 +310,14 @@ struct WorkspaceDetailView: View { progress: progress ) }, + stream: { path, onChunk in + try await source.terminalArtifactFetch( + workspaceID: workspaceID, + surfaceID: surfaceID, + path: path, + onChunk: onChunk + ) + }, thumbnail: { path, maxDimension in try await source.terminalArtifactThumbnail( workspaceID: workspaceID, From 233fa7d184b854f71658c958349e177a3b1f9948 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 13:56:17 -0700 Subject: [PATCH 14/92] feat: add artifact text jump controls --- .../Artifacts/ChatArtifactTextView.swift | 33 +++++++++--- .../ChatArtifactTextViewCoordinator.swift | 2 + .../ChatArtifactViewerRouteView.swift | 47 ++++++++++++++++- .../Resources/Localizable.xcstrings | 51 +++++++++++++++++++ 4 files changed, 125 insertions(+), 8 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift index 085fcb04ee3f..88fa88028122 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift @@ -6,6 +6,8 @@ import UIKit struct ChatArtifactTextView: UIViewRepresentable { let documentID: String let chunks: [String] + let topRequestID: Int + let bottomRequestID: Int func makeCoordinator() -> ChatArtifactTextViewCoordinator { ChatArtifactTextViewCoordinator() @@ -39,6 +41,8 @@ struct ChatArtifactTextView: UIViewRepresentable { textView.selectedRange = NSRange(location: 0, length: 0) context.coordinator.documentID = documentID context.coordinator.appliedChunkCount = 0 + context.coordinator.handledTopRequestID = topRequestID + context.coordinator.handledBottomRequestID = bottomRequestID } guard context.coordinator.appliedChunkCount <= chunks.count else { @@ -68,14 +72,29 @@ struct ChatArtifactTextView: UIViewRepresentable { } if isNewDocument { - textView.setContentOffset( - CGPoint( - x: -textView.adjustedContentInset.left, - y: -textView.adjustedContentInset.top - ), - animated: false - ) + Self.scrollToTop(textView) + } else { + if context.coordinator.handledTopRequestID != topRequestID { + context.coordinator.handledTopRequestID = topRequestID + Self.scrollToTop(textView) + } + if context.coordinator.handledBottomRequestID != bottomRequestID { + context.coordinator.handledBottomRequestID = bottomRequestID + textView.scrollRangeToVisible( + NSRange(location: textView.textStorage.length, length: 0) + ) + } } } + + private static func scrollToTop(_ textView: UITextView) { + textView.setContentOffset( + CGPoint( + x: -textView.adjustedContentInset.left, + y: -textView.adjustedContentInset.top + ), + animated: false + ) + } } #endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift index b13a04510e16..e8b2321fd335 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift @@ -5,5 +5,7 @@ import Foundation final class ChatArtifactTextViewCoordinator { var documentID: String? var appliedChunkCount = 0 + var handledTopRequestID = 0 + var handledBottomRequestID = 0 } #endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index f8d518eef6ba..8f6570f85e80 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -16,6 +16,8 @@ struct ChatArtifactViewerRouteView: View { @Environment(\.chatArtifactLoader) private var loader @State private var model = ChatArtifactViewerModel() @State private var retryGeneration = 0 + @State private var topRequestID = 0 + @State private var bottomRequestID = 0 var body: some View { content @@ -29,6 +31,29 @@ struct ChatArtifactViewerRouteView: View { onDone() } } + #if os(iOS) + if model.state == .text { + ToolbarItemGroup(placement: .primaryAction) { + Button { + topRequestID += 1 + } label: { + Label( + String( + localized: "chat.artifact.jump.top", + defaultValue: "Top", + bundle: .module + ), + systemImage: "arrow.up.to.line" + ) + } + Button { + bottomRequestID += 1 + } label: { + Label(jumpToBottomTitle, systemImage: "arrow.down.to.line") + } + } + } + #endif } .task(id: "\(path)\u{0}\(retryGeneration)") { await model.load(path: path, loader: loader) @@ -77,7 +102,12 @@ struct ChatArtifactViewerRouteView: View { streamingProgressHeader } #if canImport(UIKit) - ChatArtifactTextView(documentID: path, chunks: model.textChunks) + ChatArtifactTextView( + documentID: path, + chunks: model.textChunks, + topRequestID: topRequestID, + bottomRequestID: bottomRequestID + ) #else ScrollView { Text(model.renderedText) @@ -205,6 +235,21 @@ struct ChatArtifactViewerRouteView: View { URL(fileURLWithPath: path).lastPathComponent } + private var jumpToBottomTitle: String { + if model.textReachedEOF { + return String( + localized: "chat.artifact.jump.end", + defaultValue: "End", + bundle: .module + ) + } + return String( + localized: "chat.artifact.jump.bottom", + defaultValue: "Bottom", + bundle: .module + ) + } + private var forbiddenMessage: String { switch scope { case .chat: diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index 46348e9cb56d..aabd13736327 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -205,6 +205,57 @@ } } }, + "chat.artifact.jump.bottom" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bottom" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "下端" + } + } + } + }, + "chat.artifact.jump.end" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "End" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "末尾" + } + } + } + }, + "chat.artifact.jump.top" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Top" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "先頭" + } + } + } + }, "chat.artifact.loading" : { "extractionState" : "manual", "localizations" : { From 8e61ed43ba153b8462506649ce5400d7b34abb05 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 14:11:28 -0700 Subject: [PATCH 15/92] fix: correct guard fall-through in artifact text chunk reset The UIKit-gated viewer never compiles in host swift test runs, so the non-exiting guard only surfaced in an iOS-triple build. Co-Authored-By: Claude Fable 5 --- .../CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift index 88fa88028122..2b6828b64ac0 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift @@ -45,7 +45,7 @@ struct ChatArtifactTextView: UIViewRepresentable { context.coordinator.handledBottomRequestID = bottomRequestID } - guard context.coordinator.appliedChunkCount <= chunks.count else { + if context.coordinator.appliedChunkCount > chunks.count { context.coordinator.appliedChunkCount = 0 textView.textStorage.setAttributedString(NSAttributedString()) } From e46ba3a0c50f2587e518690267544d18d87006d8 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 14:11:49 -0700 Subject: [PATCH 16/92] perf: trim gallery directory probes to single syscalls Legacy-client directory filtering now uses one fileExists check per indexed path instead of a full attribute stat, and directory rows count children with an unsorted name listing instead of building the sorted, stat-enriched 500-entry listing just to read its count. Co-Authored-By: Claude Fable 5 --- .../ChatArtifactGalleryBuilder.swift | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift index c7b098843391..f5af965cd852 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift @@ -72,12 +72,26 @@ public struct ChatArtifactGalleryBuilder: Sendable { includeDirectories: Bool ) -> [ChatArtifactIndexedReference] { guard !includeDirectories else { return items } - let reader = ArtifactByteReader() + let fileManager = FileManager.default return items.filter { reference in - (try? reader.stat(path: reference.path).isDirectory) != true + var isDirectory: ObjCBool = false + fileManager.fileExists(atPath: reference.path, isDirectory: &isDirectory) + return !isDirectory.boolValue } } + /// Counts immediate children for a gallery directory row without sorting + /// or per-entry metadata, capped at the shared listing limit. + private func directoryChildCount(path: String) -> (count: Int, isCapped: Bool)? { + guard let names = try? FileManager.default.contentsOfDirectory(atPath: path) else { + return nil + } + return ( + count: min(names.count, ArtifactByteReader.maximumDirectoryEntryCount), + isCapped: names.count > ArtifactByteReader.maximumDirectoryEntryCount + ) + } + private func statItems( _ references: [ChatArtifactIndexedReference] ) -> [ChatArtifactGalleryItem] { @@ -85,7 +99,7 @@ public struct ChatArtifactGalleryBuilder: Sendable { return references.map { reference in do { let stat = try reader.stat(path: reference.path) - let listing = stat.isDirectory ? try? reader.list(path: reference.path) : nil + let children = stat.isDirectory ? directoryChildCount(path: reference.path) : nil return ChatArtifactGalleryItem( path: reference.path, kind: stat.kind, @@ -93,8 +107,8 @@ public struct ChatArtifactGalleryBuilder: Sendable { size: stat.size, modifiedAt: stat.modifiedAt, exists: stat.exists, - childCount: listing?.entries.count, - childCountIsCapped: listing?.isTruncated ?? false, + childCount: children?.count, + childCountIsCapped: children?.isCapped ?? false, provenance: reference.provenance ) } catch { From fd8e938b521cb306cb0d41cceef6ce9dd475b94d Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 14:21:32 -0700 Subject: [PATCH 17/92] feat: dispatch rich artifact previews client-side --- .../Artifacts/ChatArtifactPreviewRoute.swift | 11 +++++ .../Artifacts/ChatArtifactPreviewRouter.swift | 44 +++++++++++++++++ .../ChatArtifactPreviewRouterTests.swift | 47 +++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRoute.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRouter.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPreviewRouterTests.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRoute.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRoute.swift new file mode 100644 index 000000000000..17999175b9ff --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRoute.swift @@ -0,0 +1,11 @@ +/// Client-side viewer routes derived without expanding the artifact wire kind. +enum ChatArtifactPreviewRoute: Equatable, Sendable { + case folder + case image + case pdf + case media + case markdown + case text + case quickLook + case binary +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRouter.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRouter.swift new file mode 100644 index 000000000000..9550b90d0ad3 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRouter.swift @@ -0,0 +1,44 @@ +import CmuxAgentChat +import Foundation +import UniformTypeIdentifiers + +/// Resolves rich client preview routes from wire metadata and the filename. +struct ChatArtifactPreviewRouter: Sendable { + /// Chooses the viewer route while preserving the four-case wire kind. + /// + /// Quick Look candidates still require a local + /// `QLPreviewController.canPreview(_:)` check after download. + func route(stat: ChatArtifactStat, path: String) -> ChatArtifactPreviewRoute { + if stat.isDirectory { + return .folder + } + if stat.kind == .image { + return .image + } + + let fileExtension = URL(fileURLWithPath: path).pathExtension.lowercased() + let mimeType = stat.mimeType? + .split(separator: ";", maxSplits: 1) + .first? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if mimeType == "application/pdf" || fileExtension == "pdf" { + return .pdf + } + + let type = fileExtension.isEmpty ? nil : UTType(filenameExtension: fileExtension) + if let type, type.conforms(to: .movie) || type.conforms(to: .audio) { + return .media + } + if fileExtension == "md" || fileExtension == "markdown" { + return .markdown + } + if stat.kind == .text { + return .text + } + if let type, !type.isDynamic, type.conforms(to: .content) { + return .quickLook + } + return .binary + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPreviewRouterTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPreviewRouterTests.swift new file mode 100644 index 000000000000..d429120fad85 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPreviewRouterTests.swift @@ -0,0 +1,47 @@ +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact preview routing") +struct ChatArtifactPreviewRouterTests { + @Test + func routesMimeAndExtensionMatrix() { + let fixtures: [(String, ChatArtifactKind, String?, Bool, ChatArtifactPreviewRoute)] = [ + ("/tmp/photo.png", .image, "image/png", false, .image), + ("/tmp/image-with-pdf-name.pdf", .image, "application/pdf", false, .image), + ("/tmp/report", .binary, "application/pdf; charset=binary", false, .pdf), + ("/tmp/report.PDF", .binary, "application/octet-stream", false, .pdf), + ("/tmp/movie.mp4", .binary, "video/mp4", false, .media), + ("/tmp/sound.m4a", .binary, "audio/mp4", false, .media), + ("/tmp/README.md", .text, "text/markdown", false, .markdown), + ("/tmp/notes.MARKDOWN", .text, "text/plain", false, .markdown), + ("/tmp/plain.txt", .text, "text/plain", false, .text), + ("/tmp/output", .text, nil, false, .text), + ("/tmp/report.docx", .binary, nil, false, .quickLook), + ("/tmp/sheet.xlsx", .binary, nil, false, .quickLook), + ("/tmp/slides.pptx", .binary, nil, false, .quickLook), + ("/tmp/document.pages", .binary, nil, false, .quickLook), + ("/tmp/deck.key", .binary, nil, false, .quickLook), + ("/tmp/table.numbers", .binary, nil, false, .quickLook), + ("/tmp/archive.zip", .binary, nil, false, .binary), + ("/tmp/blob.bin", .binary, nil, false, .binary), + ("/tmp/folder", .directory, nil, true, .folder), + ] + let router = ChatArtifactPreviewRouter() + + for (path, kind, mimeType, isDirectory, expected) in fixtures { + let stat = ChatArtifactStat( + exists: true, + isDirectory: isDirectory, + size: 12, + modifiedAt: Date(timeIntervalSince1970: 0), + kind: kind, + mimeType: mimeType + ) + + #expect(router.route(stat: stat, path: path) == expected, "Unexpected route for \(path)") + } + } +} From 3dc89ef8fa77144741c1bc7380f8349533168e96 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 14:24:55 -0700 Subject: [PATCH 18/92] feat: preview PDF artifacts with PDFKit --- .../Artifacts/ChatArtifactPDFView.swift | 25 ++++++ .../ChatArtifactTemporaryFileStore.swift | 60 ++++++++++++++ .../ChatArtifactTemporaryFileWriter.swift | 57 ++++++++++++++ .../Artifacts/ChatArtifactViewerModel.swift | 78 +++++++++++++++---- .../ChatArtifactViewerRouteView.swift | 13 ++++ .../Artifacts/ChatArtifactViewerState.swift | 1 + .../ChatArtifactTemporaryFileStoreTests.swift | 50 ++++++++++++ .../ChatArtifactViewerModelTests.swift | 44 +++++++++++ 8 files changed, 311 insertions(+), 17 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPDFView.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPDFView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPDFView.swift new file mode 100644 index 000000000000..e510a8bc3b97 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPDFView.swift @@ -0,0 +1,25 @@ +#if os(iOS) +import PDFKit +import SwiftUI + +/// Hosts PDFKit's zoomable, scrollable document viewer for a local artifact. +struct ChatArtifactPDFView: UIViewRepresentable { + let fileURL: URL + + func makeUIView(context: Context) -> PDFView { + let pdfView = PDFView() + pdfView.autoScales = true + pdfView.displayMode = .singlePageContinuous + pdfView.displayDirection = .vertical + pdfView.document = PDFDocument(url: fileURL) + return pdfView + } + + func updateUIView(_ pdfView: PDFView, context: Context) { + if pdfView.document?.documentURL != fileURL { + pdfView.document = PDFDocument(url: fileURL) + } + pdfView.autoScales = true + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift new file mode 100644 index 000000000000..88653a590f8d --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift @@ -0,0 +1,60 @@ +import CmuxAgentChat +import Foundation + +/// Fetches streamed artifacts into a dedicated purgeable caches directory. +actor ChatArtifactTemporaryFileStore { + private let directory: URL + + init(directory: URL? = nil) { + if let directory { + self.directory = directory + } else { + let cachesDirectory = FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + ).first ?? FileManager.default.temporaryDirectory + self.directory = cachesDirectory.appendingPathComponent( + "cmux-artifact-previews", + isDirectory: true + ) + } + } + + /// Streams a bounded artifact to disk without accumulating its bytes in memory. + func fetch( + path: String, + expectedSize: Int64, + limit: Int64, + fallbackExtension: String? = nil, + loader: ChatArtifactLoader, + progress: @escaping @Sendable (ChatArtifactChunk) async -> Void + ) async throws -> URL { + guard expectedSize <= limit else { + throw ChatArtifactError.tooLarge(limitBytes: limit) + } + let originalExtension = URL(fileURLWithPath: path).pathExtension + let fileExtension = originalExtension.isEmpty + ? (fallbackExtension ?? "") + : originalExtension + let writer = try ChatArtifactTemporaryFileWriter( + directory: directory, + fileExtension: fileExtension + ) + do { + try await loader.stream(path: path) { chunk in + try Task.checkCancellation() + try await writer.append(chunk, limit: limit) + await progress(chunk) + } + try Task.checkCancellation() + return try await writer.finish() + } catch { + await writer.discard() + throw error + } + } + + func remove(_ fileURL: URL) { + try? FileManager.default.removeItem(at: fileURL) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift new file mode 100644 index 000000000000..fdfd764c0c82 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift @@ -0,0 +1,57 @@ +import CmuxAgentChat +import Foundation + +/// Serializes one streamed artifact into an ordered temporary file. +actor ChatArtifactTemporaryFileWriter { + let fileURL: URL + + private var fileHandle: FileHandle? + private var nextOffset: Int64 = 0 + + init(directory: URL, fileExtension: String) throws { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + var fileURL = directory.appendingPathComponent(UUID().uuidString) + if !fileExtension.isEmpty { + fileURL.appendPathExtension(fileExtension) + } + guard FileManager.default.createFile(atPath: fileURL.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown) + } + self.fileURL = fileURL + do { + fileHandle = try FileHandle(forWritingTo: fileURL) + } catch { + try? FileManager.default.removeItem(at: fileURL) + throw error + } + } + + func append(_ chunk: ChatArtifactChunk, limit: Int64) throws { + guard chunk.offset == nextOffset, + chunk.totalSize <= limit, + nextOffset <= limit - Int64(chunk.data.count), + let fileHandle else { + throw ChatArtifactError.macUnreachable + } + try fileHandle.write(contentsOf: chunk.data) + nextOffset += Int64(chunk.data.count) + } + + func finish() throws -> URL { + guard let fileHandle else { + throw ChatArtifactError.macUnreachable + } + try fileHandle.close() + self.fileHandle = nil + return fileURL + } + + func discard() { + try? fileHandle?.close() + fileHandle = nil + try? FileManager.default.removeItem(at: fileURL) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift index 41b145c49e29..b2266829257c 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift @@ -12,12 +12,21 @@ final class ChatArtifactViewerModel { private(set) var totalBytes: Int64? private(set) var textReachedEOF = false private(set) var activePath: String? + private let temporaryFileStore: ChatArtifactTemporaryFileStore + private var temporaryFileURL: URL? + + init( + temporaryFileStore: ChatArtifactTemporaryFileStore = ChatArtifactTemporaryFileStore() + ) { + self.temporaryFileStore = temporaryFileStore + } var renderedText: String { textChunks.joined() } func load(path: String, loader: ChatArtifactLoader) async { + await removeTemporaryFile() reset(for: path) var stat: ChatArtifactStat? do { @@ -25,8 +34,10 @@ final class ChatArtifactViewerModel { try Task.checkCancellation() guard path == activePath else { return } stat = loadedStat + totalBytes = loadedStat.size - guard !loadedStat.isDirectory else { + let route = ChatArtifactPreviewRouter().route(stat: loadedStat, path: path) + guard route != .folder else { state = loadedStat.showsFolder( supportsDirectoryBrowsing: loader.supportsDirectoryBrowsing ) ? .folder : .binary(stat: loadedStat) @@ -39,11 +50,22 @@ final class ChatArtifactViewerModel { return } - switch loadedStat.kind { - case .text: + switch route { + case .text, .markdown: try await streamText(path: path, loader: loader) - case .image, .binary, .directory: - try await loadNonText(path: path, stat: loadedStat, loader: loader) + case .image: + try await loadImage(path: path, loader: loader) + case .pdf: + try await loadPDF( + path: path, + expectedSize: loadedStat.size, + limit: limit, + loader: loader + ) + case .media, .quickLook, .binary: + state = .binary(stat: loadedStat) + case .folder: + break } } catch is CancellationError { return @@ -57,6 +79,10 @@ final class ChatArtifactViewerModel { } } + func cleanup() async { + await removeTemporaryFile() + } + private func reset(for path: String) { activePath = path state = .loading @@ -86,11 +112,7 @@ final class ChatArtifactViewerModel { state = .text } - private func loadNonText( - path: String, - stat: ChatArtifactStat, - loader: ChatArtifactLoader - ) async throws { + private func loadImage(path: String, loader: ChatArtifactLoader) async throws { let accumulator = ChatArtifactDataAccumulator() try await loader.stream(path: path) { chunk in try Task.checkCancellation() @@ -100,14 +122,30 @@ final class ChatArtifactViewerModel { try Task.checkCancellation() guard path == activePath else { return } let data = await accumulator.value() - switch stat.kind { - case .image: - state = .image(data: data) - case .text: - break - case .binary, .directory: - state = .binary(stat: stat) + state = .image(data: data) + } + + private func loadPDF( + path: String, + expectedSize: Int64, + limit: Int64, + loader: ChatArtifactLoader + ) async throws { + let fileURL = try await temporaryFileStore.fetch( + path: path, + expectedSize: expectedSize, + limit: limit, + fallbackExtension: "pdf", + loader: loader + ) { chunk in + await self.receiveNonTextProgress(chunk: chunk, path: path) } + guard path == activePath else { + await temporaryFileStore.remove(fileURL) + return + } + temporaryFileURL = fileURL + state = .pdf(fileURL: fileURL) } private func receiveNonTextProgress(chunk: ChatArtifactChunk, path: String) { @@ -122,6 +160,12 @@ final class ChatArtifactViewerModel { : chunk.offset + Int64(chunk.data.count) } + private func removeTemporaryFile() async { + guard let temporaryFileURL else { return } + self.temporaryFileURL = nil + await temporaryFileStore.remove(temporaryFileURL) + } + private static func state( for error: any Error, stat: ChatArtifactStat? diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index 8f6570f85e80..f0a768939e98 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -58,6 +58,9 @@ struct ChatArtifactViewerRouteView: View { .task(id: "\(path)\u{0}\(retryGeneration)") { await model.load(path: path, loader: loader) } + .onDisappear { + Task { await model.cleanup() } + } } @ViewBuilder @@ -96,6 +99,16 @@ struct ChatArtifactViewerRouteView: View { .scaledToFit() .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() + case .pdf(let fileURL): + #if os(iOS) + ChatArtifactPDFView(fileURL: fileURL) + .ignoresSafeArea(.container, edges: .bottom) + #else + unavailableView( + title: String(localized: "chat.artifact.preview_unavailable.title", defaultValue: "Preview unavailable", bundle: .module), + message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module) + ) + #endif case .text: VStack(spacing: 0) { if !model.textReachedEOF { diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift index 2f7b63942fb3..aabb7f20d2a9 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift @@ -6,6 +6,7 @@ enum ChatArtifactViewerState: Equatable { case loading case folder case image(data: Data) + case pdf(fileURL: URL) case text case binary(stat: ChatArtifactStat) case tooLarge(actualSize: Int64?, limit: Int64) diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift new file mode 100644 index 000000000000..188a1c0e359c --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift @@ -0,0 +1,50 @@ +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact temporary files") +struct ChatArtifactTemporaryFileStoreTests { + @Test("writes streamed chunks to a file with the requested extension") + func writesChunks() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-artifact-temp-test-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let first = Data("PDF".utf8) + let second = Data(" bytes".utf8) + let totalSize = Int64(first.count + second.count) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stream: { _, onChunk in + try await onChunk(ChatArtifactChunk( + data: first, + offset: 0, + totalSize: totalSize, + eof: false + )) + try await onChunk(ChatArtifactChunk( + data: second, + offset: Int64(first.count), + totalSize: totalSize, + eof: true + )) + } + ) + let store = ChatArtifactTemporaryFileStore(directory: directory) + + let fileURL = try await store.fetch( + path: "/remote/report", + expectedSize: totalSize, + limit: 1024, + fallbackExtension: "pdf", + loader: loader, + progress: { _ in } + ) + + #expect(fileURL.pathExtension == "pdf") + #expect(try Data(contentsOf: fileURL) == first + second) + await store.remove(fileURL) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift index 22e1af6f0a4b..de775d1a3d81 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift @@ -131,6 +131,50 @@ struct ChatArtifactViewerModelTests { #expect(model.state == .tooLarge(actualSize: actualSize, limit: limit)) } + @Test + @MainActor + func pdfStreamsToTemporaryFileAndCleansUp() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-artifact-pdf-test-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let data = Data("%PDF-test".utf8) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: Int64(data.count), + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .binary, + mimeType: "application/pdf" + ) + }, + stream: { _, onChunk in + try await onChunk(ChatArtifactChunk( + data: data, + offset: 0, + totalSize: Int64(data.count), + eof: true + )) + } + ) + let model = ChatArtifactViewerModel( + temporaryFileStore: ChatArtifactTemporaryFileStore(directory: directory) + ) + + await model.load(path: "/remote/report", loader: loader) + + guard case .pdf(let fileURL) = model.state else { + Issue.record("PDF metadata should route to the PDF file state") + return + } + #expect(fileURL.pathExtension == "pdf") + #expect(try Data(contentsOf: fileURL) == data) + await model.cleanup() + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + private static func loader( totalSize: Int64, stream: @escaping @Sendable ( From d5febb6bdabb87702e904a79a3723f583c61e8d4 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 14:27:24 -0700 Subject: [PATCH 19/92] feat: stream media artifacts to AVPlayer --- .../ChatArtifactTransferPolicy.swift | 7 +- .../ChatArtifactTransferPolicyTests.swift | 2 + .../Artifacts/ChatArtifactMediaView.swift | 32 ++++++++ .../Artifacts/ChatArtifactViewerModel.swift | 35 ++++++--- .../ChatArtifactViewerRouteView.swift | 10 +++ .../Artifacts/ChatArtifactViewerState.swift | 1 + .../ArtifactStreamCallCounter.swift | 12 +++ .../ChatArtifactTemporaryFileStoreTests.swift | 49 +++++++++++++ .../ChatArtifactViewerModelTests.swift | 73 +++++++++++++++++++ 9 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMediaView.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ArtifactStreamCallCounter.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactTransferPolicy.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactTransferPolicy.swift index 97240b163ed6..95e727a26f52 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactTransferPolicy.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactTransferPolicy.swift @@ -9,6 +9,8 @@ public struct ChatArtifactTransferPolicy: Sendable, Equatable { public let mobileSyncFrameLimitBytes: Int /// Maximum file size the iOS viewer previews inline. public let maxPreviewBytes: Int64 + /// Maximum movie or audio size streamed to an iOS temporary file. + public let maxMediaPreviewBytes: Int64 /// Creates an artifact transfer policy. /// @@ -16,14 +18,17 @@ public struct ChatArtifactTransferPolicy: Sendable, Equatable { /// - maxRawChunkBytes: Maximum raw bytes returned by one fetch chunk. /// - mobileSyncFrameLimitBytes: Maximum mobile-sync frame size. /// - maxPreviewBytes: Maximum inline preview file size. + /// - maxMediaPreviewBytes: Maximum temporary-file media preview size. public init( maxRawChunkBytes: Int = 3 * 1024 * 1024, mobileSyncFrameLimitBytes: Int = 8 * 1024 * 1024, - maxPreviewBytes: Int64 = 64 * 1024 * 1024 + maxPreviewBytes: Int64 = 64 * 1024 * 1024, + maxMediaPreviewBytes: Int64 = 512 * 1024 * 1024 ) { self.maxRawChunkBytes = maxRawChunkBytes self.mobileSyncFrameLimitBytes = mobileSyncFrameLimitBytes self.maxPreviewBytes = maxPreviewBytes + self.maxMediaPreviewBytes = maxMediaPreviewBytes } /// Clamps a requested chunk length to the policy's raw-byte maximum. diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactTransferPolicyTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactTransferPolicyTests.swift index 44bda5f8e6b2..2c9d9e19e712 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactTransferPolicyTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactTransferPolicyTests.swift @@ -8,6 +8,8 @@ struct ChatArtifactTransferPolicyTests { func defaultChunkFitsFrame() { let policy = ChatArtifactTransferPolicy.defaultPolicy #expect(policy.maxRawChunkBytes == 3 * 1024 * 1024) + #expect(policy.maxPreviewBytes == 64 * 1024 * 1024) + #expect(policy.maxMediaPreviewBytes == 512 * 1024 * 1024) #expect(policy.estimatedEnvelopeByteCount(rawByteCount: policy.maxRawChunkBytes) < policy.mobileSyncFrameLimitBytes) #expect(policy.clampedChunkLength(10 * 1024 * 1024) == policy.maxRawChunkBytes) } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMediaView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMediaView.swift new file mode 100644 index 000000000000..b0ad044323e4 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMediaView.swift @@ -0,0 +1,32 @@ +#if os(iOS) +import AVKit +import SwiftUI + +/// Hosts AVKit's system playback controls for a local movie or audio artifact. +struct ChatArtifactMediaView: UIViewControllerRepresentable { + let fileURL: URL + + func makeUIViewController(context: Context) -> AVPlayerViewController { + let controller = AVPlayerViewController() + controller.player = AVPlayer(url: fileURL) + controller.videoGravity = .resizeAspect + return controller + } + + func updateUIViewController(_ controller: AVPlayerViewController, context: Context) { + let currentURL = (controller.player?.currentItem?.asset as? AVURLAsset)?.url + if currentURL != fileURL { + controller.player?.pause() + controller.player = AVPlayer(url: fileURL) + } + } + + static func dismantleUIViewController( + _ controller: AVPlayerViewController, + coordinator: Void + ) { + controller.player?.pause() + controller.player = nil + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift index b2266829257c..4cfdb5bb7bcd 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift @@ -44,7 +44,10 @@ final class ChatArtifactViewerModel { return } - let limit = ChatArtifactTransferPolicy.defaultPolicy.maxPreviewBytes + let policy = ChatArtifactTransferPolicy.defaultPolicy + let limit = route == .media + ? policy.maxMediaPreviewBytes + : policy.maxPreviewBytes guard loadedStat.size <= limit else { state = .tooLarge(actualSize: loadedStat.size, limit: limit) return @@ -56,13 +59,26 @@ final class ChatArtifactViewerModel { case .image: try await loadImage(path: path, loader: loader) case .pdf: - try await loadPDF( + if let fileURL = try await loadTemporaryFile( path: path, expectedSize: loadedStat.size, limit: limit, + fallbackExtension: "pdf", loader: loader - ) - case .media, .quickLook, .binary: + ) { + state = .pdf(fileURL: fileURL) + } + case .media: + if let fileURL = try await loadTemporaryFile( + path: path, + expectedSize: loadedStat.size, + limit: limit, + fallbackExtension: nil, + loader: loader + ) { + state = .media(fileURL: fileURL) + } + case .quickLook, .binary: state = .binary(stat: loadedStat) case .folder: break @@ -125,27 +141,28 @@ final class ChatArtifactViewerModel { state = .image(data: data) } - private func loadPDF( + private func loadTemporaryFile( path: String, expectedSize: Int64, limit: Int64, + fallbackExtension: String?, loader: ChatArtifactLoader - ) async throws { + ) async throws -> URL? { let fileURL = try await temporaryFileStore.fetch( path: path, expectedSize: expectedSize, limit: limit, - fallbackExtension: "pdf", + fallbackExtension: fallbackExtension, loader: loader ) { chunk in await self.receiveNonTextProgress(chunk: chunk, path: path) } guard path == activePath else { await temporaryFileStore.remove(fileURL) - return + return nil } temporaryFileURL = fileURL - state = .pdf(fileURL: fileURL) + return fileURL } private func receiveNonTextProgress(chunk: ChatArtifactChunk, path: String) { diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index f0a768939e98..0c3c18a7a49b 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -109,6 +109,16 @@ struct ChatArtifactViewerRouteView: View { message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module) ) #endif + case .media(let fileURL): + #if os(iOS) + ChatArtifactMediaView(fileURL: fileURL) + .ignoresSafeArea(.container, edges: .bottom) + #else + unavailableView( + title: String(localized: "chat.artifact.preview_unavailable.title", defaultValue: "Preview unavailable", bundle: .module), + message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module) + ) + #endif case .text: VStack(spacing: 0) { if !model.textReachedEOF { diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift index aabb7f20d2a9..90071fdfbde9 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift @@ -7,6 +7,7 @@ enum ChatArtifactViewerState: Equatable { case folder case image(data: Data) case pdf(fileURL: URL) + case media(fileURL: URL) case text case binary(stat: ChatArtifactStat) case tooLarge(actualSize: Int64?, limit: Int64) diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ArtifactStreamCallCounter.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ArtifactStreamCallCounter.swift new file mode 100644 index 000000000000..51dbe8aba0e4 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ArtifactStreamCallCounter.swift @@ -0,0 +1,12 @@ +/// Records whether an artifact stream was invoked without shared test globals. +actor ArtifactStreamCallCounter { + private var count = 0 + + func recordCall() { + count += 1 + } + + func callCount() -> Int { + count + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift index 188a1c0e359c..07ea27dd5f29 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift @@ -47,4 +47,53 @@ struct ChatArtifactTemporaryFileStoreTests { await store.remove(fileURL) #expect(!FileManager.default.fileExists(atPath: fileURL.path)) } + + @Test("cancellation removes a partially written file") + func cancellationRemovesPartialFile() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-artifact-cancel-test-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let first = Data("partial".utf8) + let second = Data(" remainder".utf8) + let totalSize = Int64(first.count + second.count) + let stream = ControlledArtifactStream(chunks: [ + ChatArtifactChunk(data: first, offset: 0, totalSize: totalSize, eof: false), + ChatArtifactChunk( + data: second, + offset: Int64(first.count), + totalSize: totalSize, + eof: true + ), + ]) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stream: { _, onChunk in + try await stream.fetch(onChunk: onChunk) + } + ) + let store = ChatArtifactTemporaryFileStore(directory: directory) + let fetchTask = Task { + try await store.fetch( + path: "/remote/movie.mp4", + expectedSize: totalSize, + limit: 512 * 1024 * 1024, + loader: loader, + progress: { _ in } + ) + } + + await stream.waitUntilFirstChunkDelivered() + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).count == 1) + fetchTask.cancel() + await stream.waitUntilCancelled() + do { + _ = try await fetchTask.value + Issue.record("cancelled fetch should throw") + } catch is CancellationError { + // Expected structured cancellation. + } catch { + Issue.record("unexpected error: \(error)") + } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } } diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift index de775d1a3d81..af9385f1edff 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift @@ -175,6 +175,79 @@ struct ChatArtifactViewerModelTests { #expect(!FileManager.default.fileExists(atPath: fileURL.path)) } + @Test + @MainActor + func mediaUsesMediaCapAndRejectsFromStatBeforeFetch() async { + let limit = ChatArtifactTransferPolicy.defaultPolicy.maxMediaPreviewBytes + let streamCalls = ArtifactStreamCallCounter() + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: limit + 1, + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .binary, + mimeType: "video/mp4" + ) + }, + stream: { _, _ in + await streamCalls.recordCall() + } + ) + let model = ChatArtifactViewerModel() + + await model.load(path: "/remote/movie.mp4", loader: loader) + + #expect(model.state == .tooLarge(actualSize: limit + 1, limit: limit)) + #expect(await streamCalls.callCount() == 0) + } + + @Test + @MainActor + func mediaStreamsToExtensionPreservingFile() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-artifact-media-test-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let data = Data("media bytes".utf8) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: Int64(data.count), + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .binary, + mimeType: "video/mp4" + ) + }, + stream: { _, onChunk in + try await onChunk(ChatArtifactChunk( + data: data, + offset: 0, + totalSize: Int64(data.count), + eof: true + )) + } + ) + let model = ChatArtifactViewerModel( + temporaryFileStore: ChatArtifactTemporaryFileStore(directory: directory) + ) + + await model.load(path: "/remote/movie.mp4", loader: loader) + + guard case .media(let fileURL) = model.state else { + Issue.record("movie metadata should route to the media file state") + return + } + #expect(fileURL.pathExtension == "mp4") + #expect(try Data(contentsOf: fileURL) == data) + await model.cleanup() + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + private static func loader( totalSize: Int64, stream: @escaping @Sendable ( From af3be69bf4d478997d51e9f960e6f9ab6b9dde4f Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 14:29:20 -0700 Subject: [PATCH 20/92] feat: preview document artifacts with Quick Look --- .../ChatArtifactQuickLookCoordinator.swift | 28 ++++++++ .../Artifacts/ChatArtifactQuickLookItem.swift | 15 ++++ .../Artifacts/ChatArtifactQuickLookView.swift | 29 ++++++++ .../Artifacts/ChatArtifactViewerModel.swift | 23 +++++- .../ChatArtifactViewerRouteView.swift | 29 ++++++++ .../Artifacts/ChatArtifactViewerState.swift | 1 + .../ChatArtifactViewerModelTests.swift | 70 +++++++++++++++++++ 7 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookCoordinator.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookItem.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookView.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookCoordinator.swift new file mode 100644 index 000000000000..0996d8da327b --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookCoordinator.swift @@ -0,0 +1,28 @@ +#if os(iOS) +import QuickLook + +/// Retains and supplies the current artifact item to a Quick Look controller. +@MainActor +final class ChatArtifactQuickLookCoordinator: NSObject, QLPreviewControllerDataSource { + private(set) var item: ChatArtifactQuickLookItem + + init(item: ChatArtifactQuickLookItem) { + self.item = item + } + + func update(item: ChatArtifactQuickLookItem) { + self.item = item + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + 1 + } + + func previewController( + _ controller: QLPreviewController, + previewItemAt index: Int + ) -> any QLPreviewItem { + item + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookItem.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookItem.swift new file mode 100644 index 000000000000..1870db2e868a --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookItem.swift @@ -0,0 +1,15 @@ +#if os(iOS) +import Foundation +import QuickLook + +/// Supplies a local artifact URL and original display name to Quick Look. +final class ChatArtifactQuickLookItem: NSObject, QLPreviewItem { + let previewItemURL: URL? + let previewItemTitle: String? + + init(fileURL: URL, title: String?) { + previewItemURL = fileURL + previewItemTitle = title + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookView.swift new file mode 100644 index 000000000000..6da09f7cfb75 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookView.swift @@ -0,0 +1,29 @@ +#if os(iOS) +import QuickLook +import SwiftUI + +/// Hosts the system Quick Look document preview for one local artifact file. +struct ChatArtifactQuickLookView: UIViewControllerRepresentable { + let fileURL: URL + let title: String + + func makeCoordinator() -> ChatArtifactQuickLookCoordinator { + ChatArtifactQuickLookCoordinator( + item: ChatArtifactQuickLookItem(fileURL: fileURL, title: title) + ) + } + + func makeUIViewController(context: Context) -> QLPreviewController { + let controller = QLPreviewController() + controller.dataSource = context.coordinator + return controller + } + + func updateUIViewController(_ controller: QLPreviewController, context: Context) { + context.coordinator.update( + item: ChatArtifactQuickLookItem(fileURL: fileURL, title: title) + ) + controller.reloadData() + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift index 4cfdb5bb7bcd..7aee15ee7992 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift @@ -25,7 +25,11 @@ final class ChatArtifactViewerModel { textChunks.joined() } - func load(path: String, loader: ChatArtifactLoader) async { + func load( + path: String, + loader: ChatArtifactLoader, + quickLookCanPreview: @MainActor (URL) -> Bool = { _ in false } + ) async { await removeTemporaryFile() reset(for: path) var stat: ChatArtifactStat? @@ -78,7 +82,22 @@ final class ChatArtifactViewerModel { ) { state = .media(fileURL: fileURL) } - case .quickLook, .binary: + case .quickLook: + if let fileURL = try await loadTemporaryFile( + path: path, + expectedSize: loadedStat.size, + limit: limit, + fallbackExtension: nil, + loader: loader + ) { + if quickLookCanPreview(fileURL) { + state = .quickLook(fileURL: fileURL) + } else { + await removeTemporaryFile() + state = .binary(stat: loadedStat) + } + } + case .binary: state = .binary(stat: loadedStat) case .folder: break diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index 0c3c18a7a49b..98cdaaee8620 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -3,6 +3,9 @@ import SwiftUI #if canImport(UIKit) import UIKit +#if os(iOS) +import QuickLook +#endif #elseif canImport(AppKit) import AppKit #endif @@ -56,7 +59,15 @@ struct ChatArtifactViewerRouteView: View { #endif } .task(id: "\(path)\u{0}\(retryGeneration)") { + #if os(iOS) + await model.load( + path: path, + loader: loader, + quickLookCanPreview: canQuickLookPreview + ) + #else await model.load(path: path, loader: loader) + #endif } .onDisappear { Task { await model.cleanup() } @@ -119,6 +130,16 @@ struct ChatArtifactViewerRouteView: View { message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module) ) #endif + case .quickLook(let fileURL): + #if os(iOS) + ChatArtifactQuickLookView(fileURL: fileURL, title: displayName) + .ignoresSafeArea(.container, edges: .bottom) + #else + unavailableView( + title: String(localized: "chat.artifact.preview_unavailable.title", defaultValue: "Preview unavailable", bundle: .module), + message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module) + ) + #endif case .text: VStack(spacing: 0) { if !model.textReachedEOF { @@ -258,6 +279,14 @@ struct ChatArtifactViewerRouteView: View { URL(fileURLWithPath: path).lastPathComponent } + #if os(iOS) + private func canQuickLookPreview(_ fileURL: URL) -> Bool { + QLPreviewController.canPreview( + ChatArtifactQuickLookItem(fileURL: fileURL, title: displayName) + ) + } + #endif + private var jumpToBottomTitle: String { if model.textReachedEOF { return String( diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift index 90071fdfbde9..c02ba335cff6 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift @@ -8,6 +8,7 @@ enum ChatArtifactViewerState: Equatable { case image(data: Data) case pdf(fileURL: URL) case media(fileURL: URL) + case quickLook(fileURL: URL) case text case binary(stat: ChatArtifactStat) case tooLarge(actualSize: Int64?, limit: Int64) diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift index af9385f1edff..e5380e78cd8e 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift @@ -248,6 +248,52 @@ struct ChatArtifactViewerModelTests { #expect(!FileManager.default.fileExists(atPath: fileURL.path)) } + @Test + @MainActor + func quickLookRequiresCapabilityAcceptance() async throws { + let acceptedDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-artifact-ql-accepted-\(UUID().uuidString)", isDirectory: true) + let rejectedDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-artifact-ql-rejected-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: acceptedDirectory) + try? FileManager.default.removeItem(at: rejectedDirectory) + } + let data = Data("document bytes".utf8) + let loader = Self.quickLookLoader(data: data) + let acceptedModel = ChatArtifactViewerModel( + temporaryFileStore: ChatArtifactTemporaryFileStore(directory: acceptedDirectory) + ) + let rejectedModel = ChatArtifactViewerModel( + temporaryFileStore: ChatArtifactTemporaryFileStore(directory: rejectedDirectory) + ) + + await acceptedModel.load( + path: "/remote/report.docx", + loader: loader, + quickLookCanPreview: { _ in true } + ) + await rejectedModel.load( + path: "/remote/report.docx", + loader: loader, + quickLookCanPreview: { _ in false } + ) + + guard case .quickLook(let acceptedURL) = acceptedModel.state else { + Issue.record("accepted document should route to Quick Look") + return + } + #expect(acceptedURL.pathExtension == "docx") + #expect(try Data(contentsOf: acceptedURL) == data) + guard case .binary = rejectedModel.state else { + Issue.record("rejected document should retain the binary state") + return + } + #expect(try FileManager.default.contentsOfDirectory(atPath: rejectedDirectory.path).isEmpty) + await acceptedModel.cleanup() + #expect(!FileManager.default.fileExists(atPath: acceptedURL.path)) + } + private static func loader( totalSize: Int64, stream: @escaping @Sendable ( @@ -270,4 +316,28 @@ struct ChatArtifactViewerModelTests { stream: stream ) } + + private static func quickLookLoader(data: Data) -> ChatArtifactLoader { + ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: Int64(data.count), + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .binary, + mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + }, + stream: { _, onChunk in + try await onChunk(ChatArtifactChunk( + data: data, + offset: 0, + totalSize: Int64(data.count), + eof: true + )) + } + ) + } } From 59f91a2cd3fdb7bc1b03445af9ec0fc4361703e5 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 14:32:28 -0700 Subject: [PATCH 21/92] feat: toggle raw and rendered Markdown previews --- .../Artifacts/ChatArtifactMarkdownMode.swift | 5 ++ .../ChatArtifactMarkdownPresentation.swift | 20 +++++ .../Artifacts/ChatArtifactMarkdownView.swift | 22 +++++ .../Artifacts/ChatArtifactViewerModel.swift | 37 ++++++-- .../ChatArtifactViewerRouteView.swift | 87 +++++++++++++++---- .../Artifacts/ChatArtifactViewerState.swift | 1 + .../Resources/Localizable.xcstrings | 51 +++++++++++ ...hatArtifactMarkdownPresentationTests.swift | 28 ++++++ .../ChatArtifactViewerModelTests.swift | 35 ++++++++ 9 files changed, 264 insertions(+), 22 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownMode.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownPresentation.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownView.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactMarkdownPresentationTests.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownMode.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownMode.swift new file mode 100644 index 000000000000..4604f11b5c1e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownMode.swift @@ -0,0 +1,5 @@ +/// User-selectable Markdown presentation modes. +enum ChatArtifactMarkdownMode: Hashable, Sendable { + case raw + case rendered +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownPresentation.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownPresentation.swift new file mode 100644 index 000000000000..98b38459ae1e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownPresentation.swift @@ -0,0 +1,20 @@ +/// Enforces the rendered-Markdown size threshold and selected mode. +struct ChatArtifactMarkdownPresentation: Equatable, Sendable { + static let maximumRenderedByteCount: Int64 = 1_500_000 + + let isRenderedAvailable: Bool + private(set) var mode: ChatArtifactMarkdownMode + + init(byteCount: Int64) { + isRenderedAvailable = byteCount <= Self.maximumRenderedByteCount + mode = isRenderedAvailable ? .rendered : .raw + } + + mutating func select(_ requestedMode: ChatArtifactMarkdownMode) { + if requestedMode == .rendered, !isRenderedAvailable { + mode = .raw + } else { + mode = requestedMode + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownView.swift new file mode 100644 index 000000000000..671dbcf0bc38 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownView.swift @@ -0,0 +1,22 @@ +import Foundation +import SwiftUI + +/// Renders document-level Markdown with Foundation's native syntax support. +struct ChatArtifactMarkdownView: View { + let markdown: String + + var body: some View { + ScrollView { + Text(renderedMarkdown) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + } + + private var renderedMarkdown: AttributedString { + let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .full) + return (try? AttributedString(markdown: markdown, options: options)) + ?? AttributedString(markdown) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift index 7aee15ee7992..53e5d8dd35c0 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift @@ -12,6 +12,7 @@ final class ChatArtifactViewerModel { private(set) var totalBytes: Int64? private(set) var textReachedEOF = false private(set) var activePath: String? + private(set) var markdownPresentation = ChatArtifactMarkdownPresentation(byteCount: 0) private let temporaryFileStore: ChatArtifactTemporaryFileStore private var temporaryFileURL: URL? @@ -58,8 +59,13 @@ final class ChatArtifactViewerModel { } switch route { - case .text, .markdown: - try await streamText(path: path, loader: loader) + case .text: + try await streamText(path: path, isMarkdown: false, loader: loader) + case .markdown: + markdownPresentation = ChatArtifactMarkdownPresentation( + byteCount: loadedStat.size + ) + try await streamText(path: path, isMarkdown: true, loader: loader) case .image: try await loadImage(path: path, loader: loader) case .pdf: @@ -118,6 +124,10 @@ final class ChatArtifactViewerModel { await removeTemporaryFile() } + func selectMarkdownMode(_ mode: ChatArtifactMarkdownMode) { + markdownPresentation.select(mode) + } + private func reset(for path: String) { activePath = path state = .loading @@ -125,26 +135,41 @@ final class ChatArtifactViewerModel { fetchedBytes = 0 totalBytes = nil textReachedEOF = false + markdownPresentation = ChatArtifactMarkdownPresentation(byteCount: 0) } - private func streamText(path: String, loader: ChatArtifactLoader) async throws { + private func streamText( + path: String, + isMarkdown: Bool, + loader: ChatArtifactLoader + ) async throws { let decoder = UTF8ChunkDecoder() try await loader.stream(path: path) { chunk in try Task.checkCancellation() let decoded = try await decoder.decode(chunk.data, eof: chunk.eof) try Task.checkCancellation() - await self.receiveText(decoded, chunk: chunk, path: path) + await self.receiveText( + decoded, + chunk: chunk, + path: path, + isMarkdown: isMarkdown + ) } } - private func receiveText(_ text: String, chunk: ChatArtifactChunk, path: String) { + private func receiveText( + _ text: String, + chunk: ChatArtifactChunk, + path: String, + isMarkdown: Bool + ) { guard path == activePath else { return } if !text.isEmpty { textChunks.append(text) } updateProgress(for: chunk) textReachedEOF = chunk.eof - state = .text + state = isMarkdown ? .markdown : .text } private func loadImage(path: String, loader: ChatArtifactLoader) async throws { diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index 98cdaaee8620..21edf0ffe80a 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -35,7 +35,7 @@ struct ChatArtifactViewerRouteView: View { } } #if os(iOS) - if model.state == .text { + if shouldShowTextJumpControls { ToolbarItemGroup(placement: .primaryAction) { Button { topRequestID += 1 @@ -56,6 +56,33 @@ struct ChatArtifactViewerRouteView: View { } } } + if model.state == .markdown, + model.markdownPresentation.isRenderedAvailable { + ToolbarItem(placement: .primaryAction) { + Picker( + String( + localized: "chat.artifact.markdown.view", + defaultValue: "Markdown view", + bundle: .module + ), + selection: markdownModeBinding + ) { + Text(String( + localized: "chat.artifact.markdown.raw", + defaultValue: "Raw", + bundle: .module + )) + .tag(ChatArtifactMarkdownMode.raw) + Text(String( + localized: "chat.artifact.markdown.rendered", + defaultValue: "Rendered", + bundle: .module + )) + .tag(ChatArtifactMarkdownMode.rendered) + } + .pickerStyle(.segmented) + } + } #endif } .task(id: "\(path)\u{0}\(retryGeneration)") { @@ -145,22 +172,18 @@ struct ChatArtifactViewerRouteView: View { if !model.textReachedEOF { streamingProgressHeader } - #if canImport(UIKit) - ChatArtifactTextView( - documentID: path, - chunks: model.textChunks, - topRequestID: topRequestID, - bottomRequestID: bottomRequestID - ) - #else - ScrollView { - Text(model.renderedText) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() + rawTextView + } + case .markdown: + VStack(spacing: 0) { + if !model.textReachedEOF { + streamingProgressHeader + } + if model.markdownPresentation.mode == .rendered { + ChatArtifactMarkdownView(markdown: model.renderedText) + } else { + rawTextView } - #endif } case .binary(let stat): unavailableView( @@ -219,6 +242,26 @@ struct ChatArtifactViewerRouteView: View { .background(.bar) } + @ViewBuilder + private var rawTextView: some View { + #if canImport(UIKit) + ChatArtifactTextView( + documentID: path, + chunks: model.textChunks, + topRequestID: topRequestID, + bottomRequestID: bottomRequestID + ) + #else + ScrollView { + Text(model.renderedText) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + #endif + } + private func unavailableView( title: String, message: String, @@ -279,6 +322,18 @@ struct ChatArtifactViewerRouteView: View { URL(fileURLWithPath: path).lastPathComponent } + private var markdownModeBinding: Binding { + Binding( + get: { model.markdownPresentation.mode }, + set: { model.selectMarkdownMode($0) } + ) + } + + private var shouldShowTextJumpControls: Bool { + model.state == .text + || (model.state == .markdown && model.markdownPresentation.mode == .raw) + } + #if os(iOS) private func canQuickLookPreview(_ fileURL: URL) -> Bool { QLPreviewController.canPreview( diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift index c02ba335cff6..bdf1b2767f5d 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift @@ -10,6 +10,7 @@ enum ChatArtifactViewerState: Equatable { case media(fileURL: URL) case quickLook(fileURL: URL) case text + case markdown case binary(stat: ChatArtifactStat) case tooLarge(actualSize: Int64?, limit: Int64) case unsupportedMedia diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index aabd13736327..efdcf46b22b3 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -273,6 +273,57 @@ } } }, + "chat.artifact.markdown.raw" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raw" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ソース" + } + } + } + }, + "chat.artifact.markdown.rendered" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rendered" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プレビュー" + } + } + } + }, + "chat.artifact.markdown.view" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Markdown view" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Markdown表示" + } + } + } + }, "chat.artifact.open_item" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactMarkdownPresentationTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactMarkdownPresentationTests.swift new file mode 100644 index 000000000000..4df61116dc78 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactMarkdownPresentationTests.swift @@ -0,0 +1,28 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact Markdown presentation") +struct ChatArtifactMarkdownPresentationTests { + @Test("small files toggle between rendered and raw") + func togglesSmallFile() { + var presentation = ChatArtifactMarkdownPresentation(byteCount: 1_500_000) + + #expect(presentation.isRenderedAvailable) + #expect(presentation.mode == .rendered) + presentation.select(.raw) + #expect(presentation.mode == .raw) + presentation.select(.rendered) + #expect(presentation.mode == .rendered) + } + + @Test("oversize files remain raw") + func oversizeFallsBackToRaw() { + var presentation = ChatArtifactMarkdownPresentation(byteCount: 1_500_001) + + #expect(!presentation.isRenderedAvailable) + #expect(presentation.mode == .raw) + presentation.select(.rendered) + #expect(presentation.mode == .raw) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift index e5380e78cd8e..864c3c3cc409 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift @@ -294,6 +294,41 @@ struct ChatArtifactViewerModelTests { #expect(!FileManager.default.fileExists(atPath: acceptedURL.path)) } + @Test + @MainActor + func markdownStreamsAsTextAndAppliesRawOnlyThreshold() async { + let markdown = Data("# Heading\n\nBody".utf8) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: ChatArtifactMarkdownPresentation.maximumRenderedByteCount + 1, + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .text, + mimeType: "text/markdown" + ) + }, + stream: { _, onChunk in + try await onChunk(ChatArtifactChunk( + data: markdown, + offset: 0, + totalSize: Int64(markdown.count), + eof: true + )) + } + ) + let model = ChatArtifactViewerModel() + + await model.load(path: "/remote/README.md", loader: loader) + + #expect(model.state == .markdown) + #expect(model.renderedText == "# Heading\n\nBody") + #expect(model.markdownPresentation.mode == .raw) + #expect(!model.markdownPresentation.isRenderedAvailable) + } + private static func loader( totalSize: Int64, stream: @escaping @Sendable ( From 1683ae2ffb452e459d0f558e20d25a0748a260c9 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 14:33:44 -0700 Subject: [PATCH 22/92] perf: stop directory child counting at the listing cap Use a lazy shallow enumerator that exits after cap+1 entries so a gallery row over a huge folder costs at most 501 reads instead of materializing every child name. Co-Authored-By: Claude Fable 5 --- .../ChatArtifactGalleryBuilder.swift | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift index f5af965cd852..6190ce81f0b3 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift @@ -81,15 +81,24 @@ public struct ChatArtifactGalleryBuilder: Sendable { } /// Counts immediate children for a gallery directory row without sorting - /// or per-entry metadata, capped at the shared listing limit. + /// or per-entry metadata, stopping at the shared listing limit so the cost + /// never scales past the cap for large folders. private func directoryChildCount(path: String) -> (count: Int, isCapped: Bool)? { - guard let names = try? FileManager.default.contentsOfDirectory(atPath: path) else { + guard let enumerator = FileManager.default.enumerator( + at: URL(fileURLWithPath: path, isDirectory: true), + includingPropertiesForKeys: [], + options: [.skipsSubdirectoryDescendants] + ) else { return nil } - return ( - count: min(names.count, ArtifactByteReader.maximumDirectoryEntryCount), - isCapped: names.count > ArtifactByteReader.maximumDirectoryEntryCount - ) + var count = 0 + while enumerator.nextObject() != nil { + count += 1 + if count > ArtifactByteReader.maximumDirectoryEntryCount { + return (count: ArtifactByteReader.maximumDirectoryEntryCount, isCapped: true) + } + } + return (count: count, isCapped: false) } private func statItems( From 72e4a1d826cc1224c0cf1f57b803651dca0c334e Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 14:34:50 -0700 Subject: [PATCH 23/92] feat: sniff extensionless artifacts for UTF-8 text --- .../Artifacts/ArtifactByteReader.swift | 83 ++++++++++++++++++- .../ArtifactByteReaderTests.swift | 64 +++++++++++++- 2 files changed, 143 insertions(+), 4 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift index ea701a7bcb3c..b2fe02992e75 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift @@ -9,6 +9,7 @@ import UniformTypeIdentifiers public struct ArtifactByteReader: Sendable { /// Maximum immediate children returned by one directory-list request. public static let maximumDirectoryEntryCount = 500 + private static let utf8SniffByteCount = 8 * 1024 /// Filesystem/decoder failures surfaced by artifact RPC handlers. public enum Error: Swift.Error, Sendable { @@ -131,11 +132,13 @@ public struct ArtifactByteReader: Sendable { ) } - /// Infers preview category from a path extension and directory flag. + /// Infers preview category from a path extension and a bounded UTF-8 sniff. public func kind(path: String, isDirectory: Bool) -> ChatArtifactKind { if isDirectory { return .directory } - guard let type = UTType(filenameExtension: URL(fileURLWithPath: path).pathExtension) else { - return .binary + let fileExtension = URL(fileURLWithPath: path).pathExtension + let type = fileExtension.isEmpty ? nil : UTType(filenameExtension: fileExtension) + guard let type, !type.isDynamic else { + return isUTF8Text(path: path) ? .text : .binary } if type.conforms(to: .image) { return .image } if type.conforms(to: .text) || type.conforms(to: .sourceCode) || type.conforms(to: .json) { @@ -144,6 +147,80 @@ public struct ArtifactByteReader: Sendable { return .binary } + private func isUTF8Text(path: String) -> Bool { + guard let handle = FileHandle(forReadingAtPath: path) else { + return false + } + defer { try? handle.close() } + let bytes: Data + do { + bytes = try handle.read(upToCount: Self.utf8SniffByteCount + 1) ?? Data() + } catch { + return false + } + let sample = Data(bytes.prefix(Self.utf8SniffByteCount)) + if String(data: sample, encoding: .utf8) != nil { + return true + } + guard bytes.count > Self.utf8SniffByteCount else { + return false + } + return hasValidUTF8PrefixEndingInPartialScalar(sample) + } + + private func hasValidUTF8PrefixEndingInPartialScalar(_ data: Data) -> Bool { + let bytes = Array(data) + guard !bytes.isEmpty else { return false } + let earliestCandidate = max(0, bytes.count - 4) + for start in stride(from: bytes.count - 1, through: earliestCandidate, by: -1) { + guard let expectedLength = utf8ScalarLength(leadingByte: bytes[start]) else { + continue + } + let actualLength = bytes.count - start + guard actualLength < expectedLength, + utf8PartialScalarBytesAreValid(Array(bytes[start...])) else { + continue + } + let prefix = Data(bytes[.. Int? { + switch leadingByte { + case 0xC2...0xDF: + return 2 + case 0xE0...0xEF: + return 3 + case 0xF0...0xF4: + return 4 + default: + return nil + } + } + + private func utf8PartialScalarBytesAreValid(_ bytes: [UInt8]) -> Bool { + guard let leadingByte = bytes.first else { return false } + for byte in bytes.dropFirst() where byte & 0xC0 != 0x80 { + return false + } + guard bytes.count > 1 else { return true } + let firstContinuation = bytes[1] + switch leadingByte { + case 0xE0: + return firstContinuation >= 0xA0 + case 0xED: + return firstContinuation <= 0x9F + case 0xF0: + return firstContinuation >= 0x90 + case 0xF4: + return firstContinuation <= 0x8F + default: + return true + } + } + private func attributes(path: String) throws -> [FileAttributeKey: Any] { do { return try FileManager.default.attributesOfItem(atPath: path) diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift index 60de78342a8d..1ba6947986f6 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift @@ -3,7 +3,7 @@ import Testing @testable import CmuxAgentChat -@Suite("ArtifactByteReader directory listing") +@Suite("ArtifactByteReader") struct ArtifactByteReaderTests { @Test("directory listings cap at 500 and report truncation") func listCap() throws { @@ -39,6 +39,68 @@ struct ArtifactByteReaderTests { } } + @Test("extensionless UTF-8 text is classified as text") + func extensionlessUTF8Text() throws { + try withTemporaryDirectory { directory in + let file = directory.appendingPathComponent("output") + try Data("hello, 漢字 and 🙂".utf8).write(to: file) + + #expect(ArtifactByteReader().kind(path: file.path, isDirectory: false) == .text) + } + } + + @Test("unknown-extension UTF-8 text is classified as text") + func unknownExtensionUTF8Text() throws { + try withTemporaryDirectory { directory in + let file = directory.appendingPathComponent("output.cmux-unknown-text-kind") + try Data("plain text".utf8).write(to: file) + + #expect(ArtifactByteReader().kind(path: file.path, isDirectory: false) == .text) + } + } + + @Test("binary junk remains binary") + func binaryJunk() throws { + try withTemporaryDirectory { directory in + let file = directory.appendingPathComponent("output") + try Data([0x00, 0xFF, 0xFE, 0x80]).write(to: file) + + #expect(ArtifactByteReader().kind(path: file.path, isDirectory: false) == .binary) + } + } + + @Test("empty extensionless files are valid UTF-8 text") + func emptyFile() throws { + try withTemporaryDirectory { directory in + let file = directory.appendingPathComponent("output") + try Data().write(to: file) + + #expect(ArtifactByteReader().kind(path: file.path, isDirectory: false) == .text) + } + } + + @Test("files smaller than the sniff budget are classified from all bytes") + func smallerThanSniffBudget() throws { + try withTemporaryDirectory { directory in + let file = directory.appendingPathComponent("output") + try Data(repeating: 0x61, count: 8 * 1024 - 1).write(to: file) + + #expect(ArtifactByteReader().kind(path: file.path, isDirectory: false) == .text) + } + } + + @Test("a multibyte scalar split at the 8 KiB edge is accepted") + func multibyteScalarSplitAtSniffEdge() throws { + try withTemporaryDirectory { directory in + let file = directory.appendingPathComponent("output") + var bytes = Data(repeating: 0x61, count: 8 * 1024 - 1) + bytes.append(contentsOf: "🙂".utf8) + try bytes.write(to: file) + + #expect(ArtifactByteReader().kind(path: file.path, isDirectory: false) == .text) + } + } + private func withTemporaryDirectory( _ operation: (URL) throws -> Void ) throws { From c28b9bc4c19cdf3a79aa495beee4da1f600822ef Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 15:19:53 -0700 Subject: [PATCH 24/92] fix: route MIME-only media artifacts to the player The router already trusted wire MIME for extensionless PDFs but not for audio/video, so a MIME-typed recording without a known extension fell through to the binary placeholder. Route media by extension UTType or MIME UTType, matching the PDF check. Co-Authored-By: Claude Fable 5 --- .../Artifacts/ChatArtifactPreviewRouter.swift | 7 ++++++- .../ChatArtifactPreviewRouterTests.swift | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRouter.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRouter.swift index 9550b90d0ad3..f04937e1a17f 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRouter.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRouter.swift @@ -27,7 +27,7 @@ struct ChatArtifactPreviewRouter: Sendable { } let type = fileExtension.isEmpty ? nil : UTType(filenameExtension: fileExtension) - if let type, type.conforms(to: .movie) || type.conforms(to: .audio) { + if isMedia(type) || isMedia(mimeType.flatMap { UTType(mimeType: $0) }) { return .media } if fileExtension == "md" || fileExtension == "markdown" { @@ -41,4 +41,9 @@ struct ChatArtifactPreviewRouter: Sendable { } return .binary } + + private func isMedia(_ type: UTType?) -> Bool { + guard let type else { return false } + return type.conforms(to: .movie) || type.conforms(to: .audio) + } } diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPreviewRouterTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPreviewRouterTests.swift index d429120fad85..6292bb595da3 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPreviewRouterTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPreviewRouterTests.swift @@ -15,6 +15,8 @@ struct ChatArtifactPreviewRouterTests { ("/tmp/report.PDF", .binary, "application/octet-stream", false, .pdf), ("/tmp/movie.mp4", .binary, "video/mp4", false, .media), ("/tmp/sound.m4a", .binary, "audio/mp4", false, .media), + ("/tmp/recording", .binary, "video/mp4", false, .media), + ("/tmp/voice-note", .binary, "audio/mp4; charset=binary", false, .media), ("/tmp/README.md", .text, "text/markdown", false, .markdown), ("/tmp/notes.MARKDOWN", .text, "text/plain", false, .markdown), ("/tmp/plain.txt", .text, "text/plain", false, .text), From b9e6c53d1fed75a23dfeba0ed8bbf4948df97641 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 14:56:19 -0700 Subject: [PATCH 25/92] feat(ios): highlight streamed artifact text --- Packages/iOS/CmuxAgentChatUI/Package.resolved | 15 +++ Packages/iOS/CmuxAgentChatUI/Package.swift | 5 + .../ChatArtifactHighlightDecision.swift | 11 ++ .../ChatArtifactHighlightTheme.swift | 5 + .../ChatArtifactHighlightedText.swift | 13 ++ .../ChatArtifactHighlightingStatusPill.swift | 58 +++++++++ .../ChatArtifactSyntaxHighlightPolicy.swift | 81 +++++++++++++ .../ChatArtifactSyntaxHighlighter.swift | 30 +++++ .../Artifacts/ChatArtifactTextView.swift | 13 ++ .../ChatArtifactTextViewCoordinator.swift | 114 ++++++++++++++++++ .../Artifacts/ChatArtifactViewerModel.swift | 6 + .../ChatArtifactViewerRouteView.swift | 22 ++++ .../Resources/Localizable.xcstrings | 34 ++++++ ...atArtifactSyntaxHighlightPolicyTests.swift | 44 +++++++ .../iOS/CmuxMobileShellUI/Package.resolved | 11 +- ios/cmuxPackage/Package.resolved | 9 ++ 16 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Package.resolved create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightDecision.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightTheme.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightedText.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightingStatusPill.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlightPolicy.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlighter.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactSyntaxHighlightPolicyTests.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Package.resolved b/Packages/iOS/CmuxAgentChatUI/Package.resolved new file mode 100644 index 000000000000..d5d0d404b1a7 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "360573bfc5c7f7a8fdde3df27bac8472e0c3938aa9fb7f8bd43b1def104914e8", + "pins" : [ + { + "identity" : "highlightr", + "kind" : "remoteSourceControl", + "location" : "https://github.com/raspu/Highlightr.git", + "state" : { + "revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef", + "version" : "2.3.0" + } + } + ], + "version" : 3 +} diff --git a/Packages/iOS/CmuxAgentChatUI/Package.swift b/Packages/iOS/CmuxAgentChatUI/Package.swift index b9629130545d..99c2672bc7d3 100644 --- a/Packages/iOS/CmuxAgentChatUI/Package.swift +++ b/Packages/iOS/CmuxAgentChatUI/Package.swift @@ -18,6 +18,10 @@ let package = Package( dependencies: [ .package(path: "../../Shared/CmuxAgentChat"), .package(path: "../CmuxMobileSupport"), + .package( + url: "https://github.com/raspu/Highlightr.git", + exact: "2.3.0" + ), ], targets: [ .target( @@ -25,6 +29,7 @@ let package = Package( dependencies: [ "CmuxAgentChat", "CmuxMobileSupport", + .product(name: "Highlightr", package: "Highlightr"), ], resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v6)] diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightDecision.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightDecision.swift new file mode 100644 index 000000000000..b34cfc8ea843 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightDecision.swift @@ -0,0 +1,11 @@ +/// Describes whether and how a loaded text artifact should be syntax highlighted. +enum ChatArtifactHighlightDecision: Equatable, Sendable { + case highlight(language: String?) + case skippedForSize + case skippedNoLanguage + + /// Whether the size-threshold explanation belongs in the viewer chrome. + var showsHighlightingOffPill: Bool { + self == .skippedForSize + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightTheme.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightTheme.swift new file mode 100644 index 000000000000..d9e2838a714c --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightTheme.swift @@ -0,0 +1,5 @@ +/// The syntax color palette selected from the viewer's current appearance. +enum ChatArtifactHighlightTheme: Equatable, Sendable { + case light + case dark +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightedText.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightedText.swift new file mode 100644 index 000000000000..ea8655dff8a6 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightedText.swift @@ -0,0 +1,13 @@ +import Foundation + +/// An immutable attributed-string snapshot produced by the background highlighter. +/// +/// SAFETY: `value` is copied into an immutable `NSAttributedString` before transfer +/// and is only read after the producing actor has returned it. +final class ChatArtifactHighlightedText: @unchecked Sendable { + let value: NSAttributedString + + init(_ value: NSAttributedString) { + self.value = NSAttributedString(attributedString: value) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightingStatusPill.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightingStatusPill.swift new file mode 100644 index 000000000000..d0a7687c4999 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightingStatusPill.swift @@ -0,0 +1,58 @@ +import Foundation +import SwiftUI + +/// A compact, expandable explanation for syntax highlighting disabled by file size. +struct ChatArtifactHighlightingStatusPill: View { + let actualBytes: Int64 + let maximumBytes: Int64 + + @State private var isExpanded = false + + var body: some View { + Button { + isExpanded.toggle() + } label: { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "paintbrush.slash") + .imageScale(.small) + if isExpanded { + Text(explanation) + .font(.caption) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } else { + Text(String( + localized: "chat.artifact.highlighting.off", + defaultValue: "Highlighting off", + bundle: .module + )) + .font(.caption.weight(.semibold)) + } + Image(systemName: isExpanded ? "xmark.circle.fill" : "chevron.right") + .imageScale(.small) + .foregroundStyle(.secondary) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: isExpanded ? 360 : nil, alignment: .leading) + .background(.thinMaterial, in: Capsule()) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .animation(.snappy, value: isExpanded) + } + + private var explanation: String { + let format = String( + localized: "chat.artifact.highlighting.off.explanation", + defaultValue: "This file is %@. Syntax highlighting is off above %@ to keep scrolling smooth.", + bundle: .module + ) + return String.localizedStringWithFormat( + format, + ByteCountFormatter.string(fromByteCount: actualBytes, countStyle: .file), + ByteCountFormatter.string(fromByteCount: maximumBytes, countStyle: .file) + ) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlightPolicy.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlightPolicy.swift new file mode 100644 index 000000000000..6a47b2714ccf --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlightPolicy.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Selects syntax highlighting without spending unbounded work on large artifacts. +struct ChatArtifactSyntaxHighlightPolicy: Sendable { + static let maxHighlightBytes: Int64 = 1_500_000 + static let maxAutomaticDetectionBytes: Int64 = 256_000 + + private static let languagesByExtension: [String: String] = [ + "bash": "bash", + "c": "c", + "cc": "cpp", + "cjs": "javascript", + "clj": "clojure", + "cljs": "clojure", + "cpp": "cpp", + "cs": "csharp", + "css": "css", + "cxx": "cpp", + "dart": "dart", + "ex": "elixir", + "exs": "elixir", + "fs": "fsharp", + "fsx": "fsharp", + "go": "go", + "gradle": "gradle", + "groovy": "groovy", + "h": "c", + "hh": "cpp", + "hpp": "cpp", + "htm": "html", + "html": "html", + "java": "java", + "js": "javascript", + "json": "json", + "jsx": "javascript", + "kt": "kotlin", + "kts": "kotlin", + "less": "less", + "lua": "lua", + "m": "objectivec", + "markdown": "markdown", + "md": "markdown", + "mjs": "javascript", + "mm": "objectivec", + "php": "php", + "pl": "perl", + "pm": "perl", + "py": "python", + "r": "r", + "rb": "ruby", + "rs": "rust", + "sass": "scss", + "scala": "scala", + "scss": "scss", + "sh": "bash", + "sql": "sql", + "swift": "swift", + "ts": "typescript", + "tsx": "typescript", + "xml": "xml", + "yaml": "yaml", + "yml": "yaml", + "zsh": "bash", + ] + + /// Returns the bounded highlighting decision for a path and its real byte size. + func decision(path: String, byteCount: Int64) -> ChatArtifactHighlightDecision { + guard byteCount <= Self.maxHighlightBytes else { + return .skippedForSize + } + + let pathExtension = URL(fileURLWithPath: path).pathExtension.lowercased() + if let language = Self.languagesByExtension[pathExtension] { + return .highlight(language: language) + } + if byteCount < Self.maxAutomaticDetectionBytes { + return .highlight(language: nil) + } + return .skippedNoLanguage + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlighter.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlighter.swift new file mode 100644 index 000000000000..2056973ec97d --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlighter.swift @@ -0,0 +1,30 @@ +import Foundation +@preconcurrency import Highlightr + +/// Serializes JavaScriptCore-backed highlighting away from the main actor. +actor ChatArtifactSyntaxHighlighter { + private var engine: Highlightr? + + /// Highlights a complete artifact using the palette for the current appearance. + func highlight( + text: String, + language: String?, + theme: ChatArtifactHighlightTheme + ) -> ChatArtifactHighlightedText? { + let highlightr: Highlightr + if let engine { + highlightr = engine + } else { + guard let newEngine = Highlightr() else { return nil } + engine = newEngine + highlightr = newEngine + } + + let themeName = theme == .dark ? "xcode-dark" : "xcode" + guard highlightr.setTheme(to: themeName), + let highlighted = highlightr.highlight(text, as: language) else { + return nil + } + return ChatArtifactHighlightedText(highlighted) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift index 2b6828b64ac0..dacf6d7703e3 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift @@ -6,6 +6,9 @@ import UIKit struct ChatArtifactTextView: UIViewRepresentable { let documentID: String let chunks: [String] + let reachedEOF: Bool + let highlightDecision: ChatArtifactHighlightDecision + let highlightTheme: ChatArtifactHighlightTheme let topRequestID: Int let bottomRequestID: Int @@ -37,6 +40,7 @@ struct ChatArtifactTextView: UIViewRepresentable { func updateUIView(_ textView: UITextView, context: Context) { let isNewDocument = context.coordinator.documentID != documentID if isNewDocument { + context.coordinator.resetHighlighting() textView.textStorage.setAttributedString(NSAttributedString()) textView.selectedRange = NSRange(location: 0, length: 0) context.coordinator.documentID = documentID @@ -71,6 +75,15 @@ struct ChatArtifactTextView: UIViewRepresentable { context.coordinator.appliedChunkCount += 1 } + context.coordinator.updateHighlighting( + in: textView, + documentID: documentID, + text: textView.textStorage.string, + reachedEOF: reachedEOF, + decision: highlightDecision, + theme: highlightTheme + ) + if isNewDocument { Self.scrollToTop(textView) } else { diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift index e8b2321fd335..e39e0396c767 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift @@ -1,11 +1,125 @@ #if canImport(UIKit) import Foundation +import UIKit /// Tracks which streamed chunks have been applied to one text-storage instance. +@MainActor final class ChatArtifactTextViewCoordinator { var documentID: String? var appliedChunkCount = 0 var handledTopRequestID = 0 var handledBottomRequestID = 0 + private let syntaxHighlighter = ChatArtifactSyntaxHighlighter() + private var highlightTask: Task? + private var highlightGeneration = 0 + private var highlightedDocumentID: String? + private var highlightedTextLength = 0 + private var highlightedLanguage: String? + private var highlightedTheme: ChatArtifactHighlightTheme? + + func resetHighlighting() { + highlightTask?.cancel() + highlightTask = nil + highlightGeneration += 1 + highlightedDocumentID = nil + highlightedTextLength = 0 + highlightedLanguage = nil + highlightedTheme = nil + } + + func updateHighlighting( + in textView: UITextView, + documentID: String, + text: String, + reachedEOF: Bool, + decision: ChatArtifactHighlightDecision, + theme: ChatArtifactHighlightTheme + ) { + guard reachedEOF, + case .highlight(let language) = decision, + !text.isEmpty else { + highlightTask?.cancel() + highlightTask = nil + return + } + guard highlightedDocumentID != documentID + || highlightedTextLength != text.utf16.count + || highlightedLanguage != language + || highlightedTheme != theme else { + return + } + + highlightTask?.cancel() + highlightGeneration += 1 + let generation = highlightGeneration + let highlighter = syntaxHighlighter + highlightTask = Task { @MainActor [weak self, weak textView] in + let result = await highlighter.highlight( + text: text, + language: language, + theme: theme + ) + guard let self, + !Task.isCancelled, + generation == self.highlightGeneration, + let textView, + let result, + result.value.string == text, + textView.textStorage.string == text else { + return + } + + self.apply(result.value, to: textView) + self.highlightedDocumentID = documentID + self.highlightedTextLength = text.utf16.count + self.highlightedLanguage = language + self.highlightedTheme = theme + self.highlightTask = nil + } + } + + private func apply(_ highlighted: NSAttributedString, to textView: UITextView) { + let contentOffset = textView.contentOffset + let selection = textView.selectedRange + let pointSize = textView.font?.pointSize + ?? UIFont.preferredFont(forTextStyle: .body).pointSize + let fullRange = NSRange(location: 0, length: highlighted.length) + + textView.textStorage.beginEditing() + highlighted.enumerateAttributes(in: fullRange) { attributes, range, _ in + textView.textStorage.setAttributes( + normalized(attributes, pointSize: pointSize), + range: range + ) + } + textView.textStorage.endEditing() + textView.selectedRange = selection + textView.setContentOffset(contentOffset, animated: false) + } + + private func normalized( + _ attributes: [NSAttributedString.Key: Any], + pointSize: CGFloat + ) -> [NSAttributedString.Key: Any] { + var normalized = attributes + normalized.removeValue(forKey: .backgroundColor) + guard let highlightedFont = attributes[.font] as? UIFont else { + normalized[.font] = UIFont.monospacedSystemFont(ofSize: pointSize, weight: .regular) + return normalized + } + + let traits = highlightedFont.fontDescriptor.symbolicTraits + let weight: UIFont.Weight = traits.contains(.traitBold) ? .bold : .regular + let baseFont = UIFont.monospacedSystemFont(ofSize: pointSize, weight: weight) + if traits.contains(.traitItalic), + let descriptor = baseFont.fontDescriptor.withSymbolicTraits( + baseFont.fontDescriptor.symbolicTraits.union(.traitItalic) + ) { + normalized[.font] = UIFont(descriptor: descriptor, size: pointSize) + } else { + normalized[.font] = baseFont + } + return normalized + } } #endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift index 53e5d8dd35c0..2a8ef62be3fc 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift @@ -13,6 +13,7 @@ final class ChatArtifactViewerModel { private(set) var textReachedEOF = false private(set) var activePath: String? private(set) var markdownPresentation = ChatArtifactMarkdownPresentation(byteCount: 0) + private(set) var textHighlightDecision: ChatArtifactHighlightDecision = .skippedNoLanguage private let temporaryFileStore: ChatArtifactTemporaryFileStore private var temporaryFileURL: URL? @@ -40,6 +41,10 @@ final class ChatArtifactViewerModel { guard path == activePath else { return } stat = loadedStat totalBytes = loadedStat.size + textHighlightDecision = ChatArtifactSyntaxHighlightPolicy().decision( + path: path, + byteCount: loadedStat.size + ) let route = ChatArtifactPreviewRouter().route(stat: loadedStat, path: path) guard route != .folder else { @@ -136,6 +141,7 @@ final class ChatArtifactViewerModel { totalBytes = nil textReachedEOF = false markdownPresentation = ChatArtifactMarkdownPresentation(byteCount: 0) + textHighlightDecision = .skippedNoLanguage } private func streamText( diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index 21edf0ffe80a..1cd798f3ecce 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -17,6 +17,7 @@ struct ChatArtifactViewerRouteView: View { let onDone: () -> Void @Environment(\.chatArtifactLoader) private var loader + @Environment(\.colorScheme) private var colorScheme @State private var model = ChatArtifactViewerModel() @State private var retryGeneration = 0 @State private var topRequestID = 0 @@ -172,6 +173,7 @@ struct ChatArtifactViewerRouteView: View { if !model.textReachedEOF { streamingProgressHeader } + highlightingStatusPill rawTextView } case .markdown: @@ -182,6 +184,7 @@ struct ChatArtifactViewerRouteView: View { if model.markdownPresentation.mode == .rendered { ChatArtifactMarkdownView(markdown: model.renderedText) } else { + highlightingStatusPill rawTextView } } @@ -248,6 +251,9 @@ struct ChatArtifactViewerRouteView: View { ChatArtifactTextView( documentID: path, chunks: model.textChunks, + reachedEOF: model.textReachedEOF, + highlightDecision: model.textHighlightDecision, + highlightTheme: colorScheme == .dark ? .dark : .light, topRequestID: topRequestID, bottomRequestID: bottomRequestID ) @@ -262,6 +268,22 @@ struct ChatArtifactViewerRouteView: View { #endif } + @ViewBuilder + private var highlightingStatusPill: some View { + if model.textHighlightDecision.showsHighlightingOffPill, + let totalBytes = model.totalBytes { + HStack { + Spacer(minLength: 16) + ChatArtifactHighlightingStatusPill( + actualBytes: totalBytes, + maximumBytes: ChatArtifactSyntaxHighlightPolicy.maxHighlightBytes + ) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + } + private func unavailableView( title: String, message: String, diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index efdcf46b22b3..bd09c0b47b28 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -205,6 +205,40 @@ } } }, + "chat.artifact.highlighting.off" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Highlighting off" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ハイライトはオフ" + } + } + } + }, + "chat.artifact.highlighting.off.explanation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This file is %@. Syntax highlighting is off above %@ to keep scrolling smooth." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このファイルは%@です。スクロールを滑らかに保つため、%@を超えるファイルでは構文ハイライトがオフになります。" + } + } + } + }, "chat.artifact.jump.bottom" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactSyntaxHighlightPolicyTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactSyntaxHighlightPolicyTests.swift new file mode 100644 index 000000000000..00040496d89c --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactSyntaxHighlightPolicyTests.swift @@ -0,0 +1,44 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact syntax highlighting policy") +struct ChatArtifactSyntaxHighlightPolicyTests { + private let policy = ChatArtifactSyntaxHighlightPolicy() + + @Test("known languages highlight through the exact size limit") + func highlightsAtThreshold() { + #expect(policy.decision( + path: "/tmp/example.swift", + byteCount: ChatArtifactSyntaxHighlightPolicy.maxHighlightBytes + ) == .highlight(language: "swift")) + } + + @Test("files over the limit skip for size and show the pill") + func skipsOverThreshold() { + let decision = policy.decision( + path: "/tmp/example.swift", + byteCount: ChatArtifactSyntaxHighlightPolicy.maxHighlightBytes + 1 + ) + + #expect(decision == .skippedForSize) + #expect(decision.showsHighlightingOffPill) + } + + @Test("unknown small files use detection without showing the pill") + func detectsOnlySmallUnknownFiles() { + let small = policy.decision( + path: "/tmp/extensionless", + byteCount: ChatArtifactSyntaxHighlightPolicy.maxAutomaticDetectionBytes - 1 + ) + let larger = policy.decision( + path: "/tmp/server.log", + byteCount: ChatArtifactSyntaxHighlightPolicy.maxAutomaticDetectionBytes + ) + + #expect(small == .highlight(language: nil)) + #expect(!small.showsHighlightingOffPill) + #expect(larger == .skippedNoLanguage) + #expect(!larger.showsHighlightingOffPill) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Package.resolved b/Packages/iOS/CmuxMobileShellUI/Package.resolved index f7cc217c9778..8dfa8c37170b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Package.resolved +++ b/Packages/iOS/CmuxMobileShellUI/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "680f016588d1919fc59e09829d71907c229c473ea64dcd38f9013a99e7c9560e", + "originHash" : "8e4d600dd748d7cf440352cd9cd6219c40b399f51b2f821c0c4ddf4fae27d332", "pins" : [ + { + "identity" : "highlightr", + "kind" : "remoteSourceControl", + "location" : "https://github.com/raspu/Highlightr.git", + "state" : { + "revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef", + "version" : "2.3.0" + } + }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", diff --git a/ios/cmuxPackage/Package.resolved b/ios/cmuxPackage/Package.resolved index 151147a57371..fe1520f32946 100644 --- a/ios/cmuxPackage/Package.resolved +++ b/ios/cmuxPackage/Package.resolved @@ -1,6 +1,15 @@ { "originHash" : "eef9bc2175179405e8193eed1f1b9d34d3c35c477aa28a24697f47e4458ecd12", "pins" : [ + { + "identity" : "highlightr", + "kind" : "remoteSourceControl", + "location" : "https://github.com/raspu/Highlightr.git", + "state" : { + "revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef", + "version" : "2.3.0" + } + }, { "identity" : "sentry-cocoa", "kind" : "remoteSourceControl", From 3bafe4603adaa91008f06521880983307fac09d8 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 15:02:46 -0700 Subject: [PATCH 26/92] feat(ios): search within streamed artifacts --- .../Artifacts/ChatArtifactSearchBar.swift | 113 +++++++++ .../Artifacts/ChatArtifactSearchModel.swift | 95 +++++++ .../Artifacts/ChatArtifactSearchSummary.swift | 7 + .../Artifacts/ChatArtifactTextView.swift | 17 ++ .../ChatArtifactTextViewCoordinator.swift | 238 ++++++++++++++++++ .../ChatArtifactViewerRouteView.swift | 63 ++++- .../Resources/Localizable.xcstrings | 102 ++++++++ .../ChatArtifactSearchModelTests.swift | 49 ++++ 8 files changed, 683 insertions(+), 1 deletion(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchBar.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchModel.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchSummary.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactSearchModelTests.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchBar.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchBar.swift new file mode 100644 index 000000000000..92a720d700e3 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchBar.swift @@ -0,0 +1,113 @@ +import SwiftUI + +/// Inline in-file search controls for the currently loaded artifact text. +struct ChatArtifactSearchBar: View { + @Binding var query: String + let summary: ChatArtifactSearchSummary + let isStillLoading: Bool + let onPrevious: () -> Void + let onNext: () -> Void + let onClose: () -> Void + + @FocusState private var isSearchFieldFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + searchField + + if !query.isEmpty { + Text(verbatim: "\(summary.currentPosition)/\(summary.matchCount)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .fixedSize() + } + + Button(action: onPrevious) { + Image(systemName: "chevron.up") + } + .disabled(query.isEmpty || summary.matchCount == 0) + .accessibilityLabel(String( + localized: "chat.artifact.search.previous", + defaultValue: "Previous match", + bundle: .module + )) + + Button(action: onNext) { + Image(systemName: "chevron.down") + } + .disabled(query.isEmpty || summary.matchCount == 0) + .accessibilityLabel(String( + localized: "chat.artifact.search.next", + defaultValue: "Next match", + bundle: .module + )) + + Button { + isSearchFieldFocused = false + onClose() + } label: { + Image(systemName: "xmark.circle.fill") + } + .accessibilityLabel(String( + localized: "chat.artifact.search.close", + defaultValue: "Close search", + bundle: .module + )) + } + .buttonStyle(.plain) + + if isStillLoading, !query.isEmpty { + HStack(spacing: 5) { + ProgressView() + .controlSize(.mini) + Text(String( + localized: "chat.artifact.search.still_loading", + defaultValue: "Still loading", + bundle: .module + )) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.bar) + .onAppear { + isSearchFieldFocused = true + } + .onDisappear { + isSearchFieldFocused = false + } + } + + @ViewBuilder + private var searchField: some View { + #if os(iOS) + baseSearchField + .textInputAutocapitalization(.never) + #else + baseSearchField + #endif + } + + private var baseSearchField: some View { + TextField( + String( + localized: "chat.artifact.search.placeholder", + defaultValue: "Find in file", + bundle: .module + ), + text: $query + ) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + .submitLabel(.search) + .focused($isSearchFieldFocused) + .onSubmit { + isSearchFieldFocused = false + onNext() + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchModel.swift new file mode 100644 index 000000000000..2544316dce7e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchModel.swift @@ -0,0 +1,95 @@ +import Foundation + +/// Pure literal-search state over the currently loaded UTF-16 text range. +struct ChatArtifactSearchModel: Equatable, Sendable { + private(set) var query: String + private(set) var matchRanges: [NSRange] + private(set) var selectedMatchIndex: Int? + + init(query: String = "", text: String = "") { + self.query = query + matchRanges = Self.ranges(of: query, in: text) + selectedMatchIndex = matchRanges.isEmpty ? nil : 0 + } + + var currentRange: NSRange? { + guard let selectedMatchIndex, + matchRanges.indices.contains(selectedMatchIndex) else { + return nil + } + return matchRanges[selectedMatchIndex] + } + + var summary: ChatArtifactSearchSummary { + ChatArtifactSearchSummary( + currentPosition: selectedMatchIndex.map { $0 + 1 } ?? 0, + matchCount: matchRanges.count + ) + } + + /// Replaces the literal query and starts selection at the first loaded match. + mutating func update(query: String, in text: String) { + guard query == self.query else { + self.query = query + matchRanges = Self.ranges(of: query, in: text) + selectedMatchIndex = matchRanges.isEmpty ? nil : 0 + return + } + recompute(in: text) + } + + /// Recomputes after a stream append while preserving the current match when possible. + mutating func recompute(in text: String) { + let selectedRange = currentRange + matchRanges = Self.ranges(of: query, in: text) + if let selectedRange, + let preservedIndex = matchRanges.firstIndex(of: selectedRange) { + selectedMatchIndex = preservedIndex + } else { + selectedMatchIndex = matchRanges.isEmpty ? nil : 0 + } + } + + /// Advances to the next result, wrapping from the last match to the first. + mutating func selectNext() { + guard !matchRanges.isEmpty else { + selectedMatchIndex = nil + return + } + selectedMatchIndex = ((selectedMatchIndex ?? -1) + 1) % matchRanges.count + } + + /// Moves to the previous result, wrapping from the first match to the last. + mutating func selectPrevious() { + guard !matchRanges.isEmpty else { + selectedMatchIndex = nil + return + } + let currentIndex = selectedMatchIndex ?? 0 + selectedMatchIndex = (currentIndex - 1 + matchRanges.count) % matchRanges.count + } + + private static func ranges(of query: String, in text: String) -> [NSRange] { + guard !query.isEmpty else { return [] } + let source = text as NSString + var searchRange = NSRange(location: 0, length: source.length) + var results: [NSRange] = [] + + while searchRange.length > 0 { + let match = source.range( + of: query, + options: [.caseInsensitive, .literal], + range: searchRange + ) + guard match.location != NSNotFound else { break } + results.append(match) + let nextLocation = NSMaxRange(match) + guard nextLocation > searchRange.location else { break } + searchRange = NSRange( + location: nextLocation, + length: source.length - nextLocation + ) + } + return results + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchSummary.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchSummary.swift new file mode 100644 index 000000000000..c41034c5254e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchSummary.swift @@ -0,0 +1,7 @@ +/// The small search-state snapshot needed by the SwiftUI search chrome. +struct ChatArtifactSearchSummary: Equatable, Sendable { + static let empty = ChatArtifactSearchSummary(currentPosition: 0, matchCount: 0) + + let currentPosition: Int + let matchCount: Int +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift index dacf6d7703e3..5bd9155c2ce6 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift @@ -9,6 +9,10 @@ struct ChatArtifactTextView: UIViewRepresentable { let reachedEOF: Bool let highlightDecision: ChatArtifactHighlightDecision let highlightTheme: ChatArtifactHighlightTheme + let searchQuery: String + let previousSearchRequestID: Int + let nextSearchRequestID: Int + let onSearchSummaryChanged: (ChatArtifactSearchSummary) -> Void let topRequestID: Int let bottomRequestID: Int @@ -41,6 +45,7 @@ struct ChatArtifactTextView: UIViewRepresentable { let isNewDocument = context.coordinator.documentID != documentID if isNewDocument { context.coordinator.resetHighlighting() + context.coordinator.resetSearch() textView.textStorage.setAttributedString(NSAttributedString()) textView.selectedRange = NSRange(location: 0, length: 0) context.coordinator.documentID = documentID @@ -51,6 +56,8 @@ struct ChatArtifactTextView: UIViewRepresentable { if context.coordinator.appliedChunkCount > chunks.count { context.coordinator.appliedChunkCount = 0 + context.coordinator.resetHighlighting() + context.coordinator.resetSearch() textView.textStorage.setAttributedString(NSAttributedString()) } @@ -83,6 +90,16 @@ struct ChatArtifactTextView: UIViewRepresentable { decision: highlightDecision, theme: highlightTheme ) + context.coordinator.updateSearch( + in: textView, + documentID: documentID, + text: textView.textStorage.string, + query: searchQuery, + reachedEOF: reachedEOF, + previousRequestID: previousSearchRequestID, + nextRequestID: nextSearchRequestID, + onSummaryChanged: onSearchSummaryChanged + ) if isNewDocument { Self.scrollToTop(textView) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift index e39e0396c767..8a426da42a6d 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift @@ -16,6 +16,29 @@ final class ChatArtifactTextViewCoordinator { private var highlightedTextLength = 0 private var highlightedLanguage: String? private var highlightedTheme: ChatArtifactHighlightTheme? + private var pendingHighlightDocumentID: String? + private var pendingHighlightTextLength = 0 + private var pendingHighlightLanguage: String? + private var pendingHighlightTheme: ChatArtifactHighlightTheme? + private var searchModel = ChatArtifactSearchModel() + private var searchTask: Task? + private var searchGeneration = 0 + private var searchedDocumentID: String? + private var searchedTextLength = 0 + private var pendingSearchDocumentID: String? + private var pendingSearchTextLength = 0 + private var pendingSearchQuery = "" + private var handledPreviousSearchRequestID = 0 + private var handledNextSearchRequestID = 0 + private var appliedSearchRange: NSRange? + private var onSearchSummaryChanged: ((ChatArtifactSearchSummary) -> Void)? + private var publishedSearchSummary = ChatArtifactSearchSummary.empty + private var summaryPublishGeneration = 0 + private let searchDebounce: Duration + + init(searchDebounce: Duration = .milliseconds(160)) { + self.searchDebounce = searchDebounce + } func resetHighlighting() { highlightTask?.cancel() @@ -25,6 +48,26 @@ final class ChatArtifactTextViewCoordinator { highlightedTextLength = 0 highlightedLanguage = nil highlightedTheme = nil + pendingHighlightDocumentID = nil + pendingHighlightTextLength = 0 + pendingHighlightLanguage = nil + pendingHighlightTheme = nil + } + + func resetSearch() { + searchTask?.cancel() + searchTask = nil + searchGeneration += 1 + searchModel = ChatArtifactSearchModel() + searchedDocumentID = nil + searchedTextLength = 0 + pendingSearchDocumentID = nil + pendingSearchTextLength = 0 + pendingSearchQuery = "" + handledPreviousSearchRequestID = 0 + handledNextSearchRequestID = 0 + appliedSearchRange = nil + publishSearchSummary(.empty) } func updateHighlighting( @@ -48,11 +91,21 @@ final class ChatArtifactTextViewCoordinator { || highlightedTheme != theme else { return } + guard pendingHighlightDocumentID != documentID + || pendingHighlightTextLength != text.utf16.count + || pendingHighlightLanguage != language + || pendingHighlightTheme != theme else { + return + } highlightTask?.cancel() highlightGeneration += 1 let generation = highlightGeneration let highlighter = syntaxHighlighter + pendingHighlightDocumentID = documentID + pendingHighlightTextLength = text.utf16.count + pendingHighlightLanguage = language + pendingHighlightTheme = theme highlightTask = Task { @MainActor [weak self, weak textView] in let result = await highlighter.highlight( text: text, @@ -74,10 +127,150 @@ final class ChatArtifactTextViewCoordinator { self.highlightedTextLength = text.utf16.count self.highlightedLanguage = language self.highlightedTheme = theme + self.pendingHighlightDocumentID = nil + self.pendingHighlightTextLength = 0 + self.pendingHighlightLanguage = nil + self.pendingHighlightTheme = nil self.highlightTask = nil } } + func updateSearch( + in textView: UITextView, + documentID: String, + text: String, + query: String, + reachedEOF: Bool, + previousRequestID: Int, + nextRequestID: Int, + onSummaryChanged: @escaping (ChatArtifactSearchSummary) -> Void + ) { + self.onSearchSummaryChanged = onSummaryChanged + guard !query.isEmpty else { + searchTask?.cancel() + searchTask = nil + searchGeneration += 1 + clearSearchHighlight(in: textView) + searchModel = ChatArtifactSearchModel() + searchedDocumentID = documentID + searchedTextLength = text.utf16.count + pendingSearchDocumentID = nil + pendingSearchTextLength = 0 + pendingSearchQuery = "" + handledPreviousSearchRequestID = previousRequestID + handledNextSearchRequestID = nextRequestID + publishSearchSummary(.empty) + return + } + + let textLength = text.utf16.count + let hasCurrentResults = searchedDocumentID == documentID + && searchedTextLength == textLength + && searchModel.query == query + let hasPendingResults = pendingSearchDocumentID == documentID + && pendingSearchTextLength == textLength + && pendingSearchQuery == query + if !hasCurrentResults, !hasPendingResults { + scheduleSearch( + in: textView, + documentID: documentID, + text: text, + query: query, + reachedEOF: reachedEOF, + previousRequestID: previousRequestID, + nextRequestID: nextRequestID + ) + return + } + guard hasCurrentResults else { return } + applySearchNavigation( + in: textView, + previousRequestID: previousRequestID, + nextRequestID: nextRequestID + ) + } + + private func scheduleSearch( + in textView: UITextView, + documentID: String, + text: String, + query: String, + reachedEOF: Bool, + previousRequestID: Int, + nextRequestID: Int + ) { + searchTask?.cancel() + searchGeneration += 1 + let generation = searchGeneration + let previousModel = searchModel + let debounce = searchDebounce + let shouldDebounce = !reachedEOF + pendingSearchDocumentID = documentID + pendingSearchTextLength = text.utf16.count + pendingSearchQuery = query + if previousModel.query != query { + clearSearchHighlight(in: textView) + publishSearchSummary(.empty) + } + + searchTask = Task { @MainActor [weak self, weak textView] in + if shouldDebounce { + do { + // A bounded, cancellable debounce coalesces streamed append bursts. + try await Task.sleep(for: debounce) + } catch { + return + } + } + let nextModel = await Task.detached(priority: .userInitiated) { + var model = previousModel + model.update(query: query, in: text) + return model + }.value + guard let self, + !Task.isCancelled, + generation == self.searchGeneration, + let textView, + textView.textStorage.string == text else { + return + } + + let shouldScroll = previousModel.query != query + self.searchModel = nextModel + self.searchedDocumentID = documentID + self.searchedTextLength = text.utf16.count + self.pendingSearchDocumentID = nil + self.pendingSearchTextLength = 0 + self.pendingSearchQuery = "" + self.handledPreviousSearchRequestID = previousRequestID + self.handledNextSearchRequestID = nextRequestID + self.applyCurrentSearchHighlight(in: textView, scrollToMatch: shouldScroll) + self.publishSearchSummary(nextModel.summary) + self.searchTask = nil + } + } + + private func applySearchNavigation( + in textView: UITextView, + previousRequestID: Int, + nextRequestID: Int + ) { + var didNavigate = false + if handledPreviousSearchRequestID != previousRequestID { + handledPreviousSearchRequestID = previousRequestID + searchModel.selectPrevious() + didNavigate = true + } + if handledNextSearchRequestID != nextRequestID { + handledNextSearchRequestID = nextRequestID + searchModel.selectNext() + didNavigate = true + } + guard didNavigate else { return } + applyCurrentSearchHighlight(in: textView, scrollToMatch: true) + publishSearchSummary(searchModel.summary) + } + private func apply(_ highlighted: NSAttributedString, to textView: UITextView) { let contentOffset = textView.contentOffset let selection = textView.selectedRange @@ -95,6 +288,51 @@ final class ChatArtifactTextViewCoordinator { textView.textStorage.endEditing() textView.selectedRange = selection textView.setContentOffset(contentOffset, animated: false) + applyCurrentSearchHighlight(in: textView, scrollToMatch: false) + } + + private func applyCurrentSearchHighlight( + in textView: UITextView, + scrollToMatch: Bool + ) { + clearSearchHighlight(in: textView) + guard let currentRange = searchModel.currentRange, + NSMaxRange(currentRange) <= textView.textStorage.length else { + return + } + textView.textStorage.addAttribute( + .backgroundColor, + value: UIColor.systemYellow.withAlphaComponent(0.38), + range: currentRange + ) + appliedSearchRange = currentRange + if scrollToMatch { + textView.scrollRangeToVisible(currentRange) + } + } + + private func clearSearchHighlight(in textView: UITextView) { + guard let appliedSearchRange, + NSMaxRange(appliedSearchRange) <= textView.textStorage.length else { + self.appliedSearchRange = nil + return + } + textView.textStorage.removeAttribute(.backgroundColor, range: appliedSearchRange) + self.appliedSearchRange = nil + } + + private func publishSearchSummary(_ summary: ChatArtifactSearchSummary) { + guard publishedSearchSummary != summary else { return } + publishedSearchSummary = summary + summaryPublishGeneration += 1 + let generation = summaryPublishGeneration + let handler = onSearchSummaryChanged + Task { @MainActor [weak self] in + await Task.yield() + guard let self, + generation == self.summaryPublishGeneration else { return } + handler?(summary) + } } private func normalized( diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index 1cd798f3ecce..afd4414f5fd2 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -22,6 +22,11 @@ struct ChatArtifactViewerRouteView: View { @State private var retryGeneration = 0 @State private var topRequestID = 0 @State private var bottomRequestID = 0 + @State private var isSearchPresented = false + @State private var searchQuery = "" + @State private var searchSummary = ChatArtifactSearchSummary.empty + @State private var previousSearchRequestID = 0 + @State private var nextSearchRequestID = 0 var body: some View { content @@ -38,6 +43,24 @@ struct ChatArtifactViewerRouteView: View { #if os(iOS) if shouldShowTextJumpControls { ToolbarItemGroup(placement: .primaryAction) { + Button { + withAnimation(.snappy) { + if isSearchPresented { + dismissSearch() + } else { + isSearchPresented = true + } + } + } label: { + Label( + String( + localized: "chat.artifact.search.title", + defaultValue: "Search", + bundle: .module + ), + systemImage: "magnifyingglass" + ) + } Button { topRequestID += 1 } label: { @@ -173,6 +196,7 @@ struct ChatArtifactViewerRouteView: View { if !model.textReachedEOF { streamingProgressHeader } + searchBar highlightingStatusPill rawTextView } @@ -184,6 +208,7 @@ struct ChatArtifactViewerRouteView: View { if model.markdownPresentation.mode == .rendered { ChatArtifactMarkdownView(markdown: model.renderedText) } else { + searchBar highlightingStatusPill rawTextView } @@ -254,6 +279,12 @@ struct ChatArtifactViewerRouteView: View { reachedEOF: model.textReachedEOF, highlightDecision: model.textHighlightDecision, highlightTheme: colorScheme == .dark ? .dark : .light, + searchQuery: searchQuery, + previousSearchRequestID: previousSearchRequestID, + nextSearchRequestID: nextSearchRequestID, + onSearchSummaryChanged: { summary in + searchSummary = summary + }, topRequestID: topRequestID, bottomRequestID: bottomRequestID ) @@ -268,6 +299,25 @@ struct ChatArtifactViewerRouteView: View { #endif } + @ViewBuilder + private var searchBar: some View { + if isSearchPresented { + ChatArtifactSearchBar( + query: $searchQuery, + summary: searchSummary, + isStillLoading: !model.textReachedEOF, + onPrevious: { previousSearchRequestID += 1 }, + onNext: { nextSearchRequestID += 1 }, + onClose: { + withAnimation(.snappy) { + dismissSearch() + } + } + ) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + @ViewBuilder private var highlightingStatusPill: some View { if model.textHighlightDecision.showsHighlightingOffPill, @@ -347,10 +397,21 @@ struct ChatArtifactViewerRouteView: View { private var markdownModeBinding: Binding { Binding( get: { model.markdownPresentation.mode }, - set: { model.selectMarkdownMode($0) } + set: { mode in + if mode == .rendered { + dismissSearch() + } + model.selectMarkdownMode(mode) + } ) } + private func dismissSearch() { + isSearchPresented = false + searchQuery = "" + searchSummary = .empty + } + private var shouldShowTextJumpControls: Bool { model.state == .text || (model.state == .markdown && model.markdownPresentation.mode == .raw) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index bd09c0b47b28..ed6a0c785e46 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -460,6 +460,108 @@ } } }, + "chat.artifact.search.close" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close search" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "検索を閉じる" + } + } + } + }, + "chat.artifact.search.next" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next match" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "次の一致" + } + } + } + }, + "chat.artifact.search.placeholder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Find in file" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイル内を検索" + } + } + } + }, + "chat.artifact.search.previous" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Previous match" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "前の一致" + } + } + } + }, + "chat.artifact.search.still_loading" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Still loading" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "読み込み中" + } + } + } + }, + "chat.artifact.search.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "検索" + } + } + } + }, "chat.artifact.too_large.limit_message" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactSearchModelTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactSearchModelTests.swift new file mode 100644 index 000000000000..ec7d7ec8c6ea --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactSearchModelTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact text search") +struct ChatArtifactSearchModelTests { + @Test("literal case-insensitive search returns UTF-16 ranges") + func findsLiteralRanges() { + let model = ChatArtifactSearchModel( + query: "A.B", + text: "a.b A-B A.B a.b" + ) + + #expect(model.matchRanges == [ + NSRange(location: 0, length: 3), + NSRange(location: 8, length: 3), + NSRange(location: 12, length: 3), + ]) + #expect(model.summary == ChatArtifactSearchSummary(currentPosition: 1, matchCount: 3)) + } + + @Test("next and previous wrap around") + func navigationWraps() { + var model = ChatArtifactSearchModel(query: "hit", text: "hit no hit") + + model.selectPrevious() + #expect(model.summary.currentPosition == 2) + model.selectNext() + #expect(model.summary.currentPosition == 1) + model.selectNext() + #expect(model.summary.currentPosition == 2) + model.selectNext() + #expect(model.summary.currentPosition == 1) + } + + @Test("stream append recomputes matches and preserves selection") + func recomputesAfterAppend() { + var model = ChatArtifactSearchModel(query: "line", text: "line one\nline two") + model.selectNext() + let selectedRange = model.currentRange + + model.recompute(in: "line one\nline two\nLINE three") + + #expect(model.matchRanges.count == 3) + #expect(model.currentRange == selectedRange) + #expect(model.summary == ChatArtifactSearchSummary(currentPosition: 2, matchCount: 3)) + } +} From a59c343b81a53892042db4b6fe4b80b943a7e3f6 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 15:08:19 -0700 Subject: [PATCH 27/92] feat(ios): add artifact line navigation --- .../Artifacts/ChatArtifactGoToLineBar.swift | 82 ++++++++++++++ .../Artifacts/ChatArtifactLineIndex.swift | 48 +++++++++ .../ChatArtifactLineNumberGutterView.swift | 74 +++++++++++++ .../ChatArtifactTextContainerView.swift | 80 ++++++++++++++ .../Artifacts/ChatArtifactTextView.swift | 42 ++++---- .../ChatArtifactTextViewCoordinator.swift | 14 ++- .../Artifacts/ChatArtifactViewerModel.swift | 3 + .../ChatArtifactViewerRouteView.swift | 83 ++++++++++++++ .../Resources/Localizable.xcstrings | 102 ++++++++++++++++++ .../ChatArtifactLineIndexTests.swift | 36 +++++++ 10 files changed, 543 insertions(+), 21 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactGoToLineBar.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndex.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineNumberGutterView.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLineIndexTests.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactGoToLineBar.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactGoToLineBar.swift new file mode 100644 index 000000000000..9956bb959a74 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactGoToLineBar.swift @@ -0,0 +1,82 @@ +import SwiftUI + +/// Inline numeric controls for jumping to a currently loaded text line. +struct ChatArtifactGoToLineBar: View { + @Binding var lineText: String + let onGo: (Int) -> Void + let onClose: () -> Void + + @FocusState private var isLineFieldFocused: Bool + + var body: some View { + HStack(spacing: 8) { + lineField + + Button { + submit() + } label: { + Text(String( + localized: "chat.artifact.line.go", + defaultValue: "Go", + bundle: .module + )) + } + .buttonStyle(.borderedProminent) + .disabled(Int(lineText) == nil) + + Button { + isLineFieldFocused = false + onClose() + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + .accessibilityLabel(String( + localized: "chat.artifact.line.close", + defaultValue: "Close go to line", + bundle: .module + )) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.bar) + .onAppear { + isLineFieldFocused = true + } + .onDisappear { + isLineFieldFocused = false + } + } + + @ViewBuilder + private var lineField: some View { + #if os(iOS) + baseLineField + .keyboardType(.numberPad) + #else + baseLineField + #endif + } + + private var baseLineField: some View { + TextField( + String( + localized: "chat.artifact.line.placeholder", + defaultValue: "Line number", + bundle: .module + ), + text: $lineText + ) + .textFieldStyle(.roundedBorder) + .focused($isLineFieldFocused) + .onSubmit { + submit() + } + } + + private func submit() { + guard let line = Int(lineText) else { return } + isLineFieldFocused = false + onGo(line) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndex.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndex.swift new file mode 100644 index 000000000000..9641347352af --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndex.swift @@ -0,0 +1,48 @@ +/// Incremental UTF-16 line starts for streamed text and constant-time line jumps. +struct ChatArtifactLineIndex: Equatable, Sendable { + private(set) var lineStartOffsets: [Int] = [0] + private(set) var loadedUTF16Length = 0 + + var lineCount: Int { + lineStartOffsets.count + } + + /// Adds line starts from one newly decoded streaming chunk. + mutating func append(_ text: String) { + let baseOffset = loadedUTF16Length + var chunkOffset = 0 + for codeUnit in text.utf16 { + chunkOffset += 1 + if codeUnit == 0x0A { + lineStartOffsets.append(baseOffset + chunkOffset) + } + } + loadedUTF16Length += chunkOffset + } + + /// Clamps a one-based requested line to the range currently loaded. + func clampedLine(_ requestedLine: Int) -> Int { + min(max(requestedLine, 1), lineCount) + } + + /// Returns the UTF-16 location for a one-based line, clamped to loaded lines. + func offset(forLine requestedLine: Int) -> Int { + lineStartOffsets[clampedLine(requestedLine) - 1] + } + + /// Finds the one-based logical line containing a UTF-16 text location. + func lineNumber(containingUTF16Offset requestedOffset: Int) -> Int { + let offset = min(max(requestedOffset, 0), loadedUTF16Length) + var lowerBound = 0 + var upperBound = lineStartOffsets.count + while lowerBound < upperBound { + let midpoint = (lowerBound + upperBound) / 2 + if lineStartOffsets[midpoint] <= offset { + lowerBound = midpoint + 1 + } else { + upperBound = midpoint + } + } + return max(lowerBound, 1) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineNumberGutterView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineNumberGutterView.swift new file mode 100644 index 000000000000..61e1c47121b5 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineNumberGutterView.swift @@ -0,0 +1,74 @@ +#if canImport(UIKit) +import UIKit + +/// Draws only line numbers whose TextKit 1 fragments intersect the viewport. +@MainActor +final class ChatArtifactLineNumberGutterView: UIView { + weak var textView: UITextView? + var lineIndex = ChatArtifactLineIndex() + var textFontPointSize: CGFloat = UIFont.preferredFont(forTextStyle: .body).pointSize + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor.secondarySystemBackground.withAlphaComponent(0.45) + isOpaque = false + contentMode = .redraw + } + + required init?(coder: NSCoder) { + nil + } + + override func draw(_ rect: CGRect) { + guard let textView else { return } + let textContainer = textView.textContainer + let layoutManager = textView.layoutManager + let visibleTextRect = CGRect( + x: textView.contentOffset.x - textView.textContainerInset.left, + y: textView.contentOffset.y - textView.textContainerInset.top, + width: textView.bounds.width, + height: textView.bounds.height + ) + let glyphRange = layoutManager.glyphRange( + forBoundingRect: visibleTextRect, + in: textContainer + ) + let font = UIFont.monospacedDigitSystemFont( + ofSize: max(9, textFontPointSize * 0.78), + weight: .regular + ) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .right + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: UIColor.secondaryLabel, + .paragraphStyle: paragraphStyle, + ] + + layoutManager.enumerateLineFragments(forGlyphRange: glyphRange) { + [weak self] _, usedRect, _, fragmentGlyphRange, _ in + guard let self else { return } + let characterRange = layoutManager.characterRange( + forGlyphRange: fragmentGlyphRange, + actualGlyphRange: nil + ) + let lineNumber = self.lineIndex.lineNumber( + containingUTF16Offset: characterRange.location + ) + guard self.lineIndex.offset(forLine: lineNumber) == characterRange.location else { + return + } + let labelRect = CGRect( + x: 4, + y: usedRect.minY + textView.textContainerInset.top - textView.contentOffset.y, + width: max(0, self.bounds.width - 12), + height: max(usedRect.height, font.lineHeight) + ) + String(lineNumber).draw(in: labelRect, withAttributes: attributes) + } + + UIColor.separator.setFill() + UIBezierPath(rect: CGRect(x: bounds.maxX - 0.5, y: 0, width: 0.5, height: bounds.height)).fill() + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift new file mode 100644 index 000000000000..926855373595 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift @@ -0,0 +1,80 @@ +#if canImport(UIKit) +import UIKit + +/// Hosts the TextKit 1 text view beside its independently redrawn line gutter. +@MainActor +final class ChatArtifactTextContainerView: UIView { + let textView: UITextView + let gutterView = ChatArtifactLineNumberGutterView() + + private let gutterWidthConstraint: NSLayoutConstraint + + override init(frame: CGRect) { + textView = UITextView(usingTextLayoutManager: false) + gutterWidthConstraint = gutterView.widthAnchor.constraint(equalToConstant: 0) + super.init(frame: frame) + + backgroundColor = .clear + textView.layoutManager.allowsNonContiguousLayout = true + textView.isEditable = false + textView.isSelectable = true + textView.isScrollEnabled = true + textView.backgroundColor = .clear + textView.adjustsFontForContentSizeCategory = true + textView.font = .monospacedSystemFont( + ofSize: UIFont.preferredFont(forTextStyle: .body).pointSize, + weight: .regular + ) + textView.textContainerInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + textView.textContainer.lineFragmentPadding = 0 + + gutterView.textView = textView + gutterView.translatesAutoresizingMaskIntoConstraints = false + textView.translatesAutoresizingMaskIntoConstraints = false + addSubview(gutterView) + addSubview(textView) + NSLayoutConstraint.activate([ + gutterView.leadingAnchor.constraint(equalTo: leadingAnchor), + gutterView.topAnchor.constraint(equalTo: topAnchor), + gutterView.bottomAnchor.constraint(equalTo: bottomAnchor), + gutterWidthConstraint, + textView.leadingAnchor.constraint(equalTo: gutterView.trailingAnchor), + textView.trailingAnchor.constraint(equalTo: trailingAnchor), + textView.topAnchor.constraint(equalTo: topAnchor), + textView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { + nil + } + + override func layoutSubviews() { + super.layoutSubviews() + gutterView.setNeedsDisplay() + } + + /// Refreshes the immutable line snapshot and the width needed for its largest number. + func updateLineNumbers( + index: ChatArtifactLineIndex, + isVisible: Bool + ) { + gutterView.lineIndex = index + gutterView.textFontPointSize = textView.font?.pointSize + ?? UIFont.preferredFont(forTextStyle: .body).pointSize + gutterView.isHidden = !isVisible + if isVisible { + let digitCount = max(2, String(index.lineCount).count) + let font = UIFont.monospacedDigitSystemFont( + ofSize: max(9, gutterView.textFontPointSize * 0.78), + weight: .regular + ) + let digitWidth = ("0" as NSString).size(withAttributes: [.font: font]).width + gutterWidthConstraint.constant = ceil(digitWidth * CGFloat(digitCount) + 18) + } else { + gutterWidthConstraint.constant = 0 + } + gutterView.setNeedsDisplay() + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift index 5bd9155c2ce6..8f4a5c7c7bb2 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift @@ -13,6 +13,10 @@ struct ChatArtifactTextView: UIViewRepresentable { let previousSearchRequestID: Int let nextSearchRequestID: Int let onSearchSummaryChanged: (ChatArtifactSearchSummary) -> Void + let lineIndex: ChatArtifactLineIndex + let showsLineNumbers: Bool + let goToLineUTF16Offset: Int + let goToLineRequestID: Int let topRequestID: Int let bottomRequestID: Int @@ -20,28 +24,17 @@ struct ChatArtifactTextView: UIViewRepresentable { ChatArtifactTextViewCoordinator() } - func makeUIView(context: Context) -> UITextView { - // A default UITextView uses TextKit 2 on modern iOS. Large artifacts - // then synchronously create layout fragments during fast scrolling. - // Opt into the TextKit 1 stack so non-contiguous glyph layout remains - // genuinely lazy instead of entering TextKit 2 compatibility mode. - let textView = UITextView(usingTextLayoutManager: false) - textView.layoutManager.allowsNonContiguousLayout = true - textView.isEditable = false - textView.isSelectable = true - textView.isScrollEnabled = true - textView.backgroundColor = .clear - textView.adjustsFontForContentSizeCategory = true - textView.font = .monospacedSystemFont( - ofSize: UIFont.preferredFont(forTextStyle: .body).pointSize, - weight: .regular - ) - textView.textContainerInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) - textView.textContainer.lineFragmentPadding = 0 - return textView + func makeUIView(context: Context) -> ChatArtifactTextContainerView { + // The container constructs `UITextView(usingTextLayoutManager: false)` + // so non-contiguous TextKit 1 layout remains genuinely viewport-lazy. + let containerView = ChatArtifactTextContainerView() + containerView.textView.delegate = context.coordinator + context.coordinator.attach(containerView) + return containerView } - func updateUIView(_ textView: UITextView, context: Context) { + func updateUIView(_ containerView: ChatArtifactTextContainerView, context: Context) { + let textView = containerView.textView let isNewDocument = context.coordinator.documentID != documentID if isNewDocument { context.coordinator.resetHighlighting() @@ -52,6 +45,7 @@ struct ChatArtifactTextView: UIViewRepresentable { context.coordinator.appliedChunkCount = 0 context.coordinator.handledTopRequestID = topRequestID context.coordinator.handledBottomRequestID = bottomRequestID + context.coordinator.handledGoToLineRequestID = goToLineRequestID } if context.coordinator.appliedChunkCount > chunks.count { @@ -100,6 +94,7 @@ struct ChatArtifactTextView: UIViewRepresentable { nextRequestID: nextSearchRequestID, onSummaryChanged: onSearchSummaryChanged ) + containerView.updateLineNumbers(index: lineIndex, isVisible: showsLineNumbers) if isNewDocument { Self.scrollToTop(textView) @@ -114,6 +109,13 @@ struct ChatArtifactTextView: UIViewRepresentable { NSRange(location: textView.textStorage.length, length: 0) ) } + if context.coordinator.handledGoToLineRequestID != goToLineRequestID { + context.coordinator.handledGoToLineRequestID = goToLineRequestID + textView.scrollRangeToVisible(NSRange( + location: min(max(goToLineUTF16Offset, 0), textView.textStorage.length), + length: 0 + )) + } } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift index 8a426da42a6d..bcc393f3c958 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift @@ -4,11 +4,13 @@ import UIKit /// Tracks which streamed chunks have been applied to one text-storage instance. @MainActor -final class ChatArtifactTextViewCoordinator { +final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { var documentID: String? var appliedChunkCount = 0 var handledTopRequestID = 0 var handledBottomRequestID = 0 + var handledGoToLineRequestID = 0 + private weak var containerView: ChatArtifactTextContainerView? private let syntaxHighlighter = ChatArtifactSyntaxHighlighter() private var highlightTask: Task? private var highlightGeneration = 0 @@ -38,6 +40,15 @@ final class ChatArtifactTextViewCoordinator { init(searchDebounce: Duration = .milliseconds(160)) { self.searchDebounce = searchDebounce + super.init() + } + + func attach(_ containerView: ChatArtifactTextContainerView) { + self.containerView = containerView + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + containerView?.gutterView.setNeedsDisplay() } func resetHighlighting() { @@ -289,6 +300,7 @@ final class ChatArtifactTextViewCoordinator { textView.selectedRange = selection textView.setContentOffset(contentOffset, animated: false) applyCurrentSearchHighlight(in: textView, scrollToMatch: false) + containerView?.gutterView.setNeedsDisplay() } private func applyCurrentSearchHighlight( diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift index 2a8ef62be3fc..ef05a408d8e2 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift @@ -14,6 +14,7 @@ final class ChatArtifactViewerModel { private(set) var activePath: String? private(set) var markdownPresentation = ChatArtifactMarkdownPresentation(byteCount: 0) private(set) var textHighlightDecision: ChatArtifactHighlightDecision = .skippedNoLanguage + private(set) var textLineIndex = ChatArtifactLineIndex() private let temporaryFileStore: ChatArtifactTemporaryFileStore private var temporaryFileURL: URL? @@ -142,6 +143,7 @@ final class ChatArtifactViewerModel { textReachedEOF = false markdownPresentation = ChatArtifactMarkdownPresentation(byteCount: 0) textHighlightDecision = .skippedNoLanguage + textLineIndex = ChatArtifactLineIndex() } private func streamText( @@ -172,6 +174,7 @@ final class ChatArtifactViewerModel { guard path == activePath else { return } if !text.isEmpty { textChunks.append(text) + textLineIndex.append(text) } updateProgress(for: chunk) textReachedEOF = chunk.eof diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index afd4414f5fd2..a57531a9f935 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -27,6 +27,11 @@ struct ChatArtifactViewerRouteView: View { @State private var searchSummary = ChatArtifactSearchSummary.empty @State private var previousSearchRequestID = 0 @State private var nextSearchRequestID = 0 + @State private var showsLineNumbers = true + @State private var isGoToLinePresented = false + @State private var goToLineText = "" + @State private var goToLineUTF16Offset = 0 + @State private var goToLineRequestID = 0 var body: some View { content @@ -48,6 +53,7 @@ struct ChatArtifactViewerRouteView: View { if isSearchPresented { dismissSearch() } else { + dismissGoToLine() isSearchPresented = true } } @@ -61,6 +67,48 @@ struct ChatArtifactViewerRouteView: View { systemImage: "magnifyingglass" ) } + Menu { + Button { + withAnimation(.snappy) { + if isGoToLinePresented { + dismissGoToLine() + } else { + dismissSearch() + isGoToLinePresented = true + } + } + } label: { + Label( + String( + localized: "chat.artifact.line.goto", + defaultValue: "Go to line", + bundle: .module + ), + systemImage: "text.line.first.and.arrowtriangle.forward" + ) + } + Button { + showsLineNumbers.toggle() + } label: { + Label( + String( + localized: "chat.artifact.line.numbers", + defaultValue: "Line numbers", + bundle: .module + ), + systemImage: showsLineNumbers ? "checkmark" : "number" + ) + } + } label: { + Label( + String( + localized: "chat.artifact.text_options", + defaultValue: "Text options", + bundle: .module + ), + systemImage: "textformat" + ) + } Button { topRequestID += 1 } label: { @@ -197,6 +245,7 @@ struct ChatArtifactViewerRouteView: View { streamingProgressHeader } searchBar + goToLineBar highlightingStatusPill rawTextView } @@ -209,6 +258,7 @@ struct ChatArtifactViewerRouteView: View { ChatArtifactMarkdownView(markdown: model.renderedText) } else { searchBar + goToLineBar highlightingStatusPill rawTextView } @@ -285,6 +335,10 @@ struct ChatArtifactViewerRouteView: View { onSearchSummaryChanged: { summary in searchSummary = summary }, + lineIndex: model.textLineIndex, + showsLineNumbers: showsLineNumbers, + goToLineUTF16Offset: goToLineUTF16Offset, + goToLineRequestID: goToLineRequestID, topRequestID: topRequestID, bottomRequestID: bottomRequestID ) @@ -299,6 +353,22 @@ struct ChatArtifactViewerRouteView: View { #endif } + @ViewBuilder + private var goToLineBar: some View { + if isGoToLinePresented { + ChatArtifactGoToLineBar( + lineText: $goToLineText, + onGo: goToLine, + onClose: { + withAnimation(.snappy) { + dismissGoToLine() + } + } + ) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + @ViewBuilder private var searchBar: some View { if isSearchPresented { @@ -400,6 +470,7 @@ struct ChatArtifactViewerRouteView: View { set: { mode in if mode == .rendered { dismissSearch() + dismissGoToLine() } model.selectMarkdownMode(mode) } @@ -412,6 +483,18 @@ struct ChatArtifactViewerRouteView: View { searchSummary = .empty } + private func dismissGoToLine() { + isGoToLinePresented = false + goToLineText = "" + } + + private func goToLine(_ requestedLine: Int) { + let line = model.textLineIndex.clampedLine(requestedLine) + goToLineText = String(line) + goToLineUTF16Offset = model.textLineIndex.offset(forLine: line) + goToLineRequestID += 1 + } + private var shouldShowTextJumpControls: Bool { model.state == .text || (model.state == .markdown && model.markdownPresentation.mode == .raw) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index ed6a0c785e46..ed89840bf04e 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -290,6 +290,91 @@ } } }, + "chat.artifact.line.close" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close go to line" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "行へ移動を閉じる" + } + } + } + }, + "chat.artifact.line.go" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Go" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "移動" + } + } + } + }, + "chat.artifact.line.goto" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Go to line" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "行へ移動" + } + } + } + }, + "chat.artifact.line.numbers" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Line numbers" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "行番号" + } + } + } + }, + "chat.artifact.line.placeholder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Line number" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "行番号" + } + } + } + }, "chat.artifact.loading" : { "extractionState" : "manual", "localizations" : { @@ -613,6 +698,23 @@ } } }, + "chat.artifact.text_options" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Text options" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "テキストオプション" + } + } + } + }, "chat.artifact.view_file" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLineIndexTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLineIndexTests.swift new file mode 100644 index 000000000000..54c98e512eac --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLineIndexTests.swift @@ -0,0 +1,36 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact line index") +struct ChatArtifactLineIndexTests { + @Test("line starts accumulate incrementally in UTF-16 offsets") + func appendsLineStarts() { + var index = ChatArtifactLineIndex() + + index.append("one\n🙂") + #expect(index.lineStartOffsets == [0, 4]) + #expect(index.loadedUTF16Length == 6) + index.append("\nthree\n") + + #expect(index.lineStartOffsets == [0, 4, 7, 13]) + #expect(index.loadedUTF16Length == 13) + #expect(index.lineCount == 4) + #expect(index.lineNumber(containingUTF16Offset: 8) == 3) + } + + @Test("go to line clamps against lines loaded so far") + func clampsDuringStreaming() { + var index = ChatArtifactLineIndex() + index.append("first\nsecond") + + #expect(index.clampedLine(-4) == 1) + #expect(index.offset(forLine: -4) == 0) + #expect(index.clampedLine(200) == 2) + #expect(index.offset(forLine: 200) == 6) + + index.append("\nthird") + #expect(index.clampedLine(3) == 3) + #expect(index.offset(forLine: 3) == 13) + } +} From a47f69647bc6d5a18256728e395ea6e21bce6c4e Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 15:12:47 -0700 Subject: [PATCH 28/92] feat(ios): persist artifact text layout --- .../ChatArtifactSyntaxHighlightPolicy.swift | 9 ++- .../ChatArtifactTextContainerView.swift | 49 +++++++++++++ .../ChatArtifactTextLayoutKind.swift | 23 ++++++ .../ChatArtifactTextPreferences.swift | 61 ++++++++++++++++ .../Artifacts/ChatArtifactTextView.swift | 8 +++ .../ChatArtifactTextViewCoordinator.swift | 72 +++++++++++++++++++ .../ChatArtifactViewerRouteView.swift | 46 ++++++++++++ .../Resources/Localizable.xcstrings | 17 +++++ .../ChatArtifactTextPreferencesTests.swift | 51 +++++++++++++ 9 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextLayoutKind.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextPreferences.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextPreferencesTests.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlightPolicy.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlightPolicy.swift index 6a47b2714ccf..363a2d9b50c6 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlightPolicy.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlightPolicy.swift @@ -69,8 +69,7 @@ struct ChatArtifactSyntaxHighlightPolicy: Sendable { return .skippedForSize } - let pathExtension = URL(fileURLWithPath: path).pathExtension.lowercased() - if let language = Self.languagesByExtension[pathExtension] { + if let language = inferredLanguage(path: path) { return .highlight(language: language) } if byteCount < Self.maxAutomaticDetectionBytes { @@ -78,4 +77,10 @@ struct ChatArtifactSyntaxHighlightPolicy: Sendable { } return .skippedNoLanguage } + + /// Returns a highlight.js language inferred strictly from the path extension. + func inferredLanguage(path: String) -> String? { + let pathExtension = URL(fileURLWithPath: path).pathExtension.lowercased() + return Self.languagesByExtension[pathExtension] + } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift index 926855373595..ce8c3dc47699 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift @@ -8,6 +8,7 @@ final class ChatArtifactTextContainerView: UIView { let gutterView = ChatArtifactLineNumberGutterView() private let gutterWidthConstraint: NSLayoutConstraint + private var wrapsLines = true override init(frame: CGRect) { textView = UITextView(usingTextLayoutManager: false) @@ -51,6 +52,54 @@ final class ChatArtifactTextContainerView: UIView { override func layoutSubviews() { super.layoutSubviews() + if wrapsLines { + textView.textContainer.size = CGSize( + width: max( + 0, + textView.bounds.width + - textView.textContainerInset.left + - textView.textContainerInset.right + ), + height: .greatestFiniteMagnitude + ) + } + gutterView.setNeedsDisplay() + } + + /// Switches between viewport-width wrapping and an unbounded horizontal container. + func updateWordWrap(_ wrapsLines: Bool) { + guard self.wrapsLines != wrapsLines else { return } + self.wrapsLines = wrapsLines + let contentOffset = textView.contentOffset + textView.textContainer.widthTracksTextView = wrapsLines + textView.textContainer.lineBreakMode = wrapsLines ? .byWordWrapping : .byClipping + textView.alwaysBounceHorizontal = !wrapsLines + textView.showsHorizontalScrollIndicator = !wrapsLines + if wrapsLines { + textView.textContainer.size = CGSize( + width: max( + 0, + textView.bounds.width + - textView.textContainerInset.left + - textView.textContainerInset.right + ), + height: .greatestFiniteMagnitude + ) + textView.setContentOffset( + CGPoint(x: -textView.adjustedContentInset.left, y: contentOffset.y), + animated: false + ) + } else { + textView.textContainer.size = CGSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + textView.setContentOffset(contentOffset, animated: false) + } + textView.layoutManager.invalidateLayout( + forCharacterRange: NSRange(location: 0, length: textView.textStorage.length), + actualCharacterRange: nil + ) gutterView.setNeedsDisplay() } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextLayoutKind.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextLayoutKind.swift new file mode 100644 index 000000000000..a6fa42cc5dc0 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextLayoutKind.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Persistence buckets for artifact text layout choices. +enum ChatArtifactTextLayoutKind: String, Equatable, Sendable { + case code + case log + case plainText + + init(path: String) { + let pathExtension = URL(fileURLWithPath: path).pathExtension.lowercased() + if pathExtension == "log" || pathExtension == "out" { + self = .log + } else if ChatArtifactSyntaxHighlightPolicy().inferredLanguage(path: path) != nil { + self = .code + } else { + self = .plainText + } + } + + var defaultWrapsLines: Bool { + self != .log + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextPreferences.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextPreferences.swift new file mode 100644 index 000000000000..9ccd0f04650c --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextPreferences.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Stores independent wrap and font-size choices for each artifact text kind. +struct ChatArtifactTextPreferences { + static let minimumFontSize = 8.0 + static let maximumFontSize = 28.0 + static let defaultFontSize = 15.0 + + private let defaults: UserDefaults + private let keyPrefix: String + + init( + defaults: UserDefaults, + keyPrefix: String = "cmux.artifactText" + ) { + self.defaults = defaults + self.keyPrefix = keyPrefix + } + + /// Returns the saved wrap choice or the kind-specific default. + func wrapsLines(for kind: ChatArtifactTextLayoutKind) -> Bool { + let key = wrapKey(for: kind) + guard defaults.object(forKey: key) != nil else { + return kind.defaultWrapsLines + } + return defaults.bool(forKey: key) + } + + /// Saves an explicit wrap choice for one text kind. + func setWrapsLines(_ wrapsLines: Bool, for kind: ChatArtifactTextLayoutKind) { + defaults.set(wrapsLines, forKey: wrapKey(for: kind)) + } + + /// Returns the saved, clamped monospaced font size for one text kind. + func fontSize(for kind: ChatArtifactTextLayoutKind) -> Double { + guard defaults.object(forKey: fontSizeKey(for: kind)) != nil else { + return Self.defaultFontSize + } + return Self.clamped(defaults.double(forKey: fontSizeKey(for: kind))) + } + + /// Saves and returns a clamped monospaced font size for one text kind. + @discardableResult + func setFontSize(_ fontSize: Double, for kind: ChatArtifactTextLayoutKind) -> Double { + let clamped = Self.clamped(fontSize) + defaults.set(clamped, forKey: fontSizeKey(for: kind)) + return clamped + } + + private func wrapKey(for kind: ChatArtifactTextLayoutKind) -> String { + "\(keyPrefix).wrap.\(kind.rawValue)" + } + + private func fontSizeKey(for kind: ChatArtifactTextLayoutKind) -> String { + "\(keyPrefix).fontSize.\(kind.rawValue)" + } + + private static func clamped(_ fontSize: Double) -> Double { + min(max(fontSize, minimumFontSize), maximumFontSize) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift index 8f4a5c7c7bb2..878197da6699 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift @@ -17,6 +17,9 @@ struct ChatArtifactTextView: UIViewRepresentable { let showsLineNumbers: Bool let goToLineUTF16Offset: Int let goToLineRequestID: Int + let wrapsLines: Bool + let fontPointSize: Double + let onFontSizeChanged: (Double) -> Void let topRequestID: Int let bottomRequestID: Int @@ -30,11 +33,13 @@ struct ChatArtifactTextView: UIViewRepresentable { let containerView = ChatArtifactTextContainerView() containerView.textView.delegate = context.coordinator context.coordinator.attach(containerView) + context.coordinator.onFontSizeChanged = onFontSizeChanged return containerView } func updateUIView(_ containerView: ChatArtifactTextContainerView, context: Context) { let textView = containerView.textView + context.coordinator.onFontSizeChanged = onFontSizeChanged let isNewDocument = context.coordinator.documentID != documentID if isNewDocument { context.coordinator.resetHighlighting() @@ -55,6 +60,9 @@ struct ChatArtifactTextView: UIViewRepresentable { textView.textStorage.setAttributedString(NSAttributedString()) } + containerView.updateWordWrap(wrapsLines) + context.coordinator.updateFontSize(in: textView, pointSize: fontPointSize) + let font = textView.font ?? UIFont.monospacedSystemFont( ofSize: UIFont.preferredFont(forTextStyle: .body).pointSize, weight: .regular diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift index bcc393f3c958..f59e2d90e502 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift @@ -10,6 +10,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { var handledTopRequestID = 0 var handledBottomRequestID = 0 var handledGoToLineRequestID = 0 + var onFontSizeChanged: ((Double) -> Void)? private weak var containerView: ChatArtifactTextContainerView? private let syntaxHighlighter = ChatArtifactSyntaxHighlighter() private var highlightTask: Task? @@ -37,6 +38,13 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { private var publishedSearchSummary = ChatArtifactSearchSummary.empty private var summaryPublishGeneration = 0 private let searchDebounce: Duration + private var appliedFontPointSize = 0.0 + private var pinchStartFontPointSize = 0.0 + private lazy var fontPinchGestureRecognizer: UIPinchGestureRecognizer = { + let recognizer = UIPinchGestureRecognizer(target: self, action: #selector(handleFontPinch(_:))) + recognizer.cancelsTouchesInView = false + return recognizer + }() init(searchDebounce: Duration = .milliseconds(160)) { self.searchDebounce = searchDebounce @@ -45,12 +53,76 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { func attach(_ containerView: ChatArtifactTextContainerView) { self.containerView = containerView + containerView.textView.addGestureRecognizer(fontPinchGestureRecognizer) } func scrollViewDidScroll(_ scrollView: UIScrollView) { containerView?.gutterView.setNeedsDisplay() } + func updateFontSize(in textView: UITextView, pointSize: Double) { + let clamped = min( + max(pointSize, ChatArtifactTextPreferences.minimumFontSize), + ChatArtifactTextPreferences.maximumFontSize + ) + guard abs(appliedFontPointSize - clamped) > 0.001 else { return } + applyFontSize(clamped, to: textView) + } + + @objc + private func handleFontPinch(_ recognizer: UIPinchGestureRecognizer) { + guard let textView = containerView?.textView else { return } + switch recognizer.state { + case .began: + pinchStartFontPointSize = appliedFontPointSize > 0 + ? appliedFontPointSize + : Double(textView.font?.pointSize ?? 15) + case .changed, .ended: + let scaled = pinchStartFontPointSize * Double(recognizer.scale) + let quantized = (scaled * 2).rounded() / 2 + let clamped = min( + max(quantized, ChatArtifactTextPreferences.minimumFontSize), + ChatArtifactTextPreferences.maximumFontSize + ) + if abs(clamped - appliedFontPointSize) > 0.001 { + applyFontSize(clamped, to: textView) + } + if recognizer.state == .ended { + onFontSizeChanged?(clamped) + } + case .cancelled, .failed: + applyFontSize(pinchStartFontPointSize, to: textView) + default: + break + } + } + + private func applyFontSize(_ pointSize: Double, to textView: UITextView) { + let font = UIFont.monospacedSystemFont( + ofSize: CGFloat(pointSize), + weight: .regular + ) + let contentOffset = textView.contentOffset + let selection = textView.selectedRange + textView.font = font + if textView.textStorage.length > 0 { + textView.textStorage.addAttribute( + .font, + value: font, + range: NSRange(location: 0, length: textView.textStorage.length) + ) + } + textView.selectedRange = selection + textView.setContentOffset(contentOffset, animated: false) + appliedFontPointSize = pointSize + if let containerView { + containerView.updateLineNumbers( + index: containerView.gutterView.lineIndex, + isVisible: !containerView.gutterView.isHidden + ) + } + } + func resetHighlighting() { highlightTask?.cancel() highlightTask = nil diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index a57531a9f935..8809f73b99a3 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -15,6 +15,8 @@ struct ChatArtifactViewerRouteView: View { let path: String let scope: ChatArtifactViewerScope let onDone: () -> Void + private let textPreferences: ChatArtifactTextPreferences + private let textLayoutKind: ChatArtifactTextLayoutKind @Environment(\.chatArtifactLoader) private var loader @Environment(\.colorScheme) private var colorScheme @@ -32,6 +34,26 @@ struct ChatArtifactViewerRouteView: View { @State private var goToLineText = "" @State private var goToLineUTF16Offset = 0 @State private var goToLineRequestID = 0 + @State private var wrapsLines: Bool + @State private var textFontSize: Double + + init( + path: String, + scope: ChatArtifactViewerScope, + textPreferences: ChatArtifactTextPreferences = ChatArtifactTextPreferences( + defaults: .standard + ), + onDone: @escaping () -> Void + ) { + self.path = path + self.scope = scope + self.onDone = onDone + self.textPreferences = textPreferences + let layoutKind = ChatArtifactTextLayoutKind(path: path) + textLayoutKind = layoutKind + _wrapsLines = State(initialValue: textPreferences.wrapsLines(for: layoutKind)) + _textFontSize = State(initialValue: textPreferences.fontSize(for: layoutKind)) + } var body: some View { content @@ -99,6 +121,22 @@ struct ChatArtifactViewerRouteView: View { systemImage: showsLineNumbers ? "checkmark" : "number" ) } + Button { + wrapsLines.toggle() + textPreferences.setWrapsLines( + wrapsLines, + for: textLayoutKind + ) + } label: { + Label( + String( + localized: "chat.artifact.wrap", + defaultValue: "Word wrap", + bundle: .module + ), + systemImage: wrapsLines ? "checkmark" : "text.justify.left" + ) + } } label: { Label( String( @@ -339,6 +377,14 @@ struct ChatArtifactViewerRouteView: View { showsLineNumbers: showsLineNumbers, goToLineUTF16Offset: goToLineUTF16Offset, goToLineRequestID: goToLineRequestID, + wrapsLines: wrapsLines, + fontPointSize: textFontSize, + onFontSizeChanged: { fontSize in + textFontSize = textPreferences.setFontSize( + fontSize, + for: textLayoutKind + ) + }, topRequestID: topRequestID, bottomRequestID: bottomRequestID ) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index ed89840bf04e..6eab057c5d18 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -2380,6 +2380,23 @@ } } } + }, + "chat.artifact.wrap" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Word wrap" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "行を折り返す" + } + } + } } }, "version" : "1.0" diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextPreferencesTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextPreferencesTests.swift new file mode 100644 index 000000000000..54af0643769f --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextPreferencesTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact text preferences", .serialized) +struct ChatArtifactTextPreferencesTests { + @Test("log defaults differ and explicit wrap choices persist per kind") + func wrapPersistence() throws { + let suiteName = "ChatArtifactTextPreferencesTests.wrap.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let preferences = ChatArtifactTextPreferences(defaults: defaults, keyPrefix: "test") + + #expect(!preferences.wrapsLines(for: .log)) + #expect(preferences.wrapsLines(for: .code)) + #expect(preferences.wrapsLines(for: .plainText)) + + preferences.setWrapsLines(true, for: .log) + preferences.setWrapsLines(false, for: .code) + let reloaded = ChatArtifactTextPreferences(defaults: defaults, keyPrefix: "test") + #expect(reloaded.wrapsLines(for: .log)) + #expect(!reloaded.wrapsLines(for: .code)) + #expect(reloaded.wrapsLines(for: .plainText)) + } + + @Test("font sizes persist independently and clamp to the supported range") + func fontPersistence() throws { + let suiteName = "ChatArtifactTextPreferencesTests.font.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let preferences = ChatArtifactTextPreferences(defaults: defaults, keyPrefix: "test") + + #expect(preferences.fontSize(for: .code) == ChatArtifactTextPreferences.defaultFontSize) + #expect(preferences.setFontSize(40, for: .code) == 28) + #expect(preferences.setFontSize(6, for: .log) == 8) + + let reloaded = ChatArtifactTextPreferences(defaults: defaults, keyPrefix: "test") + #expect(reloaded.fontSize(for: .code) == 28) + #expect(reloaded.fontSize(for: .log) == 8) + #expect(reloaded.fontSize(for: .plainText) == ChatArtifactTextPreferences.defaultFontSize) + } + + @Test("paths resolve to stable preference kinds") + func kindResolution() { + #expect(ChatArtifactTextLayoutKind(path: "/tmp/build.log") == .log) + #expect(ChatArtifactTextLayoutKind(path: "/tmp/process.OUT") == .log) + #expect(ChatArtifactTextLayoutKind(path: "/tmp/main.swift") == .code) + #expect(ChatArtifactTextLayoutKind(path: "/tmp/README") == .plainText) + } +} From 284870b462083f50021ea1c56e3e99c0c5ff20f8 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 15:40:41 -0700 Subject: [PATCH 29/92] chore: retrigger CI after Actions stall Co-Authored-By: Claude Fable 5 From aa5e74436a5aef647e200788cdb3218a591d67f2 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 15:54:47 -0700 Subject: [PATCH 30/92] feat(ios): anchor the files chip to the terminal top Pinned above the bottom toolbar it covered the input row users type into; anchored at the top safe-area inset instead, sliding in from the top edge. Co-Authored-By: Claude Fable 5 --- .../GhosttySurfaceArtifactChipHost.swift | 11 +++++++---- .../CmuxMobileTerminal/GhosttySurfaceView.swift | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceArtifactChipHost.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceArtifactChipHost.swift index 9c4e4420eacf..a5ab950318ba 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceArtifactChipHost.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceArtifactChipHost.swift @@ -28,7 +28,10 @@ final class GhosttySurfaceArtifactChipHost { container.addSubview(view) } - func layout(in bounds: CGRect, toolbarTop: CGFloat) { + // Anchored to the terminal's top edge: pinned above the toolbar it covered + // the input row, which users type into far more often than they read the + // first terminal line. + func layout(in bounds: CGRect, topInset: CGFloat) { guard let content = container.subviews.first else { container.frame = .zero return @@ -43,7 +46,7 @@ final class GhosttySurfaceArtifactChipHost { let height = max(44, fitting.height) container.frame = CGRect( x: (bounds.width - width) / 2, - y: max(8, toolbarTop - height - 8), + y: max(8, topInset + 8), width: width, height: height ).integral @@ -61,7 +64,7 @@ final class GhosttySurfaceArtifactChipHost { } if animated { if container.alpha < 0.01 { - container.transform = CGAffineTransform(translationX: 0, y: 8) + container.transform = CGAffineTransform(translationX: 0, y: -8) } UIView.animate( withDuration: 0.2, @@ -79,7 +82,7 @@ final class GhosttySurfaceArtifactChipHost { container.accessibilityElementsHidden = true let changes = { [weak self] in self?.container.alpha = 0 - self?.container.transform = CGAffineTransform(translationX: 0, y: 8) + self?.container.transform = CGAffineTransform(translationX: 0, y: -8) } let completion: (Bool) -> Void = { [weak self] _ in guard let self, !self.visibilityRequested else { return } diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 9c994ecd272c..ced97c2793c5 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -1358,7 +1358,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } private func layoutArtifactChip(using snapshot: TerminalViewportSnapshot) { - artifactChipHost.layout(in: bounds, toolbarTop: snapshot.toolbarFrame.minY) + artifactChipHost.layout(in: bounds, topInset: safeAreaInsets.top) } private var artifactChipShouldBeVisible: Bool { From 3638eb7ec0ad4bb59477e41b5d2e7d07b7c97ae0 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 16:01:14 -0700 Subject: [PATCH 31/92] feat(ios): classify gallery filter buckets --- .../ChatArtifactGalleryClassifier.swift | 59 +++++++++++++++++++ .../Artifacts/ChatArtifactGalleryFilter.swift | 15 +++++ .../ChatArtifactGalleryClassifierTests.swift | 35 +++++++++++ 3 files changed, 109 insertions(+) create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryClassifier.swift create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryFilter.swift create mode 100644 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryClassifierTests.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryClassifier.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryClassifier.swift new file mode 100644 index 000000000000..38acdee125c7 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryClassifier.swift @@ -0,0 +1,59 @@ +import Foundation +import UniformTypeIdentifiers + +/// Classifies gallery artifacts into the dedicated sheet filter buckets. +public struct ChatArtifactGalleryClassifier: Sendable { + private let logExtensions: Set = ["log", "out", "txt"] + private let documentExtensions: Set = [ + "doc", "docx", "key", "md", "markdown", "mdown", "mkd", "numbers", + "odp", "ods", "odt", "pages", "pdf", "ppt", "pptx", "rtf", "xls", "xlsx", + ] + + /// Creates an artifact classifier. + public init() {} + + /// Returns the artifact's dedicated filter bucket, if it has one. + /// + /// Image and directory kinds take precedence over filename extensions. + /// Artifacts returning `nil` remain visible under ``ChatArtifactGalleryFilter/all``. + /// + /// - Parameters: + /// - kind: Preview kind assigned by the artifact host. + /// - path: Artifact path whose extension supplements the preview kind. + /// - Returns: A dedicated filter bucket, or `nil` for All-only artifacts. + public func filter( + for kind: ChatArtifactKind, + path: String + ) -> ChatArtifactGalleryFilter? { + switch kind { + case .image: + return .images + case .directory: + return .folders + case .text, .binary: + break + } + + let pathExtension = URL(fileURLWithPath: path).pathExtension.lowercased() + guard !pathExtension.isEmpty else { return nil } + if let type = UTType(filenameExtension: pathExtension), + type.conforms(to: .sourceCode) { + return .code + } + if logExtensions.contains(pathExtension) { + return .logs + } + if documentExtensions.contains(pathExtension) { + return .docs + } + return nil + } + + /// Returns the artifact's dedicated filter bucket, if it has one. + /// + /// - Parameter item: Gallery item to classify. + /// - Returns: A dedicated filter bucket, or `nil` for All-only artifacts. + public func filter(for item: ChatArtifactGalleryItem) -> ChatArtifactGalleryFilter? { + filter(for: item.kind, path: item.path) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryFilter.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryFilter.swift new file mode 100644 index 000000000000..c177a4db5de6 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryFilter.swift @@ -0,0 +1,15 @@ +/// A sheet-wide artifact-kind filter applied within every gallery group. +public enum ChatArtifactGalleryFilter: String, CaseIterable, Sendable, Equatable { + /// Includes every artifact, including kinds without a dedicated filter. + case all + /// Includes image artifacts. + case images + /// Includes source-code artifacts. + case code + /// Includes log and plain-text artifacts. + case logs + /// Includes document artifacts. + case docs + /// Includes directory artifacts. + case folders +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryClassifierTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryClassifierTests.swift new file mode 100644 index 000000000000..e11925e5395b --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryClassifierTests.swift @@ -0,0 +1,35 @@ +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact gallery classifier") +struct ChatArtifactGalleryClassifierTests { + struct Fixture: Sendable, CustomTestStringConvertible { + let path: String + let kind: ChatArtifactKind + let expected: ChatArtifactGalleryFilter? + + var testDescription: String { "\(kind.rawValue):\(path)" } + } + + @Test(arguments: [ + Fixture(path: "/tmp/photo.swift", kind: .image, expected: .images), + Fixture(path: "/tmp/folder.png", kind: .directory, expected: .folders), + Fixture(path: "/tmp/App.swift", kind: .text, expected: .code), + Fixture(path: "/tmp/main.CPP", kind: .binary, expected: .code), + Fixture(path: "/tmp/run.log", kind: .text, expected: .logs), + Fixture(path: "/tmp/process.OUT", kind: .binary, expected: .logs), + Fixture(path: "/tmp/notes.txt", kind: .text, expected: .logs), + Fixture(path: "/tmp/README.md", kind: .text, expected: .docs), + Fixture(path: "/tmp/report.PDF", kind: .binary, expected: .docs), + Fixture(path: "/tmp/deck.pptx", kind: .binary, expected: .docs), + Fixture(path: "/tmp/table.numbers", kind: .binary, expected: .docs), + Fixture(path: "/tmp/archive.zip", kind: .binary, expected: nil), + Fixture(path: "/tmp/LICENSE", kind: .text, expected: nil), + ]) + func classifiesKindAndExtensionMatrix(_ fixture: Fixture) { + let classifier = ChatArtifactGalleryClassifier() + + #expect(classifier.filter(for: fixture.kind, path: fixture.path) == fixture.expected) + } +} From 7e755c52def54d8573f87ba330c99071193fac42 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 16:03:28 -0700 Subject: [PATCH 32/92] fix: type extensionless media temp files from the wire MIME A MIME-only media artifact routed to the player but streamed into a suffixless temp file that AVPlayer cannot type. Derive the fallback extension from the normalized wire MIME's UTType, sharing one MIME normalization path in the preview router. Co-Authored-By: Claude Fable 5 --- .../Artifacts/ChatArtifactPreviewRouter.swift | 28 +++++++++++++++---- .../Artifacts/ChatArtifactViewerModel.swift | 3 +- .../ChatArtifactPreviewRouterTests.swift | 10 +++++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRouter.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRouter.swift index f04937e1a17f..57984065781a 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRouter.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRouter.swift @@ -17,11 +17,7 @@ struct ChatArtifactPreviewRouter: Sendable { } let fileExtension = URL(fileURLWithPath: path).pathExtension.lowercased() - let mimeType = stat.mimeType? - .split(separator: ";", maxSplits: 1) - .first? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() + let mimeType = Self.normalizedMIMEType(stat.mimeType) if mimeType == "application/pdf" || fileExtension == "pdf" { return .pdf } @@ -42,8 +38,30 @@ struct ChatArtifactPreviewRouter: Sendable { return .binary } + /// Preferred filename extension for a wire MIME type, used to type + /// extensionless temporary files for preview frameworks. + func preferredExtension(forMIMEType rawMIMEType: String?) -> String? { + guard let mimeType = Self.normalizedMIMEType(rawMIMEType), + let type = UTType(mimeType: mimeType) else { + return nil + } + return type.preferredFilenameExtension + } + private func isMedia(_ type: UTType?) -> Bool { guard let type else { return false } return type.conforms(to: .movie) || type.conforms(to: .audio) } + + private static func normalizedMIMEType(_ rawValue: String?) -> String? { + guard let normalized = rawValue? + .split(separator: ";", maxSplits: 1) + .first? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !normalized.isEmpty else { + return nil + } + return normalized + } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift index 53e5d8dd35c0..d2068cb2076a 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift @@ -83,7 +83,8 @@ final class ChatArtifactViewerModel { path: path, expectedSize: loadedStat.size, limit: limit, - fallbackExtension: nil, + fallbackExtension: ChatArtifactPreviewRouter() + .preferredExtension(forMIMEType: loadedStat.mimeType), loader: loader ) { state = .media(fileURL: fileURL) diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPreviewRouterTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPreviewRouterTests.swift index 6292bb595da3..171ced75e6c9 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPreviewRouterTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPreviewRouterTests.swift @@ -46,4 +46,14 @@ struct ChatArtifactPreviewRouterTests { #expect(router.route(stat: stat, path: path) == expected, "Unexpected route for \(path)") } } + + @Test + func mimeFallbackExtensionTypesExtensionlessTempFiles() { + let router = ChatArtifactPreviewRouter() + + #expect(router.preferredExtension(forMIMEType: "video/mp4") == "mp4") + #expect(router.preferredExtension(forMIMEType: "application/pdf; charset=binary") == "pdf") + #expect(router.preferredExtension(forMIMEType: "not a mime") == nil) + #expect(router.preferredExtension(forMIMEType: nil) == nil) + } } From 20759ab764819045a256b24ac62732352f968af4 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 16:05:01 -0700 Subject: [PATCH 33/92] perf: bound folder listing metadata reads to the cap contentsOfDirectory previously prefetched resource keys for every child before sorting URL objects. List child names only, sort names in memory, and read isDirectory/size metadata just for the capped 500 entries the listing returns. Co-Authored-By: Claude Fable 5 --- .../Artifacts/ArtifactByteReader.swift | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift index ea701a7bcb3c..b640c9dda351 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift @@ -101,25 +101,25 @@ public struct ArtifactByteReader: Sendable { /// Lists up to ``maximumDirectoryEntryCount`` immediate children for an /// already-authorized directory. + /// + /// One readdir pass collects child names; per-child filesystem metadata is + /// read only for the capped entries that the listing actually returns. public func list(path: String) throws -> ChatArtifactDirectoryListing { let stat = try stat(path: path) guard stat.isDirectory else { throw Error.fileNotFound } - let url = URL(fileURLWithPath: path, isDirectory: true) - let entries = try FileManager.default.contentsOfDirectory( - at: url, - includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], - options: [] - ) - let sortedEntries = entries.sorted { - $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending + let names = try FileManager.default.contentsOfDirectory(atPath: path) + let sortedNames = names.sorted { + $0.localizedStandardCompare($1) == .orderedAscending } - let listed = try sortedEntries + let directoryURL = URL(fileURLWithPath: path, isDirectory: true) + let listed = try sortedNames .prefix(Self.maximumDirectoryEntryCount) - .map { entry -> ChatArtifactDirectoryEntry in + .map { name -> ChatArtifactDirectoryEntry in + let entry = directoryURL.appendingPathComponent(name) let values = try entry.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey]) let isDirectory = values.isDirectory ?? false return ChatArtifactDirectoryEntry( - name: entry.lastPathComponent, + name: name, isDirectory: isDirectory, size: Int64(values.fileSize ?? 0), kind: kind(path: entry.path, isDirectory: isDirectory) @@ -127,7 +127,7 @@ public struct ArtifactByteReader: Sendable { } return ChatArtifactDirectoryListing( entries: listed, - isTruncated: sortedEntries.count > Self.maximumDirectoryEntryCount + isTruncated: names.count > Self.maximumDirectoryEntryCount ) } From ff97474bad8aef5e68184b2f77d774f9be6cdd41 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 16:10:52 -0700 Subject: [PATCH 34/92] feat(ios): filter and sort gallery groups --- .../ChatArtifactGalleryEagerPager.swift | 53 +++++ ...ChatArtifactGalleryEagerPagingResult.swift | 20 ++ .../Artifacts/ChatArtifactGalleryGroup.swift | 23 +++ .../ChatArtifactGalleryGroupKind.swift | 9 + .../ChatArtifactGalleryPresentation.swift | 81 ++++++++ .../ChatArtifactGallerySnapshot.swift | 11 + .../Artifacts/ChatArtifactGallerySort.swift | 9 + .../ChatArtifactGalleryEagerPagerTests.swift | 98 +++++++++ .../ChatArtifactGalleryPageScript.swift | 32 +++ ...ChatArtifactGalleryPresentationTests.swift | 79 ++++++++ .../Resources/Localizable.xcstrings | 84 ++++++++ .../TerminalArtifactFilesSheet+Content.swift | 191 ++++++++++++++++-- .../TerminalArtifactFilesSheet.swift | 96 +++++++++ 13 files changed, 769 insertions(+), 17 deletions(-) create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPager.swift create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPagingResult.swift create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGroup.swift create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGroupKind.swift create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPresentation.swift create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySort.swift create mode 100644 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryEagerPagerTests.swift create mode 100644 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPageScript.swift create mode 100644 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPresentationTests.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPager.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPager.swift new file mode 100644 index 000000000000..4be476a1db1b --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPager.swift @@ -0,0 +1,53 @@ +/// Sequentially loads referenced gallery pages for complete filtering and sorting. +public struct ChatArtifactGalleryEagerPager: Sendable { + /// Default maximum referenced rows retained during eager loading. + public static let defaultMaximumReferencedRows = 2_000 + + private let maximumReferencedRows: Int + + /// Creates an eager pager. + /// + /// - Parameter maximumReferencedRows: Maximum referenced rows to retain. + public init( + maximumReferencedRows: Int = Self.defaultMaximumReferencedRows + ) { + self.maximumReferencedRows = max(0, maximumReferencedRows) + } + + /// Loads cursor pages sequentially until exhausted or capped. + /// + /// A repeated cursor ends the loop defensively while preserving that cursor + /// for a later retry rather than issuing the same request indefinitely. + /// + /// - Parameters: + /// - initialSnapshot: First page or already accumulated gallery snapshot. + /// - fetchPage: Async operation for one opaque cursor. + /// - Returns: The accumulated snapshot and cap status. + /// - Throws: The first page-loading error or cancellation. + public func loadRemaining( + from initialSnapshot: ChatArtifactGallerySnapshot, + fetchPage: @escaping @Sendable (_ cursor: String) async throws -> ChatArtifactGalleryPage + ) async throws -> ChatArtifactGalleryEagerPagingResult { + var snapshot = initialSnapshot.limitingReferenced(to: maximumReferencedRows) + var seenCursors: Set = [] + var truncatedRows = initialSnapshot.referenced.count > snapshot.referenced.count + + while let cursor = snapshot.nextCursor, + snapshot.referenced.count < maximumReferencedRows, + seenCursors.insert(cursor).inserted { + try Task.checkCancellation() + let page = try await fetchPage(cursor) + try Task.checkCancellation() + let appended = snapshot.appending(page) + truncatedRows = truncatedRows || appended.referenced.count > maximumReferencedRows + snapshot = appended.limitingReferenced(to: maximumReferencedRows) + } + + let hasMoreAtCap = snapshot.referenced.count >= maximumReferencedRows + && snapshot.nextCursor != nil + return ChatArtifactGalleryEagerPagingResult( + snapshot: snapshot, + reachedSafetyCap: truncatedRows || hasMoreAtCap + ) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPagingResult.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPagingResult.swift new file mode 100644 index 000000000000..24723c5d5331 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPagingResult.swift @@ -0,0 +1,20 @@ +/// The completed outcome of eagerly loading referenced gallery pages. +public struct ChatArtifactGalleryEagerPagingResult: Sendable, Equatable { + /// Accumulated rows and the cursor that remains after loading. + public let snapshot: ChatArtifactGallerySnapshot + /// Whether loading stopped because more than the configured row cap exists. + public let reachedSafetyCap: Bool + + /// Creates an eager-paging result. + /// + /// - Parameters: + /// - snapshot: Accumulated rows and remaining cursor. + /// - reachedSafetyCap: Whether the row cap prevented complete loading. + public init( + snapshot: ChatArtifactGallerySnapshot, + reachedSafetyCap: Bool + ) { + self.snapshot = snapshot + self.reachedSafetyCap = reachedSafetyCap + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGroup.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGroup.swift new file mode 100644 index 000000000000..b07872a96f29 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGroup.swift @@ -0,0 +1,23 @@ +/// One immutable, filtered and sorted provenance group. +public struct ChatArtifactGalleryGroup: Sendable, Equatable, Identifiable { + /// The group's stable provenance identity. + public let kind: ChatArtifactGalleryGroupKind + /// Visible rows in the group's selected ordering. + public let items: [ChatArtifactGalleryItem] + + /// Stable group identity. + public var id: ChatArtifactGalleryGroupKind { kind } + + /// Creates a gallery group. + /// + /// - Parameters: + /// - kind: Stable provenance identity. + /// - items: Visible rows in selected order. + public init( + kind: ChatArtifactGalleryGroupKind, + items: [ChatArtifactGalleryItem] + ) { + self.kind = kind + self.items = items + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGroupKind.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGroupKind.swift new file mode 100644 index 000000000000..3f8211fd8067 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGroupKind.swift @@ -0,0 +1,9 @@ +/// The fixed provenance group organization of the artifact gallery. +public enum ChatArtifactGalleryGroupKind: String, CaseIterable, Sendable, Equatable { + /// Files created by the agent. + case created + /// Files attached by the user. + case attached + /// Other files referenced by the session. + case referenced +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPresentation.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPresentation.swift new file mode 100644 index 000000000000..163e9eab6ab0 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPresentation.swift @@ -0,0 +1,81 @@ +import Foundation + +/// A pure sheet-wide filter and sort projection over fixed gallery groups. +public struct ChatArtifactGalleryPresentation: Sendable, Equatable { + /// Created, Attached, and Referenced groups in their fixed display order. + public let groups: [ChatArtifactGalleryGroup] + + /// Creates a grouped presentation without changing the source snapshot. + /// + /// - Parameters: + /// - snapshot: Accumulated gallery rows to project. + /// - filter: Sheet-wide kind filter. + /// - sort: Ordering applied independently within each group. + /// - classifier: Classifier used for extension-based buckets. + public init( + snapshot: ChatArtifactGallerySnapshot, + filter: ChatArtifactGalleryFilter = .all, + sort: ChatArtifactGallerySort = .recent, + classifier: ChatArtifactGalleryClassifier = ChatArtifactGalleryClassifier() + ) { + groups = [ + ChatArtifactGalleryGroup( + kind: .created, + items: Self.project(snapshot.created, filter: filter, sort: sort, classifier: classifier) + ), + ChatArtifactGalleryGroup( + kind: .attached, + items: Self.project(snapshot.attached, filter: filter, sort: sort, classifier: classifier) + ), + ChatArtifactGalleryGroup( + kind: .referenced, + items: Self.project(snapshot.referenced, filter: filter, sort: sort, classifier: classifier) + ), + ] + } + + /// Returns the visible rows for one fixed group. + /// + /// - Parameter kind: Group whose projected rows are needed. + /// - Returns: Visible rows, or an empty array when the group is absent. + public func items(in kind: ChatArtifactGalleryGroupKind) -> [ChatArtifactGalleryItem] { + groups.first { $0.kind == kind }?.items ?? [] + } + + private static func project( + _ items: [ChatArtifactGalleryItem], + filter: ChatArtifactGalleryFilter, + sort: ChatArtifactGallerySort, + classifier: ChatArtifactGalleryClassifier + ) -> [ChatArtifactGalleryItem] { + let filtered = filter == .all + ? items + : items.filter { classifier.filter(for: $0) == filter } + switch sort { + case .recent: + return filtered + case .name: + return filtered.enumerated().sorted { lhs, rhs in + let comparison = lhs.element.displayName.localizedCaseInsensitiveCompare( + rhs.element.displayName + ) + return comparison == .orderedSame + ? lhs.offset < rhs.offset + : comparison == .orderedAscending + }.map(\.element) + case .size: + return filtered.enumerated().sorted { lhs, rhs in + switch (lhs.element.size, rhs.element.size) { + case let (left?, right?) where left != right: + return left > right + case (_?, nil): + return true + case (nil, _?): + return false + default: + return lhs.offset < rhs.offset + } + }.map(\.element) + } + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift index e812add400f4..766c2ac97837 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift @@ -51,6 +51,17 @@ public struct ChatArtifactGallerySnapshot: Sendable, Equatable { ) } + func limitingReferenced(to maximumCount: Int) -> ChatArtifactGallerySnapshot { + ChatArtifactGallerySnapshot( + created: created, + attached: attached, + referenced: Array(referenced.prefix(max(0, maximumCount))), + referencedTotal: referencedTotal, + nextCursor: nextCursor, + generation: generation + ) + } + private init( created: [ChatArtifactGalleryItem], attached: [ChatArtifactGalleryItem], diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySort.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySort.swift new file mode 100644 index 000000000000..f01bd4563756 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySort.swift @@ -0,0 +1,9 @@ +/// A sheet-wide ordering applied independently within every artifact group. +public enum ChatArtifactGallerySort: String, CaseIterable, Sendable, Equatable { + /// Preserves the host's descending last-reference sequence order. + case recent + /// Orders names ascending, case-insensitively. + case name + /// Orders known sizes largest-first, followed by unknown sizes. + case size +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryEagerPagerTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryEagerPagerTests.swift new file mode 100644 index 000000000000..3c91516673a2 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryEagerPagerTests.swift @@ -0,0 +1,98 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact gallery eager pager") +struct ChatArtifactGalleryEagerPagerTests { + @Test("fetches cursors sequentially until exhausted") + func fetchesUntilExhausted() async throws { + let script = ChatArtifactGalleryPageScript(pages: [ + "cursor-1": page(paths: ["/two"], nextCursor: "cursor-2"), + "cursor-2": page(paths: ["/three"], nextCursor: nil), + ]) + let initial = snapshot(paths: ["/one"], nextCursor: "cursor-1", total: 3) + + let result = try await ChatArtifactGalleryEagerPager().loadRemaining(from: initial) { cursor in + try await script.fetch(cursor: cursor) + } + + #expect(result.snapshot.referenced.map { $0.path } == ["/one", "/two", "/three"]) + #expect(result.snapshot.nextCursor == nil) + #expect(!result.reachedSafetyCap) + #expect(await script.requestedCursors() == ["cursor-1", "cursor-2"]) + } + + @Test("stops at the safety cap and retains the next cursor") + func respectsSafetyCap() async throws { + let script = ChatArtifactGalleryPageScript(pages: [ + "cursor-1": page(paths: ["/two", "/three"], nextCursor: "cursor-2"), + "cursor-2": page(paths: ["/four"], nextCursor: nil), + ]) + let initial = snapshot(paths: ["/one"], nextCursor: "cursor-1", total: 4) + + let result = try await ChatArtifactGalleryEagerPager( + maximumReferencedRows: 3 + ).loadRemaining(from: initial) { cursor in + try await script.fetch(cursor: cursor) + } + + #expect(result.snapshot.referenced.map { $0.path } == ["/one", "/two", "/three"]) + #expect(result.snapshot.nextCursor == "cursor-2") + #expect(result.reachedSafetyCap) + #expect(await script.requestedCursors() == ["cursor-1"]) + } + + @Test("caps an oversized final page even when its cursor is exhausted") + func capsOversizedFinalPage() async throws { + let script = ChatArtifactGalleryPageScript(pages: [ + "cursor-1": page(paths: ["/two", "/three"], nextCursor: nil), + ]) + let initial = snapshot(paths: ["/one"], nextCursor: "cursor-1", total: 3) + + let result = try await ChatArtifactGalleryEagerPager( + maximumReferencedRows: 2 + ).loadRemaining(from: initial) { cursor in + try await script.fetch(cursor: cursor) + } + + #expect(result.snapshot.referenced.map { $0.path } == ["/one", "/two"]) + #expect(result.snapshot.nextCursor == nil) + #expect(result.reachedSafetyCap) + } + + private func snapshot( + paths: [String], + nextCursor: String?, + total: Int + ) -> ChatArtifactGallerySnapshot { + ChatArtifactGallerySnapshot(page: ChatArtifactGalleryPage( + sessionID: "session", + referenced: paths.map(item), + referencedTotal: total, + nextCursor: nextCursor, + generation: "generation" + )) + } + + private func page( + paths: [String], + nextCursor: String? + ) -> ChatArtifactGalleryPage { + ChatArtifactGalleryPage( + sessionID: "session", + referenced: paths.map(item), + referencedTotal: paths.count, + nextCursor: nextCursor, + generation: "generation" + ) + } + + private func item(path: String) -> ChatArtifactGalleryItem { + ChatArtifactGalleryItem( + path: path, + kind: .text, + displayName: URL(fileURLWithPath: path).lastPathComponent + ) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPageScript.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPageScript.swift new file mode 100644 index 000000000000..1b53273cc74d --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPageScript.swift @@ -0,0 +1,32 @@ +@testable import CmuxAgentChat + +/// Actor-backed page script that records sequential eager-paging requests. +actor ChatArtifactGalleryPageScript { + private let pages: [String: ChatArtifactGalleryPage] + private var cursors: [String] = [] + + /// Creates a page script keyed by opaque cursor. + /// + /// - Parameter pages: Pages returned for each expected cursor. + init(pages: [String: ChatArtifactGalleryPage]) { + self.pages = pages + } + + /// Records and resolves one cursor request. + /// + /// - Parameter cursor: Requested opaque cursor. + /// - Returns: Scripted page for the cursor. + /// - Throws: ``ChatArtifactError/invalidParams`` for an unscripted cursor. + func fetch(cursor: String) throws -> ChatArtifactGalleryPage { + cursors.append(cursor) + guard let page = pages[cursor] else { + throw ChatArtifactError.invalidParams + } + return page + } + + /// Returns cursors in request order. + func requestedCursors() -> [String] { + cursors + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPresentationTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPresentationTests.swift new file mode 100644 index 000000000000..54e6467338a9 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPresentationTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact gallery presentation") +struct ChatArtifactGalleryPresentationTests { + @Test("filtering preserves group order and hides rows within every group") + func filterPreservesGroups() { + let snapshot = gallerySnapshot( + created: [item("/created/App.swift", kind: .text, size: 10)], + attached: [item("/attached/photo.png", kind: .image, size: 20)], + referenced: [ + item("/referenced/notes.txt", kind: .text, size: 30), + item("/referenced/Tool.swift", kind: .text, size: 40), + ] + ) + + let presentation = ChatArtifactGalleryPresentation( + snapshot: snapshot, + filter: .code + ) + + #expect(presentation.groups.map(\.kind) == [.created, .attached, .referenced]) + #expect(presentation.items(in: .created).map(\.displayName) == ["App.swift"]) + #expect(presentation.items(in: .attached).isEmpty) + #expect(presentation.items(in: .referenced).map(\.displayName) == ["Tool.swift"]) + } + + @Test("name and size sorts reorder only within each group") + func sortWithinGroups() { + let created = [ + item("/created/zeta.txt", kind: .text, size: nil), + item("/created/Alpha.txt", kind: .text, size: 1), + item("/created/middle.txt", kind: .text, size: 30), + ] + let referenced = [ + item("/referenced/b.txt", kind: .text, size: 5), + item("/referenced/a.txt", kind: .text, size: 10), + ] + let snapshot = gallerySnapshot(created: created, referenced: referenced) + + let named = ChatArtifactGalleryPresentation(snapshot: snapshot, sort: .name) + #expect(named.items(in: .created).map(\.displayName) == ["Alpha.txt", "middle.txt", "zeta.txt"]) + #expect(named.items(in: .referenced).map(\.displayName) == ["a.txt", "b.txt"]) + + let sized = ChatArtifactGalleryPresentation(snapshot: snapshot, sort: .size) + #expect(sized.items(in: .created).map(\.displayName) == ["middle.txt", "Alpha.txt", "zeta.txt"]) + #expect(sized.items(in: .referenced).map(\.displayName) == ["a.txt", "b.txt"]) + } + + private func gallerySnapshot( + created: [ChatArtifactGalleryItem] = [], + attached: [ChatArtifactGalleryItem] = [], + referenced: [ChatArtifactGalleryItem] = [] + ) -> ChatArtifactGallerySnapshot { + ChatArtifactGallerySnapshot(page: ChatArtifactGalleryPage( + sessionID: "session", + created: created, + attached: attached, + referenced: referenced, + referencedTotal: referenced.count, + generation: "generation" + )) + } + + private func item( + _ path: String, + kind: ChatArtifactKind, + size: Int64? + ) -> ChatArtifactGalleryItem { + ChatArtifactGalleryItem( + path: path, + kind: kind, + displayName: URL(fileURLWithPath: path).lastPathComponent, + size: size + ) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings index beebb31dec4b..a1582ab393ad 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings @@ -138,6 +138,48 @@ } } }, + "terminal.artifact.gallery.filter.all" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "All" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "すべて" } } + } + }, + "terminal.artifact.gallery.filter.code" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Code" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "コード" } } + } + }, + "terminal.artifact.gallery.filter.docs" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Docs" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "書類" } } + } + }, + "terminal.artifact.gallery.filter.folders" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Folders" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "フォルダ" } } + } + }, + "terminal.artifact.gallery.filter.images" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Images" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "画像" } } + } + }, + "terminal.artifact.gallery.filter.logs" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Logs" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ログ" } } + } + }, "terminal.artifact.gallery.kind.binary" : { "extractionState" : "manual", "localizations" : { @@ -240,6 +282,13 @@ } } }, + "terminal.artifact.gallery.loading_all" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Loading all files…" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "すべてのファイルを読み込み中…" } } + } + }, "terminal.artifact.gallery.missing" : { "extractionState" : "manual", "localizations" : { @@ -333,6 +382,41 @@ } } }, + "terminal.artifact.gallery.showing_first" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Showing first %lld files" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "最初の%lld件のファイルを表示" } } + } + }, + "terminal.artifact.gallery.sort" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Sort" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "並べ替え" } } + } + }, + "terminal.artifact.gallery.sort.name" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Name" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "名前" } } + } + }, + "terminal.artifact.gallery.sort.recent" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Recent" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "最近" } } + } + }, + "terminal.artifact.gallery.sort.size" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Size" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "サイズ" } } + } + }, "terminal.artifact.gallery.search" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift index 723d6dfb8cdb..0d7b69d6c846 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift @@ -137,10 +137,26 @@ extension TerminalArtifactFilesSheet { @ViewBuilder private var sessionContent: some View { let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) - if query.isEmpty { - sessionSectionedContent(state: sessionState) - } else { - sessionSearchContent(state: searchState, query: query) + VStack(spacing: 0) { + galleryControls + if eagerPagingState == .loading { + ProgressView() + .progressViewStyle(.linear) + .accessibilityLabel(String( + localized: "terminal.artifact.gallery.loading_all", + defaultValue: "Loading all files…", + bundle: .module + )) + } + Divider() + if query.isEmpty { + sessionSectionedContent(state: sessionState) + } else { + sessionSearchContent(state: searchState, query: query) + } + } + .task(id: eagerPagingTaskID) { + await loadRemainingSessionPages(query: query.isEmpty ? nil : query) } } @@ -168,6 +184,14 @@ extension TerminalArtifactFilesSheet { await loadFirstSessionPage(query: nil, preservingContent: true) } } else { + let presentation = ChatArtifactGalleryPresentation( + snapshot: snapshot, + filter: galleryFilter, + sort: gallerySort + ) + let created = presentation.items(in: .created) + let attached = presentation.items(in: .attached) + let referenced = presentation.items(in: .referenced) ScrollView { VStack(spacing: 0) { artifactSection( @@ -176,8 +200,8 @@ extension TerminalArtifactFilesSheet { defaultValue: "Created by agent", bundle: .module ), - count: snapshot.created.count, - items: snapshot.created, + count: created.count, + items: created, expanded: $createdExpanded ) artifactSection( @@ -186,8 +210,8 @@ extension TerminalArtifactFilesSheet { defaultValue: "You attached", bundle: .module ), - count: snapshot.attached.count, - items: snapshot.attached, + count: attached.count, + items: attached, expanded: $attachedExpanded ) artifactSection( @@ -196,10 +220,13 @@ extension TerminalArtifactFilesSheet { defaultValue: "Referenced", bundle: .module ), - count: snapshot.referencedTotal, - items: snapshot.referenced, + count: usesCompleteSessionSnapshot + ? referenced.count + : snapshot.referencedTotal, + items: referenced, expanded: $referencedExpanded, - pagingCursor: snapshot.nextCursor + pagingCursor: usesCompleteSessionSnapshot ? nil : snapshot.nextCursor, + showsEagerFooter: usesCompleteSessionSnapshot ) } } @@ -218,13 +245,19 @@ extension TerminalArtifactFilesSheet { case .failed: failureView { await loadFirstSessionPage(query: query) } case .loaded(let snapshot): - if snapshot.referenced.isEmpty { + let presentation = ChatArtifactGalleryPresentation( + snapshot: snapshot, + filter: galleryFilter, + sort: gallerySort + ) + let items = presentation.items(in: .referenced) + if items.isEmpty { ContentUnavailableView.search(text: query) } else { ScrollView { if viewMode == .list { LazyVStack(spacing: 0) { - ForEach(snapshot.referenced) { item in + ForEach(items) { item in TerminalArtifactGalleryItemView( artifact: TerminalArtifactGalleryDisplayItem( galleryItem: item, @@ -236,14 +269,17 @@ extension TerminalArtifactFilesSheet { ) Divider().padding(.leading, 72) } - if let cursor = snapshot.nextCursor { + if !usesCompleteSessionSnapshot, + let cursor = snapshot.nextCursor { pagingFooter(cursor: cursor, query: query) + } else if usesCompleteSessionSnapshot { + eagerPagingFooter } } } else { LazyVGrid(columns: gridColumns, spacing: 16) { Section { - ForEach(snapshot.referenced) { item in + ForEach(items) { item in TerminalArtifactGalleryItemView( artifact: TerminalArtifactGalleryDisplayItem( galleryItem: item, @@ -255,8 +291,11 @@ extension TerminalArtifactFilesSheet { ) } } footer: { - if let cursor = snapshot.nextCursor { + if !usesCompleteSessionSnapshot, + let cursor = snapshot.nextCursor { pagingFooter(cursor: cursor, query: query) + } else if usesCompleteSessionSnapshot { + eagerPagingFooter } } } @@ -275,7 +314,8 @@ extension TerminalArtifactFilesSheet { count: Int, items: [ChatArtifactGalleryItem], expanded: Binding, - pagingCursor: String? = nil + pagingCursor: String? = nil, + showsEagerFooter: Bool = false ) -> some View { DisclosureGroup(isExpanded: expanded) { if viewMode == .list { @@ -291,6 +331,8 @@ extension TerminalArtifactFilesSheet { } if let pagingCursor { pagingFooter(cursor: pagingCursor, query: nil) + } else if showsEagerFooter { + eagerPagingFooter } } } else { @@ -307,6 +349,8 @@ extension TerminalArtifactFilesSheet { } footer: { if let pagingCursor { pagingFooter(cursor: pagingCursor, query: nil) + } else if showsEagerFooter { + eagerPagingFooter } } } @@ -371,6 +415,43 @@ extension TerminalArtifactFilesSheet { } } + @ViewBuilder + private var eagerPagingFooter: some View { + switch eagerPagingState { + case .idle, .loading: + EmptyView() + case .capped: + let format = String( + localized: "terminal.artifact.gallery.showing_first", + defaultValue: "Showing first %lld files", + bundle: .module + ) + Text(String.localizedStringWithFormat( + format, + Int64(ChatArtifactGalleryEagerPager.defaultMaximumReferencedRows) + )) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding() + case .failed: + Button { + eagerPagingRetryGeneration += 1 + } label: { + Label( + String( + localized: "terminal.artifact.gallery.retry", + defaultValue: "Retry", + bundle: .module + ), + systemImage: "arrow.clockwise" + ) + } + .frame(maxWidth: .infinity) + .padding() + } + } + private var loadingView: some View { ProgressView(String( localized: "terminal.artifact.gallery.loading", @@ -417,6 +498,82 @@ extension TerminalArtifactFilesSheet { [GridItem(.adaptive(minimum: 96), spacing: 12)] } + private var galleryControls: some View { + HStack(spacing: 12) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(ChatArtifactGalleryFilter.allCases, id: \.self) { filter in + Button { + galleryFilter = filter + } label: { + Text(filterTitle(filter)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(galleryFilter == filter ? Color.white : Color.primary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background( + galleryFilter == filter + ? Color.accentColor + : Color(uiColor: .secondarySystemBackground), + in: Capsule() + ) + } + .buttonStyle(.plain) + .accessibilityAddTraits(galleryFilter == filter ? .isSelected : []) + } + } + } + + Menu { + Picker( + String( + localized: "terminal.artifact.gallery.sort", + defaultValue: "Sort", + bundle: .module + ), + selection: $gallerySort + ) { + ForEach(ChatArtifactGallerySort.allCases, id: \.self) { sort in + Text(sortTitle(sort)).tag(sort) + } + } + } label: { + Label(sortTitle(gallerySort), systemImage: "arrow.up.arrow.down") + .font(.subheadline.weight(.medium)) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private func filterTitle(_ filter: ChatArtifactGalleryFilter) -> String { + switch filter { + case .all: + String(localized: "terminal.artifact.gallery.filter.all", defaultValue: "All", bundle: .module) + case .images: + String(localized: "terminal.artifact.gallery.filter.images", defaultValue: "Images", bundle: .module) + case .code: + String(localized: "terminal.artifact.gallery.filter.code", defaultValue: "Code", bundle: .module) + case .logs: + String(localized: "terminal.artifact.gallery.filter.logs", defaultValue: "Logs", bundle: .module) + case .docs: + String(localized: "terminal.artifact.gallery.filter.docs", defaultValue: "Docs", bundle: .module) + case .folders: + String(localized: "terminal.artifact.gallery.filter.folders", defaultValue: "Folders", bundle: .module) + } + } + + private func sortTitle(_ sort: ChatArtifactGallerySort) -> String { + switch sort { + case .recent: + String(localized: "terminal.artifact.gallery.sort.recent", defaultValue: "Recent", bundle: .module) + case .name: + String(localized: "terminal.artifact.gallery.sort.name", defaultValue: "Name", bundle: .module) + case .size: + String(localized: "terminal.artifact.gallery.sort.size", defaultValue: "Size", bundle: .module) + } + } + private func searchSubtitle(for item: ChatArtifactGalleryItem) -> String { let provenance: String switch item.provenance { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift index b4a41822340a..b15267203833 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift @@ -33,6 +33,11 @@ struct TerminalArtifactFilesSheet: View { @State var sessionLoader = ChatArtifactLoader.unsupported() @State var scope: Scope = .session @State var viewMode: ViewMode = .list + @State var galleryFilter: ChatArtifactGalleryFilter = .all + @State var gallerySort: ChatArtifactGallerySort = .recent + @State var eagerPagingState: EagerPagingState = .idle + @State var eagerPagingRetryGeneration = 0 + @State var eagerPagingRevision = 0 @State var searchQuery = "" @State var selection: TerminalArtifactPathSelection? @State var createdExpanded = true @@ -179,6 +184,7 @@ struct TerminalArtifactFilesSheet: View { } else { sessionState = .loaded(snapshot) } + eagerPagingRevision += 1 startThumbnailPrefetch(page.referenced) } catch is CancellationError { return @@ -223,6 +229,66 @@ struct TerminalArtifactFilesSheet: View { } } + func loadRemainingSessionPages(query: String?) async { + guard let source, let sessionID, usesCompleteSessionSnapshot else { + eagerPagingState = .idle + return + } + let initialState = query == nil ? sessionState : searchState + guard case .loaded(let initialSnapshot) = initialState else { + eagerPagingState = .idle + return + } + guard initialSnapshot.nextCursor != nil else { + eagerPagingState = initialSnapshot.referenced.count + < initialSnapshot.referencedTotal ? .capped : .idle + return + } + + let expectedFilter = galleryFilter + let expectedSort = gallerySort + let expectedQuery = query + eagerPagingState = .loading + do { + let result = try await ChatArtifactGalleryEagerPager().loadRemaining( + from: initialSnapshot + ) { cursor in + try await source.chatArtifactGallery( + sessionID: sessionID, + cursor: cursor, + pageSize: Self.pageSize, + query: expectedQuery + ) + } + let currentQuery = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + let queryIsCurrent = expectedQuery.map { $0 == currentQuery } + ?? currentQuery.isEmpty + guard !Task.isCancelled, + galleryFilter == expectedFilter, + gallerySort == expectedSort, + queryIsCurrent else { return } + let currentState = query == nil ? sessionState : searchState + guard case .loaded(let currentSnapshot) = currentState, + currentSnapshot.generation == initialSnapshot.generation, + currentSnapshot.nextCursor == initialSnapshot.nextCursor else { return } + if query == nil { + sessionState = .loaded(result.snapshot) + } else { + searchState = .loaded(result.snapshot) + } + let previousPaths = Set(initialSnapshot.referenced.map(\.path)) + startThumbnailPrefetch( + result.snapshot.referenced.filter { !previousPaths.contains($0.path) } + ) + eagerPagingState = result.reachedSafetyCap ? .capped : .idle + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } + eagerPagingState = .failed + } + } + private func startThumbnailPrefetch(_ items: [ChatArtifactGalleryItem]) { let loader = sessionLoader let task = Task(priority: .low) { @@ -244,6 +310,29 @@ struct TerminalArtifactFilesSheet: View { static let pageSize = 60 + var usesCompleteSessionSnapshot: Bool { + galleryFilter != .all || gallerySort != .recent + } + + var eagerPagingTaskID: String { + let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + let state = query.isEmpty ? sessionState : searchState + let cursor: String + if case .loaded(let snapshot) = state { + cursor = "\(snapshot.generation)#\(snapshot.nextCursor ?? "exhausted")" + } else { + cursor = "unloaded" + } + return [ + galleryFilter.rawValue, + gallerySort.rawValue, + query, + cursor, + String(eagerPagingRevision), + String(eagerPagingRetryGeneration), + ].joined(separator: "#") + } + enum InViewLoadState: Equatable { case loading case loaded([TerminalArtifactReference]) @@ -269,6 +358,13 @@ struct TerminalArtifactFilesSheet: View { case grid } + enum EagerPagingState: Equatable { + case idle + case loading + case capped + case failed + } + struct TerminalArtifactPathSelection: Identifiable { let path: String let scope: Scope From 316f15a4c54f753f7b3f8ec14b5869da67429153 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 16:12:04 -0700 Subject: [PATCH 35/92] feat(ios): add three-column artifact grid --- .../TerminalArtifactFilesSheet+Content.swift | 53 ++++++------------- 1 file changed, 16 insertions(+), 37 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift index 0d7b69d6c846..920f69b306d9 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift @@ -31,46 +31,22 @@ extension TerminalArtifactFilesSheet { .padding(.vertical, 10) } - // Keep the existing single toolbar menu so the content picker never creates - // a second glass-backed segmented control in the navigation bar. var viewModePicker: some View { - Menu { - Picker( - String( - localized: "terminal.artifact.gallery.view_mode", - defaultValue: "View", - bundle: .module - ), - selection: $viewMode - ) { - Label( - String( - localized: "terminal.artifact.gallery.view_mode.list", - defaultValue: "List", - bundle: .module - ), - systemImage: "list.bullet" - ) - .tag(ViewMode.list) - - Label( - String( + Button { + viewMode = viewMode == .list ? .grid : .list + } label: { + Image(systemName: viewMode == .list ? "square.grid.3x3" : "list.bullet") + .accessibilityLabel(viewMode == .list + ? String( localized: "terminal.artifact.gallery.view_mode.grid", defaultValue: "Icons", bundle: .module - ), - systemImage: "square.grid.2x2" - ) - .tag(ViewMode.grid) - } - .pickerStyle(.inline) - } label: { - Image(systemName: viewMode == .list ? "list.bullet" : "square.grid.2x2") - .accessibilityLabel(String( - localized: "terminal.artifact.gallery.view_mode", - defaultValue: "View", - bundle: .module - )) + ) + : String( + localized: "terminal.artifact.gallery.view_mode.list", + defaultValue: "List", + bundle: .module + )) } } @@ -495,7 +471,10 @@ extension TerminalArtifactFilesSheet { } private var gridColumns: [GridItem] { - [GridItem(.adaptive(minimum: 96), spacing: 12)] + Array( + repeating: GridItem(.flexible(minimum: 0), spacing: 12, alignment: .top), + count: 3 + ) } private var galleryControls: some View { From 404c84add16b7f1869fcbec66e5b08c663a2dbcb Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 16:28:08 -0700 Subject: [PATCH 36/92] fix(ios): surface incomplete gallery paging --- .../ChatArtifactGalleryEagerPagerTests.swift | 17 +++++++++++++++++ .../TerminalArtifactFilesSheet.swift | 11 ++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryEagerPagerTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryEagerPagerTests.swift index 3c91516673a2..209988d4aef7 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryEagerPagerTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryEagerPagerTests.swift @@ -61,6 +61,23 @@ struct ChatArtifactGalleryEagerPagerTests { #expect(result.reachedSafetyCap) } + @Test("stops before requesting a repeated cursor") + func stopsAtRepeatedCursor() async throws { + let script = ChatArtifactGalleryPageScript(pages: [ + "cursor-1": page(paths: ["/two"], nextCursor: "cursor-1"), + ]) + let initial = snapshot(paths: ["/one"], nextCursor: "cursor-1", total: 3) + + let result = try await ChatArtifactGalleryEagerPager().loadRemaining(from: initial) { cursor in + try await script.fetch(cursor: cursor) + } + + #expect(result.snapshot.referenced.map(\.path) == ["/one", "/two"]) + #expect(result.snapshot.nextCursor == "cursor-1") + #expect(!result.reachedSafetyCap) + #expect(await script.requestedCursors() == ["cursor-1"]) + } + private func snapshot( paths: [String], nextCursor: String?, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift index b15267203833..e32c35a23978 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift @@ -280,7 +280,16 @@ struct TerminalArtifactFilesSheet: View { startThumbnailPrefetch( result.snapshot.referenced.filter { !previousPaths.contains($0.path) } ) - eagerPagingState = result.reachedSafetyCap ? .capped : .idle + if result.reachedSafetyCap { + eagerPagingState = .capped + } else if result.snapshot.nextCursor != nil { + // A defensive cursor-loop stop is incomplete, so surface the + // existing retry affordance instead of presenting partial + // transformed results as complete. + eagerPagingState = .failed + } else { + eagerPagingState = .idle + } } catch is CancellationError { return } catch { From a16cc2814b7cf95a11888e777c8386eb4848dffb Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 16:28:25 -0700 Subject: [PATCH 37/92] feat(ios): page through gallery artifacts --- .../ChatArtifactGallerySwipeOrder.swift | 86 +++++++++++++++++++ .../ChatArtifactGallerySwipeOrderTests.swift | 74 ++++++++++++++++ .../Artifacts/ChatArtifactViewerPager.swift | 55 ++++++++++++ .../ChatArtifactViewerRouteView.swift | 12 ++- .../Artifacts/ChatArtifactViewerSheet.swift | 23 +++-- .../TerminalArtifactFilesSheet+Content.swift | 53 +++++++++--- .../TerminalArtifactFilesSheet.swift | 4 +- 7 files changed, 286 insertions(+), 21 deletions(-) create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySwipeOrder.swift create mode 100644 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySwipeOrderTests.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySwipeOrder.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySwipeOrder.swift new file mode 100644 index 000000000000..b1e6e4f43f6c --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySwipeOrder.swift @@ -0,0 +1,86 @@ +/// The flattened visible-file order used by gallery viewer paging. +public struct ChatArtifactGallerySwipeOrder: Sendable, Equatable { + /// Visible non-folder files in paging order. + public let files: [ChatArtifactGalleryItem] + + /// Visible non-folder paths in paging order. + public var paths: [String] { files.map(\.path) } + + /// Number of visible files in the paging order. + public var count: Int { files.count } + + /// Creates a paging order by flattening visible groups in display order. + /// + /// Directory rows are excluded, and a repeated path keeps its first visible + /// position. + /// + /// - Parameter groups: Filtered and sorted groups in display order. + public init(groups: [ChatArtifactGalleryGroup]) { + self.init(items: groups.flatMap(\.items)) + } + + /// Creates a paging order from one flat visible collection. + /// + /// - Parameter items: Filtered and sorted items in display order. + public init(items: [ChatArtifactGalleryItem]) { + var seenPaths: Set = [] + files = items.filter { item in + item.kind != .directory && seenPaths.insert(item.path).inserted + } + } + + /// Creates a paging order from terminal-scan references. + /// + /// - Parameter references: Visible terminal references in display order. + public init(references: [TerminalArtifactReference]) { + self.init(items: references.map { reference in + ChatArtifactGalleryItem( + path: reference.path, + kind: reference.kind, + displayName: reference.displayName, + size: reference.size, + modifiedAt: reference.modifiedAt + ) + }) + } + + /// Returns the bounded previous/current/next window around one file. + /// + /// - Parameter path: Current visible file path. + /// - Returns: At most three files, or an empty array for an unknown path. + public func pageWindow(around path: String) -> [ChatArtifactGalleryItem] { + guard let currentIndex = files.firstIndex(where: { $0.path == path }) else { + return [] + } + let lowerBound = currentIndex > files.startIndex + ? files.index(before: currentIndex) + : currentIndex + let upperBound = files.index( + after: currentIndex < files.index(before: files.endIndex) + ? files.index(after: currentIndex) + : currentIndex + ) + return Array(files[lowerBound.. String? { + guard let index = files.firstIndex(where: { $0.path == path }), + index > files.startIndex else { return nil } + return files[files.index(before: index)].path + } + + /// Returns the file after a visible path. + /// + /// - Parameter path: Current visible file path. + /// - Returns: Next path, or `nil` at the last file or for an unknown path. + public func nextPath(after path: String) -> String? { + guard let index = files.firstIndex(where: { $0.path == path }) else { return nil } + let nextIndex = files.index(after: index) + guard nextIndex < files.endIndex else { return nil } + return files[nextIndex].path + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySwipeOrderTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySwipeOrderTests.swift new file mode 100644 index 000000000000..084663282e6a --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySwipeOrderTests.swift @@ -0,0 +1,74 @@ +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact gallery swipe order") +struct ChatArtifactGallerySwipeOrderTests { + @Test("flattens visible groups and skips folders") + func flattensVisibleFiles() { + let first = item("/created/first.swift") + let folder = item("/attached/folder", kind: .directory) + let second = item("/attached/second.png", kind: .image) + let third = item("/referenced/third.log") + let order = ChatArtifactGallerySwipeOrder(groups: [ + ChatArtifactGalleryGroup(kind: .created, items: [first]), + ChatArtifactGalleryGroup(kind: .attached, items: [folder, second]), + ChatArtifactGalleryGroup(kind: .referenced, items: [third, first]), + ]) + + #expect(order.paths == [first.path, second.path, third.path]) + } + + @Test("next and previous stop at visible boundaries") + func nextPreviousBoundaries() { + let first = item("/first") + let middle = item("/middle") + let last = item("/last") + let order = ChatArtifactGallerySwipeOrder(items: [first, middle, last]) + + #expect(order.previousPath(before: first.path) == nil) + #expect(order.nextPath(after: first.path) == middle.path) + #expect(order.previousPath(before: middle.path) == first.path) + #expect(order.nextPath(after: middle.path) == last.path) + #expect(order.previousPath(before: last.path) == middle.path) + #expect(order.nextPath(after: last.path) == nil) + #expect(order.nextPath(after: "/unknown") == nil) + #expect(order.previousPath(before: "/unknown") == nil) + } + + @Test("keeps only adjacent pages alive around the current file") + func boundsPageWindow() { + let first = item("/first") + let second = item("/second") + let third = item("/third") + let fourth = item("/fourth") + let order = ChatArtifactGallerySwipeOrder(items: [first, second, third, fourth]) + + #expect(order.pageWindow(around: first.path).map(\.path) == [first.path, second.path]) + #expect(order.pageWindow(around: second.path).map(\.path) == [first.path, second.path, third.path]) + #expect(order.pageWindow(around: fourth.path).map(\.path) == [third.path, fourth.path]) + #expect(order.pageWindow(around: "/unknown").isEmpty) + } + + @Test("terminal references preserve order while excluding folders") + func buildsFromTerminalReferences() { + let references = [ + TerminalArtifactReference(path: "/first", kind: .text, displayName: "first"), + TerminalArtifactReference(path: "/folder", kind: .directory, displayName: "folder"), + TerminalArtifactReference(path: "/last", kind: .image, displayName: "last"), + ] + + #expect(ChatArtifactGallerySwipeOrder(references: references).paths == ["/first", "/last"]) + } + + private func item( + _ path: String, + kind: ChatArtifactKind = .text + ) -> ChatArtifactGalleryItem { + ChatArtifactGalleryItem( + path: path, + kind: kind, + displayName: path + ) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift new file mode 100644 index 000000000000..5feaf0b94963 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift @@ -0,0 +1,55 @@ +import CmuxAgentChat +import SwiftUI + +/// Keeps only the selected artifact and its adjacent viewer pages mounted. +struct ChatArtifactViewerPager: View { + let initialPath: String + let scope: ChatArtifactViewerScope + let swipeOrder: ChatArtifactGallerySwipeOrder + let onDone: () -> Void + + @State private var selectedPath: String + + init( + initialPath: String, + scope: ChatArtifactViewerScope, + swipeOrder: ChatArtifactGallerySwipeOrder, + onDone: @escaping () -> Void + ) { + self.initialPath = initialPath + self.scope = scope + self.swipeOrder = swipeOrder + self.onDone = onDone + _selectedPath = State(initialValue: initialPath) + } + + @ViewBuilder + var body: some View { + #if os(iOS) + if swipeOrder.count > 1, + swipeOrder.files.contains(where: { $0.path == initialPath }) { + TabView(selection: $selectedPath) { + ForEach(swipeOrder.pageWindow(around: selectedPath)) { file in + viewer(path: file.path) + .tag(file.path) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + } else { + viewer(path: initialPath) + } + #else + viewer(path: initialPath) + #endif + } + + private func viewer(path: String) -> some View { + NavigationStack { + ChatArtifactViewerRouteView( + path: path, + scope: scope, + onDone: onDone + ) + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index 8809f73b99a3..83569ea77604 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -205,10 +205,16 @@ struct ChatArtifactViewerRouteView: View { #else await model.load(path: path, loader: loader) #endif + await waitForViewerTaskCancellation() + await model.cleanup() } - .onDisappear { - Task { await model.cleanup() } - } + } + + /// Keeps cleanup structured under the SwiftUI page task after loading ends. + private func waitForViewerTaskCancellation() async { + let (stream, continuation) = AsyncStream.makeStream() + defer { continuation.finish() } + for await _ in stream {} } @ViewBuilder diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerSheet.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerSheet.swift index aff399f12818..0bf623879d56 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerSheet.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerSheet.swift @@ -1,23 +1,36 @@ +import CmuxAgentChat import SwiftUI /// A navigation container for one Mac-hosted artifact path. public struct ChatArtifactViewerSheet: View { let path: String let scope: ChatArtifactViewerScope + let swipeOrder: ChatArtifactGallerySwipeOrder @Environment(\.dismiss) private var dismiss /// Creates an artifact viewer that routes files and folders through the /// same stat-driven navigation path. - public init(path: String, scope: ChatArtifactViewerScope = .chat) { + /// - Parameters: + /// - path: Initially selected artifact path. + /// - scope: Authorization and navigation context for the artifact. + /// - swipeOrder: Visible gallery-file order available for horizontal paging. + public init( + path: String, + scope: ChatArtifactViewerScope = .chat, + swipeOrder: ChatArtifactGallerySwipeOrder = ChatArtifactGallerySwipeOrder(items: []) + ) { self.path = path self.scope = scope + self.swipeOrder = swipeOrder } public var body: some View { - NavigationStack { - ChatArtifactViewerRouteView(path: path, scope: scope) { - dismiss() - } + ChatArtifactViewerPager( + initialPath: path, + scope: scope, + swipeOrder: swipeOrder + ) { + dismiss() } } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift index 920f69b306d9..f0a8f25ad180 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift @@ -98,10 +98,12 @@ extension TerminalArtifactFilesSheet { systemImage: "tray" ) } else { + let swipeOrder = ChatArtifactGallerySwipeOrder(references: artifacts) artifactCollection( artifacts.map(TerminalArtifactGalleryDisplayItem.init(reference:)), loader: loader, - scope: .inView + scope: .inView, + swipeOrder: swipeOrder ) .refreshable { await refreshInView() } } @@ -168,6 +170,7 @@ extension TerminalArtifactFilesSheet { let created = presentation.items(in: .created) let attached = presentation.items(in: .attached) let referenced = presentation.items(in: .referenced) + let swipeOrder = ChatArtifactGallerySwipeOrder(groups: presentation.groups) ScrollView { VStack(spacing: 0) { artifactSection( @@ -178,7 +181,8 @@ extension TerminalArtifactFilesSheet { ), count: created.count, items: created, - expanded: $createdExpanded + expanded: $createdExpanded, + swipeOrder: swipeOrder ) artifactSection( title: String( @@ -188,7 +192,8 @@ extension TerminalArtifactFilesSheet { ), count: attached.count, items: attached, - expanded: $attachedExpanded + expanded: $attachedExpanded, + swipeOrder: swipeOrder ) artifactSection( title: String( @@ -201,6 +206,7 @@ extension TerminalArtifactFilesSheet { : snapshot.referencedTotal, items: referenced, expanded: $referencedExpanded, + swipeOrder: swipeOrder, pagingCursor: usesCompleteSessionSnapshot ? nil : snapshot.nextCursor, showsEagerFooter: usesCompleteSessionSnapshot ) @@ -227,6 +233,7 @@ extension TerminalArtifactFilesSheet { sort: gallerySort ) let items = presentation.items(in: .referenced) + let swipeOrder = ChatArtifactGallerySwipeOrder(items: items) if items.isEmpty { ContentUnavailableView.search(text: query) } else { @@ -241,7 +248,9 @@ extension TerminalArtifactFilesSheet { ), layout: .list, loader: sessionLoader, - open: { open(item.path, scope: .session) } + open: { + open(item.path, scope: .session, swipeOrder: swipeOrder) + } ) Divider().padding(.leading, 72) } @@ -263,7 +272,9 @@ extension TerminalArtifactFilesSheet { ), layout: .grid, loader: sessionLoader, - open: { open(item.path, scope: .session) } + open: { + open(item.path, scope: .session, swipeOrder: swipeOrder) + } ) } } footer: { @@ -290,6 +301,7 @@ extension TerminalArtifactFilesSheet { count: Int, items: [ChatArtifactGalleryItem], expanded: Binding, + swipeOrder: ChatArtifactGallerySwipeOrder, pagingCursor: String? = nil, showsEagerFooter: Bool = false ) -> some View { @@ -301,7 +313,9 @@ extension TerminalArtifactFilesSheet { artifact: TerminalArtifactGalleryDisplayItem(galleryItem: item), layout: .list, loader: sessionLoader, - open: { open(item.path, scope: .session) } + open: { + open(item.path, scope: .session, swipeOrder: swipeOrder) + } ) Divider().padding(.leading, 72) } @@ -319,7 +333,9 @@ extension TerminalArtifactFilesSheet { artifact: TerminalArtifactGalleryDisplayItem(galleryItem: item), layout: .grid, loader: sessionLoader, - open: { open(item.path, scope: .session) } + open: { + open(item.path, scope: .session, swipeOrder: swipeOrder) + } ) } } footer: { @@ -344,7 +360,8 @@ extension TerminalArtifactFilesSheet { private func artifactCollection( _ artifacts: [TerminalArtifactGalleryDisplayItem], loader: ChatArtifactLoader, - scope: Scope + scope: Scope, + swipeOrder: ChatArtifactGallerySwipeOrder ) -> some View { switch viewMode { case .list: @@ -355,7 +372,9 @@ extension TerminalArtifactFilesSheet { artifact: artifact, layout: .list, loader: loader, - open: { open(artifact.path, scope: scope) } + open: { + open(artifact.path, scope: scope, swipeOrder: swipeOrder) + } ) Divider().padding(.leading, 72) } @@ -369,7 +388,9 @@ extension TerminalArtifactFilesSheet { artifact: artifact, layout: .grid, loader: loader, - open: { open(artifact.path, scope: scope) } + open: { + open(artifact.path, scope: scope, swipeOrder: swipeOrder) + } ) } } @@ -579,8 +600,16 @@ extension TerminalArtifactFilesSheet { return "\(provenance) · \(modifiedAt.formatted(date: .abbreviated, time: .omitted))" } - private func open(_ path: String, scope: Scope) { - selection = TerminalArtifactPathSelection(path: path, scope: scope) + private func open( + _ path: String, + scope: Scope, + swipeOrder: ChatArtifactGallerySwipeOrder + ) { + selection = TerminalArtifactPathSelection( + path: path, + scope: scope, + swipeOrder: swipeOrder + ) } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift index e32c35a23978..eaaa6a21e963 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift @@ -83,7 +83,8 @@ struct TerminalArtifactFilesSheet: View { .sheet(item: $selection) { selection in ChatArtifactViewerSheet( path: selection.path, - scope: selection.scope == .session ? .chat : .terminal + scope: selection.scope == .session ? .chat : .terminal, + swipeOrder: selection.swipeOrder ) .environment( \.chatArtifactLoader, @@ -377,6 +378,7 @@ struct TerminalArtifactFilesSheet: View { struct TerminalArtifactPathSelection: Identifiable { let path: String let scope: Scope + let swipeOrder: ChatArtifactGallerySwipeOrder var id: String { "\(scope)#\(path)" } } } From 2928f2883a2df2f7a486fa01c0f8bf8aca4fba0c Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 17:06:49 -0700 Subject: [PATCH 38/92] feat(ios): cache fetched artifact content --- .../Artifacts/ChatArtifactContentCache.swift | 203 ++++++++++++++++++ .../ChatArtifactContentCacheWriter.swift | 79 +++++++ .../Artifacts/ChatArtifactLoader.swift | 43 +++- .../ChatArtifactTemporaryFileStore.swift | 7 +- .../Artifacts/ChatArtifactViewerModel.swift | 40 +++- .../ChatArtifactContentCacheTests.swift | 119 ++++++++++ .../CountingContentSource.swift | 30 +++ 7 files changed, 509 insertions(+), 12 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCache.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCacheWriter.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactContentCacheTests.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/CountingContentSource.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCache.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCache.swift new file mode 100644 index 000000000000..daa5f33105ee --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCache.swift @@ -0,0 +1,203 @@ +import CmuxAgentChat +import CryptoKit +import Foundation + +/// Memory-fronted, purgeable disk LRU for fully fetched artifact bytes. +public actor ChatArtifactContentCache { + private let directory: URL + private let maxDiskBytes: Int64 + private let maxMemoryBytes: Int + private let fileManager = FileManager() + private let memoryCache = NSCache() + + /// Creates a content cache rooted at an injected directory. + /// + /// - Parameters: + /// - directory: Purgeable cache directory. + /// - maxDiskBytes: Maximum aggregate disk usage. + /// - maxMemoryBytes: Approximate `NSCache` byte-cost limit. + public init( + directory: URL, + maxDiskBytes: Int64 = 256 * 1_024 * 1_024, + maxMemoryBytes: Int = 32 * 1_024 * 1_024 + ) { + self.directory = directory + self.maxDiskBytes = max(0, maxDiskBytes) + self.maxMemoryBytes = max(0, maxMemoryBytes) + memoryCache.totalCostLimit = self.maxMemoryBytes + } + + /// Creates the production cache in `Caches//artifact-content/`. + /// + /// - Parameters: + /// - maxDiskBytes: Maximum aggregate disk usage. + /// - maxMemoryBytes: Approximate `NSCache` byte-cost limit. + /// - fileManager: Filesystem locator used to resolve the caches directory. + /// - bundleIdentifier: Bundle-specific directory component. + /// - Returns: A cache configured for application use. + public static func applicationDefault( + maxDiskBytes: Int64 = 256 * 1_024 * 1_024, + maxMemoryBytes: Int = 32 * 1_024 * 1_024, + fileManager: FileManager = .default, + bundleIdentifier: String? = Bundle.main.bundleIdentifier + ) -> ChatArtifactContentCache { + let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + let bundleComponent = bundleIdentifier.flatMap { $0.isEmpty ? nil : $0 } ?? "cmux" + return ChatArtifactContentCache( + directory: caches + .appendingPathComponent(bundleComponent, isDirectory: true) + .appendingPathComponent("artifact-content", isDirectory: true), + maxDiskBytes: maxDiskBytes, + maxMemoryBytes: maxMemoryBytes + ) + } + + /// Derives a versioned key from authorization scope and host file metadata. + public static func key( + scopeKey: String, + path: String, + modifiedAt: Date?, + size: Int64? + ) -> String? { + guard let modifiedAt, let size else { return nil } + let raw = "\(scopeKey)#\(path)#\(String(modifiedAt.timeIntervalSince1970.bitPattern, radix: 16))#\(size)" + return SHA256.hash(data: Data(raw.utf8)).map { String(format: "%02x", $0) }.joined() + } + + /// Replays a cache hit or writes a fetched stream through atomically. + /// + /// - Returns: `true` when no source fetch was needed. + func stream( + for key: String, + expectedSize: Int64, + accessedAt: Date = Date(), + fetch: @escaping @Sendable ( + _ receive: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws -> Void, + receive: @escaping @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws -> Bool { + if let data = memoryCache.object(forKey: key as NSString) { + let value = Data(referencing: data) + try? touch(fileURL(for: key), at: accessedAt) + try await receive(Self.chunk(data: value, totalSize: expectedSize)) + return true + } + + if try await replayDiskEntry( + for: key, + expectedSize: expectedSize, + accessedAt: accessedAt, + receive: receive + ) { + return true + } + + let writer = try ChatArtifactContentCacheWriter( + directory: directory, + key: key, + expectedSize: expectedSize, + retainsMemoryCopy: expectedSize <= Int64(maxMemoryBytes) + ) + do { + try await fetch { chunk in + try Task.checkCancellation() + try await writer.append(chunk) + try await receive(chunk) + } + try Task.checkCancellation() + let data = try await writer.finish() + if let data, maxMemoryBytes > 0 { + memoryCache.setObject(data as NSData, forKey: key as NSString, cost: data.count) + } + try touch(fileURL(for: key), at: accessedAt) + try enforceDiskBudget() + return false + } catch { + await writer.discard() + throw error + } + } + + private func replayDiskEntry( + for key: String, + expectedSize: Int64, + accessedAt: Date, + receive: @escaping @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws -> Bool { + let url = fileURL(for: key) + guard let attributes = try? fileManager.attributesOfItem(atPath: url.path), + (attributes[.size] as? NSNumber)?.int64Value == expectedSize else { + try? fileManager.removeItem(at: url) + return false + } + try touch(url, at: accessedAt) + if expectedSize == 0 { + try await receive(Self.chunk(data: Data(), totalSize: 0)) + return true + } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var offset: Int64 = 0 + while offset < expectedSize { + try Task.checkCancellation() + let requested = Int(min(Int64(Self.diskReadChunkBytes), expectedSize - offset)) + guard let data = try handle.read(upToCount: requested), !data.isEmpty else { + throw CocoaError(.fileReadCorruptFile) + } + let chunkOffset = offset + offset += Int64(data.count) + try await receive(ChatArtifactChunk( + data: data, + offset: chunkOffset, + totalSize: expectedSize, + eof: offset == expectedSize + )) + } + return true + } + + private func fileURL(for key: String) -> URL { + directory.appendingPathComponent(key, isDirectory: false) + } + + private func touch(_ url: URL, at date: Date) throws { + try fileManager.setAttributes([.modificationDate: date], ofItemAtPath: url.path) + } + + private func enforceDiskBudget() throws { + let keys: Set = [ + .fileSizeKey, + .contentModificationDateKey, + .isRegularFileKey, + .nameKey, + ] + let urls = try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: Array(keys), + options: [.skipsHiddenFiles] + ) + var entries: [(url: URL, size: Int64, accessedAt: Date)] = [] + var totalBytes: Int64 = 0 + for url in urls { + let values = try url.resourceValues(forKeys: keys) + guard values.isRegularFile == true, + values.name?.hasSuffix(".partial") != true else { continue } + let size = Int64(values.fileSize ?? 0) + totalBytes += size + entries.append((url, size, values.contentModificationDate ?? .distantPast)) + } + guard totalBytes > maxDiskBytes else { return } + for entry in entries.sorted(by: { $0.accessedAt < $1.accessedAt }) + where totalBytes > maxDiskBytes { + try fileManager.removeItem(at: entry.url) + totalBytes -= entry.size + } + } + + private static func chunk(data: Data, totalSize: Int64) -> ChatArtifactChunk { + ChatArtifactChunk(data: data, offset: 0, totalSize: totalSize, eof: true) + } + + private static let diskReadChunkBytes = 1 * 1_024 * 1_024 +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCacheWriter.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCacheWriter.swift new file mode 100644 index 000000000000..10ce5f1aa29b --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCacheWriter.swift @@ -0,0 +1,79 @@ +import CmuxAgentChat +import Foundation + +/// Serializes one artifact stream into an atomic content-cache entry. +actor ChatArtifactContentCacheWriter { + private let expectedSize: Int64 + private let temporaryURL: URL + private let destinationURL: URL + private var fileHandle: FileHandle? + private var memoryData: Data? + private var nextOffset: Int64 = 0 + private var reachedEOF = false + + init( + directory: URL, + key: String, + expectedSize: Int64, + retainsMemoryCopy: Bool + ) throws { + let fileManager = FileManager() + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + self.expectedSize = expectedSize + destinationURL = directory.appendingPathComponent(key, isDirectory: false) + temporaryURL = directory.appendingPathComponent( + ".\(key).\(UUID().uuidString).partial", + isDirectory: false + ) + guard fileManager.createFile(atPath: temporaryURL.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown) + } + do { + fileHandle = try FileHandle(forWritingTo: temporaryURL) + } catch { + try? fileManager.removeItem(at: temporaryURL) + throw error + } + memoryData = retainsMemoryCopy ? Data() : nil + if retainsMemoryCopy, expectedSize <= Int64(Int.max) { + memoryData?.reserveCapacity(Int(expectedSize)) + } + } + + func append(_ chunk: ChatArtifactChunk) throws { + guard !reachedEOF, + chunk.offset == nextOffset, + chunk.totalSize == expectedSize, + nextOffset <= expectedSize - Int64(chunk.data.count), + let fileHandle else { + throw ChatArtifactError.macUnreachable + } + try fileHandle.write(contentsOf: chunk.data) + memoryData?.append(chunk.data) + nextOffset += Int64(chunk.data.count) + reachedEOF = chunk.eof + if reachedEOF, nextOffset != expectedSize { + throw ChatArtifactError.macUnreachable + } + } + + func finish() throws -> Data? { + guard reachedEOF, nextOffset == expectedSize, let fileHandle else { + throw ChatArtifactError.macUnreachable + } + try fileHandle.close() + self.fileHandle = nil + let fileManager = FileManager() + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + try fileManager.moveItem(at: temporaryURL, to: destinationURL) + return memoryData + } + + func discard() { + try? fileHandle?.close() + fileHandle = nil + try? FileManager().removeItem(at: temporaryURL) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift index 697ac8e7d744..2b1d53c37c40 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift @@ -93,6 +93,7 @@ public struct ChatArtifactLoader: Sendable { private let thumbnailHandler: @Sendable (_ path: String, _ maxDimension: Int) async throws -> ChatArtifactThumbnail private let listHandler: @Sendable (_ path: String) async throws -> ChatArtifactDirectoryListing private let thumbnailCache: ChatArtifactThumbnailCache + private let contentCache: ChatArtifactContentCache /// Creates a closure-backed artifact loader. /// @@ -101,6 +102,7 @@ public struct ChatArtifactLoader: Sendable { /// - supportsDirectoryBrowsing: Whether directory stat results may be listed. /// - scope: Cache and authorization namespace for this loader. /// - cache: Thumbnail cache shared by rows and viewers. + /// - contentCache: Full-content cache shared by viewer routes. /// - stat: Metadata operation for an absolute host path. /// - fetch: Whole-file operation retained for image and compatibility callers. /// - stream: Optional structured chunk operation; defaults to one callback @@ -112,6 +114,7 @@ public struct ChatArtifactLoader: Sendable { supportsDirectoryBrowsing: Bool = false, scope: ChatArtifactLoaderScope = .unsupported, cache: ChatArtifactThumbnailCache = ChatArtifactThumbnailCache(), + contentCache: ChatArtifactContentCache = .applicationDefault(), stat: @escaping @Sendable (_ path: String) async throws -> ChatArtifactStat = { _ in throw ChatArtifactError.unsupported }, @@ -136,6 +139,7 @@ public struct ChatArtifactLoader: Sendable { self.supportsDirectoryBrowsing = supportsDirectoryBrowsing self.scope = scope self.thumbnailCache = cache + self.contentCache = contentCache statHandler = stat fetchHandler = fetch streamHandler = stream ?? { path, onChunk in @@ -157,13 +161,15 @@ public struct ChatArtifactLoader: Sendable { public init( source: any ChatEventSource, sessionID: String, - cache: ChatArtifactThumbnailCache = ChatArtifactThumbnailCache() + cache: ChatArtifactThumbnailCache = ChatArtifactThumbnailCache(), + contentCache: ChatArtifactContentCache = .applicationDefault() ) { self.init( supportsArtifacts: source.supportsArtifacts, supportsDirectoryBrowsing: source.supportsArtifactFolders, scope: .chat(sessionID: sessionID), cache: cache, + contentCache: contentCache, stat: { path in try await source.artifactStat(sessionID: sessionID, path: path) }, @@ -194,6 +200,7 @@ public struct ChatArtifactLoader: Sendable { /// - supportsArtifacts: Whether terminal artifact operations are available. /// - supportsDirectoryBrowsing: Whether terminal directory listing is available. /// - cache: Thumbnail cache shared by rows and viewers. + /// - contentCache: Full-content cache shared by viewer routes. /// - stat: Metadata operation for an absolute host path. /// - fetch: Whole-file compatibility operation. /// - stream: Optional structured chunk operation. @@ -205,6 +212,7 @@ public struct ChatArtifactLoader: Sendable { supportsArtifacts: Bool, supportsDirectoryBrowsing: Bool = false, cache: ChatArtifactThumbnailCache = ChatArtifactThumbnailCache(), + contentCache: ChatArtifactContentCache = .applicationDefault(), stat: @escaping @Sendable (_ path: String) async throws -> ChatArtifactStat, fetch: @escaping @Sendable ( _ path: String, @@ -224,6 +232,7 @@ public struct ChatArtifactLoader: Sendable { supportsDirectoryBrowsing: supportsDirectoryBrowsing, scope: .terminal(workspaceID: terminalWorkspaceID, surfaceID: terminalSurfaceID), cache: cache, + contentCache: contentCache, stat: stat, fetch: fetch, stream: stream, @@ -232,8 +241,11 @@ public struct ChatArtifactLoader: Sendable { ) } - public static func unsupported(cache: ChatArtifactThumbnailCache = ChatArtifactThumbnailCache()) -> ChatArtifactLoader { - ChatArtifactLoader(cache: cache) + public static func unsupported( + cache: ChatArtifactThumbnailCache = ChatArtifactThumbnailCache(), + contentCache: ChatArtifactContentCache = .applicationDefault() + ) -> ChatArtifactLoader { + ChatArtifactLoader(cache: cache, contentCache: contentCache) } public func stat(path: String) async throws -> ChatArtifactStat { @@ -251,12 +263,33 @@ public struct ChatArtifactLoader: Sendable { /// /// - Parameters: /// - path: Absolute Mac host path. + /// - modifiedAt: Stat modification time used to invalidate cached bytes. + /// - size: Stat byte size used to validate and invalidate cached bytes. /// - onChunk: Structured callback awaited for each chunk in byte order. public func stream( path: String, - onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + modifiedAt: Date? = nil, + size: Int64? = nil, + onChunk: @escaping @Sendable (ChatArtifactChunk) async throws -> Void ) async throws { - try await streamHandler(path, onChunk) + guard let key = ChatArtifactContentCache.key( + scopeKey: scope.cacheNamespace, + path: path, + modifiedAt: modifiedAt, + size: size + ), let size else { + try await streamHandler(path, onChunk) + return + } + let handler = streamHandler + _ = try await contentCache.stream( + for: key, + expectedSize: size, + fetch: { receive in + try await handler(path, receive) + }, + receive: onChunk + ) } public func thumbnail( diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift index 88653a590f8d..f09d51b2c4fe 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift @@ -24,6 +24,7 @@ actor ChatArtifactTemporaryFileStore { func fetch( path: String, expectedSize: Int64, + modifiedAt: Date? = nil, limit: Int64, fallbackExtension: String? = nil, loader: ChatArtifactLoader, @@ -41,7 +42,11 @@ actor ChatArtifactTemporaryFileStore { fileExtension: fileExtension ) do { - try await loader.stream(path: path) { chunk in + try await loader.stream( + path: path, + modifiedAt: modifiedAt, + size: expectedSize + ) { chunk in try Task.checkCancellation() try await writer.append(chunk, limit: limit) await progress(chunk) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift index ef05a408d8e2..652df96f7b0b 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift @@ -66,18 +66,29 @@ final class ChatArtifactViewerModel { switch route { case .text: - try await streamText(path: path, isMarkdown: false, loader: loader) + try await streamText( + path: path, + stat: loadedStat, + isMarkdown: false, + loader: loader + ) case .markdown: markdownPresentation = ChatArtifactMarkdownPresentation( byteCount: loadedStat.size ) - try await streamText(path: path, isMarkdown: true, loader: loader) + try await streamText( + path: path, + stat: loadedStat, + isMarkdown: true, + loader: loader + ) case .image: - try await loadImage(path: path, loader: loader) + try await loadImage(path: path, stat: loadedStat, loader: loader) case .pdf: if let fileURL = try await loadTemporaryFile( path: path, expectedSize: loadedStat.size, + modifiedAt: loadedStat.modifiedAt, limit: limit, fallbackExtension: "pdf", loader: loader @@ -88,6 +99,7 @@ final class ChatArtifactViewerModel { if let fileURL = try await loadTemporaryFile( path: path, expectedSize: loadedStat.size, + modifiedAt: loadedStat.modifiedAt, limit: limit, fallbackExtension: nil, loader: loader @@ -98,6 +110,7 @@ final class ChatArtifactViewerModel { if let fileURL = try await loadTemporaryFile( path: path, expectedSize: loadedStat.size, + modifiedAt: loadedStat.modifiedAt, limit: limit, fallbackExtension: nil, loader: loader @@ -148,11 +161,16 @@ final class ChatArtifactViewerModel { private func streamText( path: String, + stat: ChatArtifactStat, isMarkdown: Bool, loader: ChatArtifactLoader ) async throws { let decoder = UTF8ChunkDecoder() - try await loader.stream(path: path) { chunk in + try await loader.stream( + path: path, + modifiedAt: stat.modifiedAt, + size: stat.size + ) { chunk in try Task.checkCancellation() let decoded = try await decoder.decode(chunk.data, eof: chunk.eof) try Task.checkCancellation() @@ -181,9 +199,17 @@ final class ChatArtifactViewerModel { state = isMarkdown ? .markdown : .text } - private func loadImage(path: String, loader: ChatArtifactLoader) async throws { + private func loadImage( + path: String, + stat: ChatArtifactStat, + loader: ChatArtifactLoader + ) async throws { let accumulator = ChatArtifactDataAccumulator() - try await loader.stream(path: path) { chunk in + try await loader.stream( + path: path, + modifiedAt: stat.modifiedAt, + size: stat.size + ) { chunk in try Task.checkCancellation() await accumulator.append(chunk.data, totalSize: chunk.totalSize) await self.receiveNonTextProgress(chunk: chunk, path: path) @@ -197,6 +223,7 @@ final class ChatArtifactViewerModel { private func loadTemporaryFile( path: String, expectedSize: Int64, + modifiedAt: Date?, limit: Int64, fallbackExtension: String?, loader: ChatArtifactLoader @@ -204,6 +231,7 @@ final class ChatArtifactViewerModel { let fileURL = try await temporaryFileStore.fetch( path: path, expectedSize: expectedSize, + modifiedAt: modifiedAt, limit: limit, fallbackExtension: fallbackExtension, loader: loader diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactContentCacheTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactContentCacheTests.swift new file mode 100644 index 000000000000..6fccfe8a1d47 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactContentCacheTests.swift @@ -0,0 +1,119 @@ +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Chat artifact content cache") +struct ChatArtifactContentCacheTests { + @Test("mtime and size changes invalidate the content key") + func keyInvalidation() throws { + let base = try #require(ChatArtifactContentCache.key( + scopeKey: "chat:session", + path: "/tmp/report.txt", + modifiedAt: Date(timeIntervalSince1970: 10), + size: 3 + )) + let changedMTime = try #require(ChatArtifactContentCache.key( + scopeKey: "chat:session", + path: "/tmp/report.txt", + modifiedAt: Date(timeIntervalSince1970: 11), + size: 3 + )) + let changedSize = try #require(ChatArtifactContentCache.key( + scopeKey: "chat:session", + path: "/tmp/report.txt", + modifiedAt: Date(timeIntervalSince1970: 10), + size: 4 + )) + + #expect(base != changedMTime) + #expect(base != changedSize) + } + + @Test("disk budget evicts the least recently used content") + func diskLRUEviction() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let cache = ChatArtifactContentCache( + directory: directory, + maxDiskBytes: 6, + maxMemoryBytes: 0 + ) + let source = CountingContentSource(values: [ + "a": Data([1, 1, 1]), + "b": Data([2, 2, 2]), + "c": Data([3, 3, 3]), + ]) + + try await stream(key: "a", at: 1, cache: cache, source: source) + try await stream(key: "b", at: 2, cache: cache, source: source) + try await stream(key: "a", at: 3, cache: cache, source: source) + try await stream(key: "c", at: 4, cache: cache, source: source) + try await stream(key: "a", at: 5, cache: cache, source: source) + try await stream(key: "b", at: 6, cache: cache, source: source) + + #expect(await source.fetchCount(for: "a") == 1) + #expect(await source.fetchCount(for: "b") == 2) + #expect(await source.fetchCount(for: "c") == 1) + } + + @Test("reopening unchanged viewer content performs no second fetch") + @MainActor + func viewerInstantHit() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let source = CountingContentSource(values: ["artifact": Data("hello".utf8)]) + let modifiedAt = Date(timeIntervalSince1970: 100) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + scope: .chat(sessionID: "session"), + contentCache: ChatArtifactContentCache(directory: directory), + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: 5, + modifiedAt: modifiedAt, + kind: .text, + mimeType: "text/plain" + ) + }, + fetch: { _, _ in Data("hello".utf8) }, + stream: { _, receive in + try await source.fetch(key: "artifact", receive: receive) + } + ) + + let first = ChatArtifactViewerModel() + await first.load(path: "/tmp/artifact.txt", loader: loader) + let second = ChatArtifactViewerModel() + await second.load(path: "/tmp/artifact.txt", loader: loader) + + #expect(first.renderedText == "hello") + #expect(second.renderedText == "hello") + #expect(await source.fetchCount(for: "artifact") == 1) + } + + private func stream( + key: String, + at seconds: TimeInterval, + cache: ChatArtifactContentCache, + source: CountingContentSource + ) async throws { + _ = try await cache.stream( + for: key, + expectedSize: 3, + accessedAt: Date(timeIntervalSince1970: seconds), + fetch: { receive in + try await source.fetch(key: key, receive: receive) + }, + receive: { _ in } + ) + } + + private func temporaryDirectory() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-content-cache-\(UUID().uuidString)", isDirectory: true) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/CountingContentSource.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/CountingContentSource.swift new file mode 100644 index 000000000000..ea3e20c1a071 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/CountingContentSource.swift @@ -0,0 +1,30 @@ +import CmuxAgentChat +import Foundation + +/// Supplies deterministic artifact bytes while recording source fetches. +actor CountingContentSource { + private let values: [String: Data] + private var counts: [String: Int] = [:] + + init(values: [String: Data]) { + self.values = values + } + + func fetch( + key: String, + receive: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws { + let data = values[key] ?? Data() + counts[key, default: 0] += 1 + try await receive(ChatArtifactChunk( + data: data, + offset: 0, + totalSize: Int64(data.count), + eof: true + )) + } + + func fetchCount(for key: String) -> Int { + counts[key, default: 0] + } +} From b0a0f64428774b1f733beabc96c5e51e0fb8d910 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 17:21:28 -0700 Subject: [PATCH 39/92] feat(ios): refresh artifact gallery without scroll jumps --- .../ChatArtifactGalleryLiveRefreshState.swift | 57 ++++++++++++++ .../ChatArtifactGallerySnapshot.swift | 27 +++++++ ...ArtifactGalleryLiveRefreshStateTests.swift | 78 +++++++++++++++++++ .../GhosttySurfaceCoordinator+Artifacts.swift | 27 +++++-- .../GhosttySurfaceRepresentable.swift | 11 ++- .../Resources/Localizable.xcstrings | 7 ++ .../TerminalArtifactChipCountState.swift | 5 +- .../TerminalArtifactFilesSheet+Content.swift | 58 +++++++++++++- .../TerminalArtifactFilesSheet.swift | 73 +++++++++++++++++ ...TerminalArtifactGalleryRefreshSignal.swift | 7 ++ ...orkspaceDetailView+TerminalArtifacts.swift | 6 ++ .../WorkspaceDetailView.swift | 1 + .../TerminalArtifactChipCountStateTests.swift | 35 ++++++--- 13 files changed, 369 insertions(+), 23 deletions(-) create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryLiveRefreshState.swift create mode 100644 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryLiveRefreshStateTests.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryRefreshSignal.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryLiveRefreshState.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryLiveRefreshState.swift new file mode 100644 index 000000000000..e68f699822ae --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryLiveRefreshState.swift @@ -0,0 +1,57 @@ +/// Defers gallery generation changes while the user is reading away from the top. +public struct ChatArtifactGalleryLiveRefreshState: Sendable, Equatable { + private var pendingSnapshot: ChatArtifactGallerySnapshot? + + /// Number of unseen paths waiting for explicit application. + public private(set) var pendingNewFileCount = 0 + + /// Creates an empty live-refresh state. + public init() {} + + /// Receives a fresh first page and decides whether it is safe to show now. + /// + /// - Parameters: + /// - fresh: First page from the latest session generation. + /// - displayed: Snapshot currently rendered by the gallery. + /// - isAtTopOrFits: Whether inserting leading rows cannot disturb a reading position. + /// - Returns: A reconciled snapshot to display immediately, or `nil` when a pill should be shown. + public mutating func receive( + fresh: ChatArtifactGallerySnapshot, + displayed: ChatArtifactGallerySnapshot, + isAtTopOrFits: Bool + ) -> ChatArtifactGallerySnapshot? { + guard fresh.generation != displayed.generation else { return nil } + let displayedPaths = Set((displayed.created + displayed.attached + displayed.referenced).map(\.path)) + let freshPaths = Set((fresh.created + fresh.attached + fresh.referenced).map(\.path)) + let newFileCount = freshPaths.subtracting(displayedPaths).count + let reconciled = displayed.reconciling(withFreshFirstPage: fresh) + + guard !isAtTopOrFits, newFileCount > 0 else { + pendingSnapshot = nil + pendingNewFileCount = 0 + return reconciled + } + pendingSnapshot = fresh + pendingNewFileCount = newFileCount + return nil + } + + /// Applies the newest deferred first page and clears the pending pill. + /// + /// - Parameter displayed: Snapshot currently rendered by the gallery. + /// - Returns: The reconciled snapshot, or `nil` when no refresh is pending. + public mutating func applyPending( + to displayed: ChatArtifactGallerySnapshot + ) -> ChatArtifactGallerySnapshot? { + guard let pendingSnapshot else { return nil } + self.pendingSnapshot = nil + pendingNewFileCount = 0 + return displayed.reconciling(withFreshFirstPage: pendingSnapshot) + } + + /// Drops a deferred refresh when the session or scope changes. + public mutating func reset() { + pendingSnapshot = nil + pendingNewFileCount = 0 + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift index 766c2ac97837..183fc767a3c9 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift @@ -51,6 +51,33 @@ public struct ChatArtifactGallerySnapshot: Sendable, Equatable { ) } + /// Reconciles a newer first page without discarding rows already loaded. + /// + /// Fresh rows lead their provenance sections, while previously loaded rows + /// retain their relative order. A path that moved between provenance + /// sections appears only in the fresh section. + /// + /// - Parameter fresh: First-page snapshot from a newer host generation. + /// - Returns: A generation-updated snapshot that preserves loaded history. + public func reconciling(withFreshFirstPage fresh: ChatArtifactGallerySnapshot) -> ChatArtifactGallerySnapshot { + var seenPaths: Set = [] + let freshCreated = fresh.created.filter { seenPaths.insert($0.path).inserted } + let freshAttached = fresh.attached.filter { seenPaths.insert($0.path).inserted } + let freshReferenced = fresh.referenced.filter { seenPaths.insert($0.path).inserted } + let retainedCreated = created.filter { seenPaths.insert($0.path).inserted } + let retainedAttached = attached.filter { seenPaths.insert($0.path).inserted } + let retainedReferenced = referenced.filter { seenPaths.insert($0.path).inserted } + let mergedReferenced = freshReferenced + retainedReferenced + return ChatArtifactGallerySnapshot( + created: freshCreated + retainedCreated, + attached: freshAttached + retainedAttached, + referenced: mergedReferenced, + referencedTotal: max(fresh.referencedTotal, mergedReferenced.count), + nextCursor: fresh.nextCursor, + generation: fresh.generation + ) + } + func limitingReferenced(to maximumCount: Int) -> ChatArtifactGallerySnapshot { ChatArtifactGallerySnapshot( created: created, diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryLiveRefreshStateTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryLiveRefreshStateTests.swift new file mode 100644 index 000000000000..eb647e837eb9 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryLiveRefreshStateTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact gallery live refresh") +struct ChatArtifactGalleryLiveRefreshStateTests { + @Test("a top or fitting gallery merges fresh rows immediately") + func mergesAtTop() throws { + var state = ChatArtifactGalleryLiveRefreshState() + let displayed = snapshot(generation: "one", paths: ["/old.txt"]) + let fresh = snapshot(generation: "two", paths: ["/new.txt", "/old.txt"]) + + let received = state.receive( + fresh: fresh, + displayed: displayed, + isAtTopOrFits: true + ) + let merged = try #require(received) + + #expect(merged.referenced.map(\.path) == ["/new.txt", "/old.txt"]) + #expect(state.pendingNewFileCount == 0) + } + + @Test("a reading position keeps the visible snapshot and reports unseen paths") + func defersAwayFromTop() { + var state = ChatArtifactGalleryLiveRefreshState() + let displayed = snapshot(generation: "one", paths: ["/old.txt"]) + let fresh = snapshot(generation: "two", paths: ["/new-a.txt", "/new-b.txt", "/old.txt"]) + + let merged = state.receive( + fresh: fresh, + displayed: displayed, + isAtTopOrFits: false + ) + + #expect(merged == nil) + #expect(state.pendingNewFileCount == 2) + #expect(displayed.referenced.map(\.path) == ["/old.txt"]) + } + + @Test("applying a pending refresh clears the pill and preserves loaded history") + func appliesPendingRefresh() throws { + var state = ChatArtifactGalleryLiveRefreshState() + let displayed = snapshot(generation: "one", paths: ["/old.txt", "/older.txt"]) + let fresh = snapshot(generation: "two", paths: ["/new.txt", "/old.txt"]) + #expect(state.receive( + fresh: fresh, + displayed: displayed, + isAtTopOrFits: false + ) == nil) + + let pendingApplication = state.applyPending(to: displayed) + let applied = try #require(pendingApplication) + + #expect(applied.referenced.map(\.path) == ["/new.txt", "/old.txt", "/older.txt"]) + #expect(applied.referencedTotal == 3) + #expect(applied.generation == "two") + #expect(state.pendingNewFileCount == 0) + #expect(state.applyPending(to: applied) == nil) + } + + private func snapshot(generation: String, paths: [String]) -> ChatArtifactGallerySnapshot { + ChatArtifactGallerySnapshot(page: ChatArtifactGalleryPage( + sessionID: "session", + referenced: paths.map { path in + ChatArtifactGalleryItem( + path: path, + kind: .text, + displayName: URL(fileURLWithPath: path).lastPathComponent, + provenance: .referenced + ) + }, + referencedTotal: paths.count, + generation: generation + )) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceCoordinator+Artifacts.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceCoordinator+Artifacts.swift index 02ff06453e89..707887975704 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceCoordinator+Artifacts.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceCoordinator+Artifacts.swift @@ -26,6 +26,7 @@ extension GhosttySurfaceRepresentable.Coordinator { artifactCountState.reset() artifactCountNeedsRefresh = artifactFilesEnabled visibleArtifactCount = 0 + freshestLocalArtifactCount = 0 return true } @@ -37,10 +38,14 @@ extension GhosttySurfaceRepresentable.Coordinator { case .none: break case .report(let report): - surfaceView.reportArtifactCount( + guard surfaceView.reportArtifactCount( report.count, generation: report.surfaceGeneration - ) + ) else { return } + onArtifactGalleryRefreshSignal(TerminalArtifactGalleryRefreshSignal( + count: report.count, + surfaceGeneration: report.surfaceGeneration + )) case .request(let request): startArtifactCountRequest(request, surfaceView: surfaceView) } @@ -74,16 +79,22 @@ extension GhosttySurfaceRepresentable.Coordinator { let completion = self.artifactCountState.complete( request, sessionTotal: sessionTotal, - currentSurfaceGeneration: surfaceView.visibleArtifactCountGeneration + currentSurfaceGeneration: surfaceView.visibleArtifactCountGeneration, + freshestLocalCount: self.freshestLocalArtifactCount ) guard self.artifactCountTaskRequest == request else { return } self.artifactCountTask = nil self.artifactCountTaskRequest = nil if case .reported(let report) = completion.outcome { - surfaceView.reportArtifactCount( + if surfaceView.reportArtifactCount( report.count, generation: report.surfaceGeneration - ) + ) { + self.onArtifactGalleryRefreshSignal(TerminalArtifactGalleryRefreshSignal( + count: report.count, + surfaceGeneration: report.surfaceGeneration + )) + } } if let nextRequest = completion.nextRequest { self.startArtifactCountRequest(nextRequest, surfaceView: surfaceView) @@ -183,6 +194,7 @@ extension GhosttySurfaceRepresentable.Coordinator { generation: UInt64 ) { guard artifactFilesEnabled else { return } + freshestLocalArtifactCount = count let action = artifactCountState.trigger( localCount: count, surfaceGeneration: generation, @@ -197,12 +209,17 @@ extension GhosttySurfaceRepresentable.Coordinator { artifactCountTaskRequest = nil artifactCountState.reset() artifactCountNeedsRefresh = artifactFilesEnabled + freshestLocalArtifactCount = 0 let previousCount = visibleArtifactCount visibleArtifactCount = 0 guard self.surfaceView === surfaceView else { return } updateArtifactChip(count: 0, enabled: artifactFilesEnabled) guard previousCount != 0 else { return } onVisibleArtifactCountChanged(0) + onArtifactGalleryRefreshSignal(TerminalArtifactGalleryRefreshSignal( + count: 0, + surfaceGeneration: surfaceView.visibleArtifactCountGeneration + )) } func ghosttySurfaceView( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift index 27423e201cc7..f24cf9453cd9 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift @@ -50,6 +50,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { var onArtifactFilesRequested: @MainActor (_ anchor: UnitPoint) -> Void = { _ in } var onArtifactPathTapped: @MainActor (_ path: String) -> Void = { _ in } var onVisibleArtifactCountChanged: @MainActor (_ count: Int) -> Void = { _ in } + var onArtifactGalleryRefreshSignal: @MainActor (TerminalArtifactGalleryRefreshSignal) -> Void = { _ in } func makeCoordinator() -> Coordinator { Coordinator( @@ -61,7 +62,8 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { visibleArtifactCount: visibleArtifactCount, onArtifactFilesRequested: onArtifactFilesRequested, onArtifactPathTapped: onArtifactPathTapped, - onVisibleArtifactCountChanged: onVisibleArtifactCountChanged + onVisibleArtifactCountChanged: onVisibleArtifactCountChanged, + onArtifactGalleryRefreshSignal: onArtifactGalleryRefreshSignal ) } @@ -129,6 +131,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { context.coordinator.onArtifactFilesRequested = onArtifactFilesRequested context.coordinator.onArtifactPathTapped = onArtifactPathTapped context.coordinator.onVisibleArtifactCountChanged = onVisibleArtifactCountChanged + context.coordinator.onArtifactGalleryRefreshSignal = onArtifactGalleryRefreshSignal let artifactCountModeChanged = context.coordinator.updateArtifactCountMode( artifactFilesEnabled: artifactFilesEnabled, sessionArtifactCountEnabled: sessionArtifactCountEnabled @@ -178,12 +181,14 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { var onArtifactFilesRequested: @MainActor (_ anchor: UnitPoint) -> Void var onArtifactPathTapped: @MainActor (_ path: String) -> Void var onVisibleArtifactCountChanged: @MainActor (_ count: Int) -> Void + var onArtifactGalleryRefreshSignal: @MainActor (TerminalArtifactGalleryRefreshSignal) -> Void private var outputTask: Task? private var liveFontTask: Task? var artifactCountTask: Task? var artifactCountTaskRequest: TerminalArtifactChipCountState.Request? var artifactCountState = TerminalArtifactChipCountState() var artifactCountNeedsRefresh: Bool + var freshestLocalArtifactCount = 0 /// Hosts the SwiftUI ``TerminalComposerView`` so it can be installed into the /// surface's composer band. Built lazily on first open and torn down on /// dismantle; mounted/unmounted by ``setComposerMounted(_:)``. @@ -218,7 +223,8 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { visibleArtifactCount: Int, onArtifactFilesRequested: @escaping @MainActor (_ anchor: UnitPoint) -> Void, onArtifactPathTapped: @escaping @MainActor (_ path: String) -> Void, - onVisibleArtifactCountChanged: @escaping @MainActor (_ count: Int) -> Void + onVisibleArtifactCountChanged: @escaping @MainActor (_ count: Int) -> Void, + onArtifactGalleryRefreshSignal: @escaping @MainActor (TerminalArtifactGalleryRefreshSignal) -> Void ) { self.workspaceID = workspaceID self.surfaceID = surfaceID @@ -230,6 +236,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { self.onArtifactFilesRequested = onArtifactFilesRequested self.onArtifactPathTapped = onArtifactPathTapped self.onVisibleArtifactCountChanged = onVisibleArtifactCountChanged + self.onArtifactGalleryRefreshSignal = onArtifactGalleryRefreshSignal super.init() } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings index a1582ab393ad..99251a96b6e2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings @@ -306,6 +306,13 @@ } } }, + "terminal.artifact.gallery.new_files" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld new files" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "新しいファイル%lld件" } } + } + }, "terminal.artifact.gallery.provenance.attached" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChipCountState.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChipCountState.swift index 00d92aa30570..0a3c2a3fd89b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChipCountState.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChipCountState.swift @@ -73,7 +73,8 @@ struct TerminalArtifactChipCountState: Sendable { mutating func complete( _ request: Request, sessionTotal: Int?, - currentSurfaceGeneration: UInt64 + currentSurfaceGeneration: UInt64, + freshestLocalCount: Int ) -> Completion { guard request.stateGeneration == stateGeneration, inFlight == request else { @@ -110,7 +111,7 @@ struct TerminalArtifactChipCountState: Sendable { consecutiveRearmCount += 1 let nextRequest = makeRequest(Pending( surfaceGeneration: currentSurfaceGeneration, - localCount: request.localCount + localCount: freshestLocalCount )) inFlight = nextRequest return Completion(outcome: outcome, nextRequest: nextRequest) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift index f0a8f25ad180..03115d78ea2f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift @@ -171,8 +171,12 @@ extension TerminalArtifactFilesSheet { let attached = presentation.items(in: .attached) let referenced = presentation.items(in: .referenced) let swipeOrder = ChatArtifactGallerySwipeOrder(groups: presentation.groups) - ScrollView { - VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 0) { + Color.clear + .frame(height: 0) + .id(Self.sessionScrollTopID) artifactSection( title: String( localized: "terminal.artifact.gallery.section.created", @@ -211,6 +215,22 @@ extension TerminalArtifactFilesSheet { showsEagerFooter: usesCompleteSessionSnapshot ) } + } + .onScrollGeometryChange(for: Bool.self) { geometry in + let isAtTop = geometry.contentOffset.y + <= geometry.contentInsets.top + Self.sessionTopTolerance + let fits = geometry.contentSize.height + <= geometry.containerSize.height + Self.sessionTopTolerance + return isAtTop || fits + } action: { _, isAtTopOrFits in + sessionViewportIsAtTopOrFits = isAtTopOrFits + } + .overlay(alignment: .top) { + if liveRefreshState.pendingNewFileCount > 0 { + newFilesPill(snapshot: snapshot, proxy: proxy) + .padding(.top, 8) + } + } } .refreshable { await loadFirstSessionPage(query: nil, preservingContent: true) @@ -219,6 +239,37 @@ extension TerminalArtifactFilesSheet { } } + private func newFilesPill( + snapshot: SessionGallerySnapshot, + proxy: ScrollViewProxy + ) -> some View { + let format = String( + localized: "terminal.artifact.gallery.new_files", + defaultValue: "%lld new files", + bundle: .module + ) + let title = String.localizedStringWithFormat( + format, + Int64(liveRefreshState.pendingNewFileCount) + ) + return Button { + guard let reconciled = liveRefreshState.applyPending(to: snapshot) else { return } + withAnimation(.easeInOut(duration: 0.25)) { + sessionState = .loaded(reconciled) + proxy.scrollTo(Self.sessionScrollTopID, anchor: .top) + } + } label: { + Label(title, systemImage: "arrow.up") + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(.regularMaterial, in: Capsule()) + .shadow(color: .black.opacity(0.12), radius: 5, y: 2) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + } + @ViewBuilder private func sessionSearchContent(state: SessionLoadState, query: String) -> some View { switch state { @@ -498,6 +549,9 @@ extension TerminalArtifactFilesSheet { ) } + private static let sessionScrollTopID = "terminal-artifact-gallery-top" + private static let sessionTopTolerance: CGFloat = 1 + private var galleryControls: some View { HStack(spacing: 12) { ScrollView(.horizontal, showsIndicators: false) { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift index eaaa6a21e963..760145d1f457 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift @@ -24,6 +24,7 @@ struct TerminalArtifactFilesSheet: View { let workspaceID: String let surfaceID: String let source: MobileChatEventSource? + let refreshSignal: TerminalArtifactGalleryRefreshSignal let loader: ChatArtifactLoader @State var inViewState: InViewLoadState = .loading @@ -44,8 +45,26 @@ struct TerminalArtifactFilesSheet: View { @State var attachedExpanded = true @State var referencedExpanded = true @State var thumbnailPrefetchTasks: [Task] = [] + @State var liveRefreshState = ChatArtifactGalleryLiveRefreshState() + @State var sessionViewportIsAtTopOrFits = true + @State var lastHandledRefreshSignal: TerminalArtifactGalleryRefreshSignal @Environment(\.dismiss) private var dismiss + init( + workspaceID: String, + surfaceID: String, + source: MobileChatEventSource?, + refreshSignal: TerminalArtifactGalleryRefreshSignal, + loader: ChatArtifactLoader + ) { + self.workspaceID = workspaceID + self.surfaceID = surfaceID + self.source = source + self.refreshSignal = refreshSignal + self.loader = loader + _lastHandledRefreshSignal = State(initialValue: refreshSignal) + } + var body: some View { NavigationStack { VStack(spacing: 0) { @@ -80,6 +99,9 @@ struct TerminalArtifactFilesSheet: View { .task(id: "\(workspaceID)#\(surfaceID)") { await loadInitial() } + .task(id: liveRefreshTaskID) { + await refreshSessionForLiveSignal() + } .sheet(item: $selection) { selection in ChatArtifactViewerSheet( path: selection.path, @@ -183,6 +205,7 @@ struct TerminalArtifactFilesSheet: View { guard query == searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) else { return } searchState = .loaded(snapshot) } else { + liveRefreshState.reset() sessionState = .loaded(snapshot) } eagerPagingRevision += 1 @@ -200,6 +223,46 @@ struct TerminalArtifactFilesSheet: View { } } + func refreshSessionForLiveSignal() async { + guard refreshSignal != lastHandledRefreshSignal, + scope == .session, + searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let source, + let sessionID, + case .loaded(let displayed) = sessionState else { return } + do { + let page = try await source.chatArtifactGallery( + sessionID: sessionID, + cursor: nil, + pageSize: Self.pageSize, + query: nil + ) + guard !Task.isCancelled else { return } + let fresh = SessionGallerySnapshot(page: page) + lastHandledRefreshSignal = refreshSignal + if let reconciled = liveRefreshState.receive( + fresh: fresh, + displayed: displayed, + isAtTopOrFits: sessionViewportIsAtTopOrFits + ) { + withAnimation(.easeInOut(duration: 0.25)) { + sessionState = .loaded(reconciled) + } + } + let displayedPaths = Set((displayed.created + displayed.attached + displayed.referenced).map(\.path)) + startThumbnailPrefetch( + (fresh.created + fresh.attached + fresh.referenced).filter { + !displayedPaths.contains($0.path) + } + ) + } catch is CancellationError { + return + } catch { + // Keep the current gallery stable. The next accepted chip signal + // retries against a newer terminal/session generation. + } + } + func loadNextSessionPage(cursor: String, query: String?) async { guard let source, let sessionID else { return } do { @@ -343,6 +406,16 @@ struct TerminalArtifactFilesSheet: View { ].joined(separator: "#") } + var liveRefreshTaskID: String { + [ + String(refreshSignal.surfaceGeneration), + String(refreshSignal.count), + sessionID ?? "unresolved", + String(describing: scope), + searchQuery.trimmingCharacters(in: .whitespacesAndNewlines), + ].joined(separator: "#") + } + enum InViewLoadState: Equatable { case loading case loaded([TerminalArtifactReference]) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryRefreshSignal.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryRefreshSignal.swift new file mode 100644 index 000000000000..7297d0e58baf --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryRefreshSignal.swift @@ -0,0 +1,7 @@ +/// One generation-checked artifact count accepted by the terminal chip pipeline. +struct TerminalArtifactGalleryRefreshSignal: Sendable, Equatable { + let count: Int + let surfaceGeneration: UInt64 + + static let initial = TerminalArtifactGalleryRefreshSignal(count: 0, surfaceGeneration: 0) +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift index fae73b9cb53d..83763ab353ce 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift @@ -47,6 +47,11 @@ extension WorkspaceDetailView { if visibleArtifactCount != count { visibleArtifactCount = count } + }, + onArtifactGalleryRefreshSignal: { signal in + if artifactGalleryRefreshSignal != signal { + artifactGalleryRefreshSignal = signal + } } ) .popover( @@ -58,6 +63,7 @@ extension WorkspaceDetailView { workspaceID: context.workspaceID, surfaceID: context.surfaceID, source: store.makeChatEventSource(), + refreshSignal: artifactGalleryRefreshSignal, loader: terminalArtifactLoader( workspaceID: context.workspaceID, surfaceID: context.surfaceID diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 125fc628517b..3a2c0503a2f5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -72,6 +72,7 @@ struct WorkspaceDetailView: View { @State var selectedTerminalArtifact: TerminalArtifactSelection? @State var terminalArtifactThumbnailCache = ChatArtifactThumbnailCache() @State var visibleArtifactCount = 0 + @State var artifactGalleryRefreshSignal = TerminalArtifactGalleryRefreshSignal.initial /// App lifecycle phase used to re-pull chat sessions on foreground. @Environment(\.scenePhase) var scenePhase #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChipCountStateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChipCountStateTests.swift index 914033c07e58..7fafbd6225d5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChipCountStateTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChipCountStateTests.swift @@ -21,7 +21,8 @@ struct TerminalArtifactChipCountStateTests { let completion = state.complete( nilTotalRequest, sessionTotal: nil, - currentSurfaceGeneration: 2 + currentSurfaceGeneration: 2, + freshestLocalCount: 5 ) #expect(completion.outcome == .reported(.init(count: 5, surfaceGeneration: 2))) @@ -33,7 +34,8 @@ struct TerminalArtifactChipCountStateTests { let zeroCompletion = state.complete( zeroRequest, sessionTotal: 0, - currentSurfaceGeneration: 3 + currentSurfaceGeneration: 3, + freshestLocalCount: 6 ) #expect(zeroCompletion.outcome == .reported(.init(count: 6, surfaceGeneration: 3))) } @@ -50,7 +52,8 @@ struct TerminalArtifactChipCountStateTests { #expect(resetState.complete( resetRequest, sessionTotal: 20, - currentSurfaceGeneration: 10 + currentSurfaceGeneration: 10, + freshestLocalCount: 2 ) == .stale) var surfaceState = TerminalArtifactChipCountState() @@ -62,7 +65,8 @@ struct TerminalArtifactChipCountStateTests { let completion = surfaceState.complete( surfaceRequest, sessionTotal: 30, - currentSurfaceGeneration: 12 + currentSurfaceGeneration: 12, + freshestLocalCount: 7 ) #expect(completion.outcome == .droppedForSurfaceGenerationMismatch) #expect(completion.nextRequest?.surfaceGeneration == 12) @@ -80,16 +84,18 @@ struct TerminalArtifactChipCountStateTests { let dropped = state.complete( request, sessionTotal: 30, - currentSurfaceGeneration: 12 + currentSurfaceGeneration: 12, + freshestLocalCount: 8 ) let rearmed = try #require(dropped.nextRequest) - #expect(rearmed.localCount == 3) + #expect(rearmed.localCount == 8) #expect(rearmed.surfaceGeneration == 12) let reported = state.complete( rearmed, sessionTotal: 30, - currentSurfaceGeneration: 12 + currentSurfaceGeneration: 12, + freshestLocalCount: 8 ) #expect(reported.outcome == .reported(.init(count: 30, surfaceGeneration: 12))) #expect(reported.nextRequest == nil) @@ -108,7 +114,8 @@ struct TerminalArtifactChipCountStateTests { let completion = state.complete( request, sessionTotal: 40, - currentSurfaceGeneration: UInt64(20 + offset) + currentSurfaceGeneration: UInt64(20 + offset), + freshestLocalCount: 4 + offset ) request = try #require(completion.nextRequest) } @@ -116,7 +123,8 @@ struct TerminalArtifactChipCountStateTests { let bounded = state.complete( request, sessionTotal: 40, - currentSurfaceGeneration: 100 + currentSurfaceGeneration: 100, + freshestLocalCount: 100 ) #expect(bounded.outcome == .droppedForSurfaceGenerationMismatch) #expect(bounded.nextRequest == nil) @@ -140,12 +148,14 @@ struct TerminalArtifactChipCountStateTests { #expect(state.complete( stale, sessionTotal: 10, - currentSurfaceGeneration: 31 + currentSurfaceGeneration: 31, + freshestLocalCount: 2 ) == .stale) #expect(state.complete( current, sessionTotal: 20, - currentSurfaceGeneration: 31 + currentSurfaceGeneration: 31, + freshestLocalCount: 2 ).outcome == .reported(.init(count: 20, surfaceGeneration: 31))) } @@ -171,7 +181,8 @@ struct TerminalArtifactChipCountStateTests { let completion = state.complete( first, sessionTotal: 10, - currentSurfaceGeneration: 22 + currentSurfaceGeneration: 22, + freshestLocalCount: 3 ) #expect(completion.outcome == .droppedForSurfaceGenerationMismatch) #expect(completion.nextRequest?.localCount == 3) From f2f45bd6aaf506456456d280f9a9baf6d9d27771 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 17:37:53 -0700 Subject: [PATCH 40/92] feat(ios): add artifact file actions --- .../ChatArtifactFileActionPresentation.swift | 127 ++++++++++++++++++ .../ChatArtifactFileActionStore.swift | 69 ++++++++++ .../ChatArtifactTemporaryFileStore.swift | 9 +- .../ChatArtifactTemporaryFileWriter.swift | 26 +++- .../Artifacts/ChatArtifactViewerModel.swift | 20 +++ .../ChatArtifactViewerRouteView.swift | 105 +++++++++++++++ .../Resources/Localizable.xcstrings | 56 ++++++++ .../ChatArtifactFileActionStoreTests.swift | 62 +++++++++ .../Resources/Localizable.xcstrings | 42 ++++++ .../TerminalArtifactGalleryItemView.swift | 82 +++++++++++ 10 files changed, 593 insertions(+), 5 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFileActionPresentation.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFileActionStore.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactFileActionStoreTests.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFileActionPresentation.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFileActionPresentation.swift new file mode 100644 index 000000000000..0cb420ff3358 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFileActionPresentation.swift @@ -0,0 +1,127 @@ +import SwiftUI + +#if os(iOS) +import UIKit + +/// A system presentation prepared by an artifact file action. +public enum ChatArtifactFileActionPresentation: Identifiable, Equatable { + /// Presents the standard activity controller for a local file. + case share(URL) + /// Presents the Files document picker in export-as-copy mode. + case save(URL) + + /// Stable identity for SwiftUI sheet presentation. + public var id: String { + switch self { + case .share(let url): + return "share:\(url.path)" + case .save(let url): + return "save:\(url.path)" + } + } + + fileprivate var fileURL: URL { + switch self { + case .share(let url), .save(let url): + return url + } + } +} + +public extension View { + /// Presents Share or Save to Files for a materialized artifact. + /// + /// Temporary files are removed when the system controller finishes. + func chatArtifactFileActionPresentation( + _ presentation: Binding + ) -> some View { + modifier(ChatArtifactFileActionPresentationModifier(presentation: presentation)) + } +} + +private struct ChatArtifactFileActionPresentationModifier: ViewModifier { + @Binding var presentation: ChatArtifactFileActionPresentation? + + func body(content: Content) -> some View { + content.sheet(item: $presentation) { item in + switch item { + case .share(let url): + ChatArtifactActivityView(fileURL: url) { + finish(item) + } + case .save(let url): + ChatArtifactDocumentPicker(fileURL: url) { + finish(item) + } + } + } + } + + private func finish(_ item: ChatArtifactFileActionPresentation) { + presentation = nil + Task { + await ChatArtifactFileActionStore.applicationDefault.remove(item.fileURL) + } + } +} + +private struct ChatArtifactActivityView: UIViewControllerRepresentable { + let fileURL: URL + let onFinish: () -> Void + + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController( + activityItems: [fileURL], + applicationActivities: nil + ) + controller.completionWithItemsHandler = { _, _, _, _ in + onFinish() + } + return controller + } + + func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} +} + +private struct ChatArtifactDocumentPicker: UIViewControllerRepresentable { + let fileURL: URL + let onFinish: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onFinish: onFinish) + } + + func makeUIViewController(context: Context) -> UIDocumentPickerViewController { + let controller = UIDocumentPickerViewController( + forExporting: [fileURL], + asCopy: true + ) + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController( + _ controller: UIDocumentPickerViewController, + context: Context + ) {} + + final class Coordinator: NSObject, UIDocumentPickerDelegate { + private let onFinish: () -> Void + + init(onFinish: @escaping () -> Void) { + self.onFinish = onFinish + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + onFinish() + } + + func documentPicker( + _ controller: UIDocumentPickerViewController, + didPickDocumentsAt urls: [URL] + ) { + onFinish() + } + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFileActionStore.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFileActionStore.swift new file mode 100644 index 000000000000..26aa414d51f7 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFileActionStore.swift @@ -0,0 +1,69 @@ +import CmuxAgentChat +import Foundation + +/// Materializes host artifacts as correctly named local files for system actions. +public actor ChatArtifactFileActionStore { + /// Shared purgeable store used by artifact viewers and gallery rows. + public static let applicationDefault = ChatArtifactFileActionStore() + + private let temporaryFileStore: ChatArtifactTemporaryFileStore + + /// Creates a file-action store rooted in a purgeable caches directory. + /// + /// - Parameter directory: Optional directory override used by tests. + public init(directory: URL? = nil) { + let resolvedDirectory: URL + if let directory { + resolvedDirectory = directory + } else { + let cachesDirectory = FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + ).first ?? FileManager.default.temporaryDirectory + resolvedDirectory = cachesDirectory.appendingPathComponent( + "cmux-artifact-actions", + isDirectory: true + ) + } + temporaryFileStore = ChatArtifactTemporaryFileStore(directory: resolvedDirectory) + } + + /// Stats and streams one file through the shared content cache. + /// + /// The returned URL keeps the source basename so system share and document + /// pickers display the filename the user expects. + /// + /// - Parameters: + /// - path: Absolute path on the Mac host. + /// - loader: Scoped artifact loader authorizing the path. + /// - Returns: A local, correctly named temporary file. + public func materialize( + path: String, + loader: ChatArtifactLoader + ) async throws -> URL { + let stat = try await loader.stat(path: path) + guard !stat.isDirectory else { + throw ChatArtifactError.unsupported + } + let filename = Self.filename(for: path) + return try await temporaryFileStore.fetch( + path: path, + expectedSize: stat.size, + modifiedAt: stat.modifiedAt, + limit: Int64.max, + preferredFilename: filename, + loader: loader, + progress: { _ in } + ) + } + + /// Removes a file previously returned by ``materialize(path:loader:)``. + public func remove(_ fileURL: URL) async { + await temporaryFileStore.remove(fileURL) + } + + private static func filename(for path: String) -> String { + let candidate = URL(fileURLWithPath: path).lastPathComponent + return candidate.isEmpty ? "Artifact" : candidate + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift index f09d51b2c4fe..8766de102e5f 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift @@ -27,6 +27,7 @@ actor ChatArtifactTemporaryFileStore { modifiedAt: Date? = nil, limit: Int64, fallbackExtension: String? = nil, + preferredFilename: String? = nil, loader: ChatArtifactLoader, progress: @escaping @Sendable (ChatArtifactChunk) async -> Void ) async throws -> URL { @@ -39,7 +40,8 @@ actor ChatArtifactTemporaryFileStore { : originalExtension let writer = try ChatArtifactTemporaryFileWriter( directory: directory, - fileExtension: fileExtension + fileExtension: fileExtension, + preferredFilename: preferredFilename ) do { try await loader.stream( @@ -61,5 +63,10 @@ actor ChatArtifactTemporaryFileStore { func remove(_ fileURL: URL) { try? FileManager.default.removeItem(at: fileURL) + let parent = fileURL.deletingLastPathComponent() + if parent != directory, + parent.deletingLastPathComponent() == directory { + try? FileManager.default.removeItem(at: parent) + } } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift index fdfd764c0c82..b10aaf2c4bc5 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift @@ -8,14 +8,32 @@ actor ChatArtifactTemporaryFileWriter { private var fileHandle: FileHandle? private var nextOffset: Int64 = 0 - init(directory: URL, fileExtension: String) throws { + init( + directory: URL, + fileExtension: String, + preferredFilename: String? = nil + ) throws { try FileManager.default.createDirectory( at: directory, withIntermediateDirectories: true ) - var fileURL = directory.appendingPathComponent(UUID().uuidString) - if !fileExtension.isEmpty { - fileURL.appendPathExtension(fileExtension) + let fileURL: URL + if let preferredFilename { + let itemDirectory = directory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + try FileManager.default.createDirectory( + at: itemDirectory, + withIntermediateDirectories: true + ) + fileURL = itemDirectory.appendingPathComponent(preferredFilename) + } else { + var generatedURL = directory.appendingPathComponent(UUID().uuidString) + if !fileExtension.isEmpty { + generatedURL.appendPathExtension(fileExtension) + } + fileURL = generatedURL } guard FileManager.default.createFile(atPath: fileURL.path, contents: nil) else { throw CocoaError(.fileWriteUnknown) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift index 652df96f7b0b..fa2f434e9c6f 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift @@ -12,6 +12,7 @@ final class ChatArtifactViewerModel { private(set) var totalBytes: Int64? private(set) var textReachedEOF = false private(set) var activePath: String? + private(set) var activeStat: ChatArtifactStat? private(set) var markdownPresentation = ChatArtifactMarkdownPresentation(byteCount: 0) private(set) var textHighlightDecision: ChatArtifactHighlightDecision = .skippedNoLanguage private(set) var textLineIndex = ChatArtifactLineIndex() @@ -28,6 +29,21 @@ final class ChatArtifactViewerModel { textChunks.joined() } + var hasFileActions: Bool { + guard let activeStat, !activeStat.isDirectory else { return false } + return state != .loading + } + + var isTextFile: Bool { + state == .text || state == .markdown + } + + var canCopyContents: Bool { + isTextFile + && textReachedEOF + && (activeStat?.size ?? .max) <= Self.maximumCopyContentsBytes + } + func load( path: String, loader: ChatArtifactLoader, @@ -41,6 +57,7 @@ final class ChatArtifactViewerModel { try Task.checkCancellation() guard path == activePath else { return } stat = loadedStat + activeStat = loadedStat totalBytes = loadedStat.size textHighlightDecision = ChatArtifactSyntaxHighlightPolicy().decision( path: path, @@ -149,6 +166,7 @@ final class ChatArtifactViewerModel { private func reset(for path: String) { activePath = path + activeStat = nil state = .loading textChunks = [] fetchedBytes = 0 @@ -284,4 +302,6 @@ final class ChatArtifactViewerModel { return .tooLarge(actualSize: stat?.size, limit: limitBytes) } } + + private static let maximumCopyContentsBytes: Int64 = 4 * 1024 * 1024 } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index 83569ea77604..7a924ac27c16 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -36,6 +36,11 @@ struct ChatArtifactViewerRouteView: View { @State private var goToLineRequestID = 0 @State private var wrapsLines: Bool @State private var textFontSize: Double + #if os(iOS) + @State private var fileActionPresentation: ChatArtifactFileActionPresentation? + @State private var isFileActionRunning = false + @State private var showsFileActionError = false + #endif init( path: String, @@ -68,6 +73,11 @@ struct ChatArtifactViewerRouteView: View { } } #if os(iOS) + if model.hasFileActions { + ToolbarItem(placement: .primaryAction) { + fileActionsMenu + } + } if shouldShowTextJumpControls { ToolbarItemGroup(placement: .primaryAction) { Button { @@ -195,6 +205,25 @@ struct ChatArtifactViewerRouteView: View { } #endif } + #if os(iOS) + .chatArtifactFileActionPresentation($fileActionPresentation) + .alert( + String( + localized: "chat.artifact.action_failed.title", + defaultValue: "Couldn't complete action", + bundle: .module + ), + isPresented: $showsFileActionError + ) { + Button(String(localized: "chat.artifact.ok", defaultValue: "OK", bundle: .module)) {} + } message: { + Text(String( + localized: "chat.artifact.action_failed.message", + defaultValue: "Check the connection to your Mac and try again.", + bundle: .module + )) + } + #endif .task(id: "\(path)\u{0}\(retryGeneration)") { #if os(iOS) await model.load( @@ -210,6 +239,82 @@ struct ChatArtifactViewerRouteView: View { } } + #if os(iOS) + private var fileActionsMenu: some View { + Menu { + Button { + prepareFileAction(.share) + } label: { + Label( + String(localized: "chat.artifact.share", defaultValue: "Share", bundle: .module), + systemImage: "square.and.arrow.up" + ) + } + Button { + prepareFileAction(.save) + } label: { + Label( + String(localized: "chat.artifact.save_to_files", defaultValue: "Save to Files", bundle: .module), + systemImage: "folder.badge.plus" + ) + } + if model.isTextFile { + Button { + UIPasteboard.general.string = model.renderedText + } label: { + Label( + String(localized: "chat.artifact.copy_contents", defaultValue: "Copy contents", bundle: .module), + systemImage: "doc.on.doc" + ) + } + .disabled(!model.canCopyContents) + } + Button { + UIPasteboard.general.string = path + } label: { + Label( + String(localized: "chat.artifact.copy_path", defaultValue: "Copy path", bundle: .module), + systemImage: "link" + ) + } + } label: { + Label( + String(localized: "chat.artifact.file_actions", defaultValue: "File actions", bundle: .module), + systemImage: "ellipsis.circle" + ) + } + .disabled(isFileActionRunning) + } + + private enum PreparedFileAction { + case share + case save + } + + private func prepareFileAction(_ action: PreparedFileAction) { + guard !isFileActionRunning else { return } + isFileActionRunning = true + Task { + do { + let fileURL = try await ChatArtifactFileActionStore.applicationDefault.materialize( + path: path, + loader: loader + ) + try Task.checkCancellation() + fileActionPresentation = switch action { + case .share: .share(fileURL) + case .save: .save(fileURL) + } + } catch is CancellationError { + // The viewer is going away; no error needs presenting. + } catch { + showsFileActionError = true + } + isFileActionRunning = false + } + } + #endif + /// Keeps cleanup structured under the SwiftUI page task after loading ends. private func waitForViewerTaskCancellation() async { let (stream, continuation) = AsyncStream.makeStream() diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index 6eab057c5d18..d865d1aea9ce 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -18,6 +18,20 @@ } } }, + "chat.artifact.action_failed.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Check the connection to your Mac and try again." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Macへの接続を確認して、もう一度お試しください。" } } + } + }, + "chat.artifact.action_failed.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Couldn't complete action" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "操作を完了できませんでした" } } + } + }, "chat.artifact.actions.title" : { "extractionState" : "manual", "localizations" : { @@ -52,6 +66,20 @@ } } }, + "chat.artifact.copy_contents" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Copy contents" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "内容をコピー" } } + } + }, + "chat.artifact.copy_path" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Copy path" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "パスをコピー" } } + } + }, "chat.artifact.done" : { "extractionState" : "manual", "localizations" : { @@ -69,6 +97,13 @@ } } }, + "chat.artifact.file_actions" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "File actions" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ファイル操作" } } + } + }, "chat.artifact.file_missing.message" : { "extractionState" : "manual", "localizations" : { @@ -545,6 +580,20 @@ } } }, + "chat.artifact.ok" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "OK" } } + } + }, + "chat.artifact.save_to_files" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Save to Files" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "「ファイル」に保存" } } + } + }, "chat.artifact.search.close" : { "extractionState" : "manual", "localizations" : { @@ -698,6 +747,13 @@ } } }, + "chat.artifact.share" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Share" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "共有" } } + } + }, "chat.artifact.text_options" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactFileActionStoreTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactFileActionStoreTests.swift new file mode 100644 index 000000000000..6aa742f6cb1e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactFileActionStoreTests.swift @@ -0,0 +1,62 @@ +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Chat artifact file actions") +struct ChatArtifactFileActionStoreTests { + @Test("materialized files retain their source name and reuse cached bytes") + func materializeNamedCachedFile() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-file-action-tests-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let cacheDirectory = root.appendingPathComponent("cache", isDirectory: true) + let actionDirectory = root.appendingPathComponent("actions", isDirectory: true) + let bytes = Data("quarterly results".utf8) + let modifiedAt = Date(timeIntervalSince1970: 123) + let source = CountingContentSource(values: ["report": bytes]) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + scope: .chat(sessionID: "session"), + contentCache: ChatArtifactContentCache(directory: cacheDirectory), + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: Int64(bytes.count), + modifiedAt: modifiedAt, + kind: .text, + mimeType: "text/plain" + ) + }, + fetch: { _, _ in bytes }, + stream: { _, receive in + try await source.fetch(key: "report", receive: receive) + } + ) + let store = ChatArtifactFileActionStore(directory: actionDirectory) + + let first = try await store.materialize( + path: "/Users/example/Quarterly Report.txt", + loader: loader + ) + let second = try await store.materialize( + path: "/Users/example/Quarterly Report.txt", + loader: loader + ) + + #expect(first.lastPathComponent == "Quarterly Report.txt") + #expect(second.lastPathComponent == "Quarterly Report.txt") + #expect(try Data(contentsOf: first) == bytes) + #expect(try Data(contentsOf: second) == bytes) + #expect(await source.fetchCount(for: "report") == 1) + + await store.remove(first) + await store.remove(second) + #expect(!FileManager.default.fileExists(atPath: first.path)) + #expect(!FileManager.default.fileExists(atPath: second.path)) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings index 99251a96b6e2..629e26b1f7c5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings @@ -70,6 +70,27 @@ } } }, + "terminal.artifact.gallery.action_failed.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Check the connection to your Mac and try again." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Macへの接続を確認して、もう一度お試しください。" } } + } + }, + "terminal.artifact.gallery.action_failed.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Couldn't complete action" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "操作を完了できませんでした" } } + } + }, + "terminal.artifact.gallery.browse_folder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Browse folder" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "フォルダを表示" } } + } + }, "terminal.artifact.gallery.child_count" : { "extractionState" : "manual", "localizations" : { @@ -104,6 +125,13 @@ } } }, + "terminal.artifact.gallery.copy_path" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Copy path" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "パスをコピー" } } + } + }, "terminal.artifact.gallery.empty" : { "extractionState" : "manual", "localizations" : { @@ -313,6 +341,13 @@ "ja" : { "stringUnit" : { "state" : "translated", "value" : "新しいファイル%lld件" } } } }, + "terminal.artifact.gallery.ok" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "OK" } } + } + }, "terminal.artifact.gallery.provenance.attached" : { "extractionState" : "manual", "localizations" : { @@ -403,6 +438,13 @@ "ja" : { "stringUnit" : { "state" : "translated", "value" : "並べ替え" } } } }, + "terminal.artifact.gallery.share" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Share" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "共有" } } + } + }, "terminal.artifact.gallery.sort.name" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift index 5f9bc9564a03..5e57980588dc 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift @@ -18,6 +18,9 @@ struct TerminalArtifactGalleryItemView: View { let open: () -> Void @State private var thumbnail: ChatArtifactThumbnail? + @State private var fileActionPresentation: ChatArtifactFileActionPresentation? + @State private var isFileActionRunning = false + @State private var showsFileActionError = false @ScaledMetric(relativeTo: .subheadline) private var gridNameMinHeight: CGFloat = 38 @ScaledMetric(relativeTo: .caption2) private var gridMetadataMinHeight: CGFloat = 28 @ScaledMetric(relativeTo: .body) private var gridSymbolSize: CGFloat = 48 @@ -37,6 +40,65 @@ struct TerminalArtifactGalleryItemView: View { .accessibilityLabel(artifact.displayName) .accessibilityValue(accessibilityDetail) .opacity(artifact.exists ? 1 : 0.5) + .contextMenu { + if artifact.kind != .directory { + Button { + shareFile() + } label: { + Label( + String( + localized: "terminal.artifact.gallery.share", + defaultValue: "Share", + bundle: .module + ), + systemImage: "square.and.arrow.up" + ) + } + .disabled(!artifact.exists || isFileActionRunning) + } + Button { + UIPasteboard.general.string = artifact.path + } label: { + Label( + String( + localized: "terminal.artifact.gallery.copy_path", + defaultValue: "Copy path", + bundle: .module + ), + systemImage: "link" + ) + } + if artifact.kind == .directory { + Button(action: open) { + Label( + String( + localized: "terminal.artifact.gallery.browse_folder", + defaultValue: "Browse folder", + bundle: .module + ), + systemImage: "folder" + ) + } + .disabled(!artifact.exists) + } + } + .chatArtifactFileActionPresentation($fileActionPresentation) + .alert( + String( + localized: "terminal.artifact.gallery.action_failed.title", + defaultValue: "Couldn't complete action", + bundle: .module + ), + isPresented: $showsFileActionError + ) { + Button(String(localized: "terminal.artifact.gallery.ok", defaultValue: "OK", bundle: .module)) {} + } message: { + Text(String( + localized: "terminal.artifact.gallery.action_failed.message", + defaultValue: "Check the connection to your Mac and try again.", + bundle: .module + )) + } .task(id: "\(artifact.path)#\(Self.thumbnailDimension)") { guard artifact.kind == .image, artifact.exists else { return } thumbnail = try? await loader.thumbnail( @@ -48,6 +110,26 @@ struct TerminalArtifactGalleryItemView: View { } } + private func shareFile() { + guard !isFileActionRunning else { return } + isFileActionRunning = true + Task { + do { + let fileURL = try await ChatArtifactFileActionStore.applicationDefault.materialize( + path: artifact.path, + loader: loader + ) + try Task.checkCancellation() + fileActionPresentation = .share(fileURL) + } catch is CancellationError { + // The row disappeared while its file was being prepared. + } catch { + showsFileActionError = true + } + isFileActionRunning = false + } + } + private var listContent: some View { HStack(spacing: 12) { preview From 42a04b98c7df9a7d70c5cdb15504bb946125ccb7 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 17:44:08 -0700 Subject: [PATCH 41/92] feat(ios): zoom artifact images without paging --- .../Artifacts/ChatArtifactFolderView.swift | 2 + .../Artifacts/ChatArtifactViewerPager.swift | 11 ++ .../ChatArtifactViewerRouteView.swift | 25 +++- .../Artifacts/ChatArtifactZoomPolicy.swift | 26 ++++ .../ChatArtifactZoomableImageView.swift | 112 ++++++++++++++++++ .../ChatArtifactZoomPolicyTests.swift | 26 ++++ 6 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomPolicy.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomableImageView.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactZoomPolicyTests.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift index d0de3a2eaf67..078b2d50b194 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift @@ -10,6 +10,7 @@ import AppKit struct ChatArtifactFolderView: View { let path: String let scope: ChatArtifactViewerScope + let onImageMinimumZoomChanged: (Bool) -> Void let onDone: () -> Void @Environment(\.chatArtifactLoader) private var loader @@ -45,6 +46,7 @@ struct ChatArtifactFolderView: View { ChatArtifactViewerRouteView( path: childPath(named: entry.name), scope: scope, + onImageMinimumZoomChanged: onImageMinimumZoomChanged, onDone: onDone ) } label: { diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift index 5feaf0b94963..8b83ae689f3c 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift @@ -9,6 +9,7 @@ struct ChatArtifactViewerPager: View { let onDone: () -> Void @State private var selectedPath: String + @State private var zoomedPath: String? init( initialPath: String, @@ -35,6 +36,7 @@ struct ChatArtifactViewerPager: View { } } .tabViewStyle(.page(indexDisplayMode: .never)) + .scrollDisabled(zoomedPath != nil) } else { viewer(path: initialPath) } @@ -48,6 +50,15 @@ struct ChatArtifactViewerPager: View { ChatArtifactViewerRouteView( path: path, scope: scope, + onImageMinimumZoomChanged: { isAtMinimum in + if isAtMinimum { + if zoomedPath == path { + zoomedPath = nil + } + } else { + zoomedPath = path + } + }, onDone: onDone ) } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index 7a924ac27c16..e19ccea5e217 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -15,6 +15,7 @@ struct ChatArtifactViewerRouteView: View { let path: String let scope: ChatArtifactViewerScope let onDone: () -> Void + let onImageMinimumZoomChanged: (Bool) -> Void private let textPreferences: ChatArtifactTextPreferences private let textLayoutKind: ChatArtifactTextLayoutKind @@ -48,11 +49,13 @@ struct ChatArtifactViewerRouteView: View { textPreferences: ChatArtifactTextPreferences = ChatArtifactTextPreferences( defaults: .standard ), + onImageMinimumZoomChanged: @escaping (Bool) -> Void = { _ in }, onDone: @escaping () -> Void ) { self.path = path self.scope = scope self.onDone = onDone + self.onImageMinimumZoomChanged = onImageMinimumZoomChanged self.textPreferences = textPreferences let layoutKind = ChatArtifactTextLayoutKind(path: path) textLayoutKind = layoutKind @@ -352,12 +355,32 @@ struct ChatArtifactViewerRouteView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() case .folder: - ChatArtifactFolderView(path: path, scope: scope, onDone: onDone) + ChatArtifactFolderView( + path: path, + scope: scope, + onImageMinimumZoomChanged: onImageMinimumZoomChanged, + onDone: onDone + ) case .image(let data): + #if os(iOS) + if let image = UIImage(data: data) { + ChatArtifactZoomableImageView( + image: image, + onMinimumZoomChanged: onImageMinimumZoomChanged + ) + .ignoresSafeArea(.container, edges: .bottom) + .onDisappear { + onImageMinimumZoomChanged(true) + } + } else { + Color.clear + } + #else artifactImage(data: data) .scaledToFit() .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() + #endif case .pdf(let fileURL): #if os(iOS) ChatArtifactPDFView(fileURL: fileURL) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomPolicy.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomPolicy.swift new file mode 100644 index 000000000000..5925213c5be7 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomPolicy.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Scale rules shared by artifact image zoom gestures and tests. +struct ChatArtifactZoomPolicy: Equatable, Sendable { + let minimumScale: Double + let doubleTapScale: Double + let maximumScale: Double + + init( + minimumScale: Double = 1, + doubleTapScale: Double = 3, + maximumScale: Double = 8 + ) { + self.minimumScale = minimumScale + self.doubleTapScale = doubleTapScale + self.maximumScale = maximumScale + } + + func isAtMinimum(_ scale: Double) -> Bool { + abs(scale - minimumScale) <= 0.01 + } + + func scaleAfterDoubleTap(currentScale: Double) -> Double { + isAtMinimum(currentScale) ? doubleTapScale : minimumScale + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomableImageView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomableImageView.swift new file mode 100644 index 000000000000..79f6e9367a1e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomableImageView.swift @@ -0,0 +1,112 @@ +#if os(iOS) +import SwiftUI +import UIKit + +/// An aspect-fit image surface with native pinch, pan, and double-tap zoom. +struct ChatArtifactZoomableImageView: UIViewRepresentable { + let image: UIImage + let onMinimumZoomChanged: (Bool) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onMinimumZoomChanged: onMinimumZoomChanged) + } + + func makeUIView(context: Context) -> UIScrollView { + let scrollView = UIScrollView() + scrollView.delegate = context.coordinator + scrollView.minimumZoomScale = CGFloat(context.coordinator.policy.minimumScale) + scrollView.maximumZoomScale = CGFloat(context.coordinator.policy.maximumScale) + scrollView.zoomScale = scrollView.minimumZoomScale + scrollView.bouncesZoom = true + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.contentInsetAdjustmentBehavior = .never + + let imageView = context.coordinator.imageView + imageView.image = image + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + imageView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + imageView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + imageView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + imageView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + ]) + + let doubleTap = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.didDoubleTap(_:)) + ) + doubleTap.numberOfTapsRequired = 2 + scrollView.addGestureRecognizer(doubleTap) + context.coordinator.scrollView = scrollView + context.coordinator.reportMinimumZoomIfNeeded(force: true) + return scrollView + } + + func updateUIView(_ scrollView: UIScrollView, context: Context) { + context.coordinator.onMinimumZoomChanged = onMinimumZoomChanged + if context.coordinator.imageView.image !== image { + context.coordinator.imageView.image = image + scrollView.setZoomScale(scrollView.minimumZoomScale, animated: false) + } + context.coordinator.reportMinimumZoomIfNeeded(force: false) + } + + final class Coordinator: NSObject, UIScrollViewDelegate { + let imageView = UIImageView() + let policy = ChatArtifactZoomPolicy() + weak var scrollView: UIScrollView? + var onMinimumZoomChanged: (Bool) -> Void + private var lastReportedMinimumState: Bool? + + init(onMinimumZoomChanged: @escaping (Bool) -> Void) { + self.onMinimumZoomChanged = onMinimumZoomChanged + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + imageView + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + reportMinimumZoomIfNeeded(force: false) + } + + @objc + func didDoubleTap(_ recognizer: UITapGestureRecognizer) { + guard let scrollView else { return } + let targetScale = CGFloat(policy.scaleAfterDoubleTap( + currentScale: Double(scrollView.zoomScale) + )) + if policy.isAtMinimum(Double(scrollView.zoomScale)) { + let location = recognizer.location(in: imageView) + let width = scrollView.bounds.width / targetScale + let height = scrollView.bounds.height / targetScale + scrollView.zoom( + to: CGRect( + x: location.x - width / 2, + y: location.y - height / 2, + width: width, + height: height + ), + animated: true + ) + } else { + scrollView.setZoomScale(targetScale, animated: true) + } + } + + func reportMinimumZoomIfNeeded(force: Bool) { + guard let scrollView else { return } + let isAtMinimum = policy.isAtMinimum(Double(scrollView.zoomScale)) + guard force || lastReportedMinimumState != isAtMinimum else { return } + lastReportedMinimumState = isAtMinimum + onMinimumZoomChanged(isAtMinimum) + } + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactZoomPolicyTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactZoomPolicyTests.swift new file mode 100644 index 000000000000..6cc20ac0f6a7 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactZoomPolicyTests.swift @@ -0,0 +1,26 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Chat artifact zoom policy") +struct ChatArtifactZoomPolicyTests { + @Test("fitted images double tap to three times and back") + func doubleTapToggle() { + let policy = ChatArtifactZoomPolicy() + + #expect(policy.minimumScale == 1) + #expect(policy.maximumScale == 8) + #expect(policy.scaleAfterDoubleTap(currentScale: 1) == 3) + #expect(policy.scaleAfterDoubleTap(currentScale: 3) == 1) + #expect(policy.scaleAfterDoubleTap(currentScale: 7) == 1) + } + + @Test("paging threshold tolerates UIScrollView scale noise") + func minimumThreshold() { + let policy = ChatArtifactZoomPolicy() + + #expect(policy.isAtMinimum(1)) + #expect(policy.isAtMinimum(1.005)) + #expect(!policy.isAtMinimum(1.02)) + } +} From 3d7b3722b30bda7e5fa5b8b851616f1a980002af Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 17:51:05 -0700 Subject: [PATCH 42/92] perf: page artifact stats across gallery sections --- .../ChatArtifactGalleryBuilder.swift | 94 ++++++++++++------- .../Artifacts/ChatArtifactGalleryCursor.swift | 22 +++++ .../Artifacts/ChatArtifactGalleryPage.swift | 20 +++- .../ChatArtifactGallerySnapshot.swift | 38 ++++++-- .../ChatArtifactGallerySnapshotTests.swift | 10 +- .../ChatArtifactGalleryTests.swift | 63 ++++++++++++- .../TerminalArtifactFilesSheet+Content.swift | 4 +- ...rminalController+MobileChatArtifacts.swift | 7 +- 8 files changed, 204 insertions(+), 54 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift index c7b098843391..3eb498116614 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift @@ -11,8 +11,8 @@ public struct ChatArtifactGalleryBuilder: Sendable { /// - sessionID: Session represented by the artifact index. /// - items: De-duplicated transcript artifact references. /// - generation: Stable snapshot generation carried by page cursors. - /// - cursor: Position after which the referenced section continues. - /// - pageSize: Maximum referenced entries to include. + /// - cursor: Per-section positions after which paging continues. + /// - pageSize: Maximum entries to stat and include per section. /// - query: Optional basename or path search. /// - includeDirectories: Whether directory references are eligible for /// rows. This defaults to `false` for clients without folder capability. @@ -27,64 +27,94 @@ public struct ChatArtifactGalleryBuilder: Sendable { includeDirectories: Bool = false ) -> ChatArtifactGalleryPage { let ordering = ChatArtifactGalleryOrdering() - let eligibleItems = eligibleItems(items, includeDirectories: includeDirectories) let normalizedQuery = query?.trimmingCharacters(in: .whitespacesAndNewlines) let isSearch = normalizedQuery?.isEmpty == false - let candidates: [ChatArtifactIndexedReference] + let createdCandidates: [ChatArtifactIndexedReference] + let attachedCandidates: [ChatArtifactIndexedReference] + let referencedCandidates: [ChatArtifactIndexedReference] if let normalizedQuery, !normalizedQuery.isEmpty { - candidates = ordering.search(eligibleItems, query: normalizedQuery) + createdCandidates = [] + attachedCandidates = [] + referencedCandidates = ordering.search(items, query: normalizedQuery) } else { - candidates = ordering.sorted(eligibleItems.filter { $0.provenance == .referenced }) + createdCandidates = ordering.sorted(items.filter { $0.provenance == .created }) + attachedCandidates = ordering.sorted(items.filter { $0.provenance == .attached }) + referencedCandidates = ordering.sorted(items.filter { $0.provenance == .referenced }) } - let remaining = ordering.items(candidates, strictlyAfter: cursor) - let pageReferences = Array(remaining.prefix(pageSize)) + let count = max(1, pageSize) + let starts = pageStarts( + cursor: cursor, + created: createdCandidates, + attached: attachedCandidates, + referenced: referencedCandidates, + ordering: ordering + ) + let pageCreated = Array(createdCandidates.dropFirst(starts.created).prefix(count)) + let pageAttached = Array(attachedCandidates.dropFirst(starts.attached).prefix(count)) + let pageReferenced = Array(referencedCandidates.dropFirst(starts.referenced).prefix(count)) + let nextCreatedOffset = starts.created + pageCreated.count + let nextAttachedOffset = starts.attached + pageAttached.count + let nextReferencedOffset = starts.referenced + pageReferenced.count let nextCursor: String? - if remaining.count > pageReferences.count, let last = pageReferences.last { + if nextCreatedOffset < createdCandidates.count + || nextAttachedOffset < attachedCandidates.count + || nextReferencedOffset < referencedCandidates.count { + let last = pageReferenced.last nextCursor = try? ChatArtifactGalleryCursor( generation: generation, - seq: last.lastReferencedSeq, - path: last.path + seq: last?.lastReferencedSeq ?? cursor?.seq ?? .max, + path: last?.path ?? cursor?.path ?? "", + createdOffset: nextCreatedOffset, + attachedOffset: nextAttachedOffset, + referencedOffset: nextReferencedOffset ).token() } else { nextCursor = nil } - let includeCompleteSections = cursor == nil && !isSearch - let created = includeCompleteSections - ? statItems(ordering.sorted(eligibleItems.filter { $0.provenance == .created })) - : [] - let attached = includeCompleteSections - ? statItems(ordering.sorted(eligibleItems.filter { $0.provenance == .attached })) - : [] return ChatArtifactGalleryPage( sessionID: sessionID, - created: created, - attached: attached, - referenced: statItems(pageReferences), - referencedTotal: candidates.count, + created: isSearch ? [] : statItems(pageCreated, includeDirectories: includeDirectories), + createdTotal: createdCandidates.count, + attached: isSearch ? [] : statItems(pageAttached, includeDirectories: includeDirectories), + attachedTotal: attachedCandidates.count, + referenced: statItems(pageReferenced, includeDirectories: includeDirectories), + referencedTotal: referencedCandidates.count, nextCursor: nextCursor, generation: generation ) } - private func eligibleItems( - _ items: [ChatArtifactIndexedReference], - includeDirectories: Bool - ) -> [ChatArtifactIndexedReference] { - guard !includeDirectories else { return items } - let reader = ArtifactByteReader() - return items.filter { reference in - (try? reader.stat(path: reference.path).isDirectory) != true + private func pageStarts( + cursor: ChatArtifactGalleryCursor?, + created: [ChatArtifactIndexedReference], + attached: [ChatArtifactIndexedReference], + referenced: [ChatArtifactIndexedReference], + ordering: ChatArtifactGalleryOrdering + ) -> (created: Int, attached: Int, referenced: Int) { + guard let cursor else { return (0, 0, 0) } + if let createdOffset = cursor.createdOffset, + let attachedOffset = cursor.attachedOffset, + let referencedOffset = cursor.referencedOffset { + return ( + min(max(0, createdOffset), created.count), + min(max(0, attachedOffset), attached.count), + min(max(0, referencedOffset), referenced.count) + ) } + let remaining = ordering.items(referenced, strictlyAfter: cursor) + return (created.count, attached.count, referenced.count - remaining.count) } private func statItems( - _ references: [ChatArtifactIndexedReference] + _ references: [ChatArtifactIndexedReference], + includeDirectories: Bool ) -> [ChatArtifactGalleryItem] { let reader = ArtifactByteReader() - return references.map { reference in + return references.compactMap { reference in do { let stat = try reader.stat(path: reference.path) + guard includeDirectories || !stat.isDirectory else { return nil } let listing = stat.isDirectory ? try? reader.list(path: reference.path) : nil return ChatArtifactGalleryItem( path: reference.path, diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryCursor.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryCursor.swift index a66c4909ffbb..981217f37c88 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryCursor.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryCursor.swift @@ -8,12 +8,34 @@ public struct ChatArtifactGalleryCursor: Sendable, Equatable, Codable { public let seq: Int /// Path tie-breaker of the page's final row. public let path: String + let createdOffset: Int? + let attachedOffset: Int? + let referencedOffset: Int? /// Creates a stable cursor. public init(generation: String, seq: Int, path: String) { self.generation = generation self.seq = seq self.path = path + createdOffset = nil + attachedOffset = nil + referencedOffset = nil + } + + init( + generation: String, + seq: Int, + path: String, + createdOffset: Int, + attachedOffset: Int, + referencedOffset: Int + ) { + self.generation = generation + self.seq = seq + self.path = path + self.createdOffset = createdOffset + self.attachedOffset = attachedOffset + self.referencedOffset = referencedOffset } /// Encodes this cursor as an opaque RPC token. diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift index 69c8a790de88..cae487475300 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift @@ -2,15 +2,19 @@ public struct ChatArtifactGalleryPage: Sendable, Equatable, Codable { /// Session that authorizes every returned path. public let sessionID: String - /// Complete created paths, present only on the first sectioned page. + /// Created paths in this page. public let created: [ChatArtifactGalleryItem] - /// Complete attachment paths, present only on the first sectioned page. + /// Total created paths in the stable snapshot. + public let createdTotal: Int + /// Attachment paths in this page. public let attached: [ChatArtifactGalleryItem] + /// Total attachment paths in the stable snapshot. + public let attachedTotal: Int /// Referenced page, or a flat all-provenance search-result page. public let referenced: [ChatArtifactGalleryItem] /// Total referenced count, or total search-result count for a query. public let referencedTotal: Int - /// Opaque cursor for the next append-only page. + /// Opaque cursor for the next append-only page across all sections. public let nextCursor: String? /// Snapshot generation that served this response. public let generation: String @@ -19,7 +23,9 @@ public struct ChatArtifactGalleryPage: Sendable, Equatable, Codable { public init( sessionID: String, created: [ChatArtifactGalleryItem] = [], + createdTotal: Int? = nil, attached: [ChatArtifactGalleryItem] = [], + attachedTotal: Int? = nil, referenced: [ChatArtifactGalleryItem] = [], referencedTotal: Int = 0, nextCursor: String? = nil, @@ -27,7 +33,9 @@ public struct ChatArtifactGalleryPage: Sendable, Equatable, Codable { ) { self.sessionID = sessionID self.created = created + self.createdTotal = createdTotal ?? created.count self.attached = attached + self.attachedTotal = attachedTotal ?? attached.count self.referenced = referenced self.referencedTotal = referencedTotal self.nextCursor = nextCursor @@ -37,7 +45,9 @@ public struct ChatArtifactGalleryPage: Sendable, Equatable, Codable { private enum CodingKeys: String, CodingKey { case sessionID = "session_id" case created + case createdTotal = "created_total" case attached + case attachedTotal = "attached_total" case referenced case referencedTotal = "referenced_total" case nextCursor = "next_cursor" @@ -48,7 +58,9 @@ public struct ChatArtifactGalleryPage: Sendable, Equatable, Codable { let container = try decoder.container(keyedBy: CodingKeys.self) sessionID = (try? container.decode(String.self, forKey: .sessionID)) ?? "" created = (try? container.decode([ChatArtifactGalleryItem].self, forKey: .created)) ?? [] + createdTotal = (try? container.decode(Int.self, forKey: .createdTotal)) ?? created.count attached = (try? container.decode([ChatArtifactGalleryItem].self, forKey: .attached)) ?? [] + attachedTotal = (try? container.decode(Int.self, forKey: .attachedTotal)) ?? attached.count referenced = (try? container.decode([ChatArtifactGalleryItem].self, forKey: .referenced)) ?? [] referencedTotal = (try? container.decode(Int.self, forKey: .referencedTotal)) ?? referenced.count nextCursor = try? container.decode(String.self, forKey: .nextCursor) @@ -66,7 +78,9 @@ public struct ChatArtifactGalleryPage: Sendable, Equatable, Codable { return ChatArtifactGalleryPage( sessionID: sessionID, created: filteredCreated, + createdTotal: max(0, createdTotal - (created.count - filteredCreated.count)), attached: filteredAttached, + attachedTotal: max(0, attachedTotal - (attached.count - filteredAttached.count)), referenced: filteredReferenced, referencedTotal: max(0, referencedTotal - (referenced.count - filteredReferenced.count)), nextCursor: nextCursor, diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift index 183fc767a3c9..edc66aed337f 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift @@ -1,21 +1,25 @@ /// Accumulated section rows and paging state for a session artifact gallery. public struct ChatArtifactGallerySnapshot: Sendable, Equatable { - /// Created artifact rows from the first page. + /// Created artifact rows accumulated across pages. public let created: [ChatArtifactGalleryItem] - /// Attached artifact rows from the first page. + /// Complete created-row count reported by the host. + public let createdTotal: Int + /// Attached artifact rows accumulated across pages. public let attached: [ChatArtifactGalleryItem] + /// Complete attached-row count reported by the host. + public let attachedTotal: Int /// Referenced artifact rows accumulated across pages. public let referenced: [ChatArtifactGalleryItem] /// Complete referenced-row count reported by the host. public let referencedTotal: Int - /// Cursor for the next referenced page. + /// Cursor for the next page across any incomplete section. public let nextCursor: String? /// Host snapshot generation that served the latest page. public let generation: String /// Whether the complete gallery has no rows. public var isEmpty: Bool { - created.isEmpty && attached.isEmpty && referencedTotal == 0 + createdTotal == 0 && attachedTotal == 0 && referencedTotal == 0 } /// Creates an accumulated snapshot from a first gallery page. @@ -23,27 +27,37 @@ public struct ChatArtifactGallerySnapshot: Sendable, Equatable { /// - Parameter page: The first sectioned gallery page. public init(page: ChatArtifactGalleryPage) { created = page.created + createdTotal = page.createdTotal attached = page.attached + attachedTotal = page.attachedTotal referenced = page.referenced referencedTotal = page.referencedTotal nextCursor = page.nextCursor generation = page.generation } - /// Appends a referenced page while dropping paths already in any section. + /// Appends one sectioned page while dropping paths already in any section. /// /// First-seen order is preserved both across pages and within `page`. /// - /// - Parameter page: The next referenced gallery page. + /// - Parameter page: The next sectioned gallery page. /// - Returns: A snapshot containing only path-unique appended rows. public func appending(_ page: ChatArtifactGalleryPage) -> ChatArtifactGallerySnapshot { var seenPaths = Set((created + attached + referenced).map(\.path)) + let uniqueCreated = page.created.filter { item in + seenPaths.insert(item.path).inserted + } + let uniqueAttached = page.attached.filter { item in + seenPaths.insert(item.path).inserted + } let uniqueReferenced = page.referenced.filter { item in seenPaths.insert(item.path).inserted } return ChatArtifactGallerySnapshot( - created: created, - attached: attached, + created: created + uniqueCreated, + createdTotal: page.createdTotal, + attached: attached + uniqueAttached, + attachedTotal: page.attachedTotal, referenced: referenced + uniqueReferenced, referencedTotal: page.referencedTotal, nextCursor: page.nextCursor, @@ -70,7 +84,9 @@ public struct ChatArtifactGallerySnapshot: Sendable, Equatable { let mergedReferenced = freshReferenced + retainedReferenced return ChatArtifactGallerySnapshot( created: freshCreated + retainedCreated, + createdTotal: max(fresh.createdTotal, freshCreated.count + retainedCreated.count), attached: freshAttached + retainedAttached, + attachedTotal: max(fresh.attachedTotal, freshAttached.count + retainedAttached.count), referenced: mergedReferenced, referencedTotal: max(fresh.referencedTotal, mergedReferenced.count), nextCursor: fresh.nextCursor, @@ -81,7 +97,9 @@ public struct ChatArtifactGallerySnapshot: Sendable, Equatable { func limitingReferenced(to maximumCount: Int) -> ChatArtifactGallerySnapshot { ChatArtifactGallerySnapshot( created: created, + createdTotal: createdTotal, attached: attached, + attachedTotal: attachedTotal, referenced: Array(referenced.prefix(max(0, maximumCount))), referencedTotal: referencedTotal, nextCursor: nextCursor, @@ -91,14 +109,18 @@ public struct ChatArtifactGallerySnapshot: Sendable, Equatable { private init( created: [ChatArtifactGalleryItem], + createdTotal: Int, attached: [ChatArtifactGalleryItem], + attachedTotal: Int, referenced: [ChatArtifactGalleryItem], referencedTotal: Int, nextCursor: String?, generation: String ) { self.created = created + self.createdTotal = createdTotal self.attached = attached + self.attachedTotal = attachedTotal self.referenced = referenced self.referencedTotal = referencedTotal self.nextCursor = nextCursor diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySnapshotTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySnapshotTests.swift index 26f9d8aadb99..834c99623ea3 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySnapshotTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySnapshotTests.swift @@ -22,6 +22,10 @@ struct ChatArtifactGallerySnapshotTests { ) let page = ChatArtifactGalleryPage( sessionID: "session", + created: [created, item(path: "/session/created-next.txt", provenance: .created)], + createdTotal: 2, + attached: [attached, item(path: "/session/attached-next.txt", provenance: .attached)], + attachedTotal: 2, referenced: [created, attached, referenced, next, next], referencedTotal: 2, generation: "generation" @@ -29,9 +33,11 @@ struct ChatArtifactGallerySnapshotTests { let snapshot = ChatArtifactGallerySnapshot(page: initial).appending(page) - #expect(snapshot.created.map(\.path) == [created.path]) - #expect(snapshot.attached.map(\.path) == [attached.path]) + #expect(snapshot.created.map(\.path) == [created.path, "/session/created-next.txt"]) + #expect(snapshot.attached.map(\.path) == [attached.path, "/session/attached-next.txt"]) #expect(snapshot.referenced.map(\.path) == [referenced.path, next.path]) + #expect(snapshot.createdTotal == 2) + #expect(snapshot.attachedTotal == 2) } private func item( diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift index 384f180aeeb0..a70f319fada9 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift @@ -28,6 +28,8 @@ struct ChatArtifactGalleryTests { let data = try coding.encode(page) let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) #expect(json["session_id"] as? String == "session-1") + #expect(json["created_total"] as? Int == 1) + #expect(json["attached_total"] as? Int == 0) #expect(json["referenced_total"] as? Int == 7) #expect(json["next_cursor"] as? String == "cursor") let created = try #require(json["created"] as? [[String: Any]]) @@ -94,7 +96,7 @@ struct ChatArtifactGalleryTests { query: nil ) #expect(legacy.referenced.map(\.path) == [file.path]) - #expect(legacy.referencedTotal == 1) + #expect(legacy.referencedTotal == 2) let folders = builder.page( sessionID: "session", @@ -122,6 +124,65 @@ struct ChatArtifactGalleryTests { #expect(oldHostPage.referencedTotal == 0) } + @Test("each provenance section stats only one bounded page at a time") + func allSectionsPageLazily() throws { + let items = (1...3).flatMap { index in + [ + ChatArtifactIndexedReference( + path: "/missing/created-\(index).txt", + provenance: .created, + lastReferencedSeq: index + ), + ChatArtifactIndexedReference( + path: "/missing/attached-\(index).txt", + provenance: .attached, + lastReferencedSeq: index + ), + ChatArtifactIndexedReference( + path: "/missing/referenced-\(index).txt", + provenance: .referenced, + lastReferencedSeq: index + ), + ] + } + let builder = ChatArtifactGalleryBuilder() + + let first = builder.page( + sessionID: "session", + items: items, + generation: "generation", + cursor: nil, + pageSize: 2, + query: nil, + includeDirectories: true + ) + let cursor = try #require(first.nextCursor.flatMap(ChatArtifactGalleryCursor.init(token:))) + let second = builder.page( + sessionID: "session", + items: items, + generation: "generation", + cursor: cursor, + pageSize: 2, + query: nil, + includeDirectories: true + ) + let snapshot = ChatArtifactGallerySnapshot(page: first).appending(second) + + #expect(first.created.count == 2) + #expect(first.attached.count == 2) + #expect(first.referenced.count == 2) + #expect(first.createdTotal == 3) + #expect(first.attachedTotal == 3) + #expect(first.referencedTotal == 3) + #expect(second.created.count == 1) + #expect(second.attached.count == 1) + #expect(second.referenced.count == 1) + #expect(second.nextCursor == nil) + #expect(snapshot.created.count == 3) + #expect(snapshot.attached.count == 3) + #expect(snapshot.referenced.count == 3) + } + @Test("directory child counts stop at the listing cap") func directoryChildCountCap() throws { let root = FileManager.default.temporaryDirectory diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift index 03115d78ea2f..83c97b4a551a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift @@ -183,7 +183,7 @@ extension TerminalArtifactFilesSheet { defaultValue: "Created by agent", bundle: .module ), - count: created.count, + count: usesCompleteSessionSnapshot ? created.count : snapshot.createdTotal, items: created, expanded: $createdExpanded, swipeOrder: swipeOrder @@ -194,7 +194,7 @@ extension TerminalArtifactFilesSheet { defaultValue: "You attached", bundle: .module ), - count: attached.count, + count: usesCompleteSessionSnapshot ? attached.count : snapshot.attachedTotal, items: attached, expanded: $attachedExpanded, swipeOrder: swipeOrder diff --git a/Sources/TerminalController+MobileChatArtifacts.swift b/Sources/TerminalController+MobileChatArtifacts.swift index 35ff109d13a5..156797da601a 100644 --- a/Sources/TerminalController+MobileChatArtifacts.swift +++ b/Sources/TerminalController+MobileChatArtifacts.swift @@ -54,12 +54,7 @@ extension TerminalController { includeDirectories: includeDirectories ) }.value - var payload = ChatArtifactWire.payload(page) ?? [:] - if cursor != nil { - payload.removeValue(forKey: "created") - payload.removeValue(forKey: "attached") - } - return .ok(payload) + return .ok(ChatArtifactWire.payload(page) ?? [:]) } catch { return mobileChatArtifactError(.notFound, path: "") } From 1d9a5c8383da9487ba01c6dde8bbda1135548412 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 17:53:14 -0700 Subject: [PATCH 43/92] perf: cache gallery ordering by index generation --- .../ChatArtifactGalleryBuilder.swift | 11 ++-- .../ChatArtifactGalleryOrdering.swift | 11 +++- .../ChatArtifactGalleryOrderingCache.swift | 59 +++++++++++++++++++ ...hatArtifactGalleryOrderingCacheTests.swift | 44 ++++++++++++++ ...rminalController+MobileChatArtifacts.swift | 7 +++ 5 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryOrderingCache.swift create mode 100644 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryOrderingCacheTests.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift index 3eb498116614..dbd52bfae2a7 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift @@ -10,6 +10,7 @@ public struct ChatArtifactGalleryBuilder: Sendable { /// - Parameters: /// - sessionID: Session represented by the artifact index. /// - items: De-duplicated transcript artifact references. + /// - orderedItems: Optional generation-cached stable ordering of `items`. /// - generation: Stable snapshot generation carried by page cursors. /// - cursor: Per-section positions after which paging continues. /// - pageSize: Maximum entries to stat and include per section. @@ -20,6 +21,7 @@ public struct ChatArtifactGalleryBuilder: Sendable { public func page( sessionID: String, items: [ChatArtifactIndexedReference], + orderedItems: [ChatArtifactIndexedReference]? = nil, generation: String, cursor: ChatArtifactGalleryCursor?, pageSize: Int, @@ -27,6 +29,7 @@ public struct ChatArtifactGalleryBuilder: Sendable { includeDirectories: Bool = false ) -> ChatArtifactGalleryPage { let ordering = ChatArtifactGalleryOrdering() + let stableItems = orderedItems ?? ordering.sorted(items) let normalizedQuery = query?.trimmingCharacters(in: .whitespacesAndNewlines) let isSearch = normalizedQuery?.isEmpty == false let createdCandidates: [ChatArtifactIndexedReference] @@ -35,11 +38,11 @@ public struct ChatArtifactGalleryBuilder: Sendable { if let normalizedQuery, !normalizedQuery.isEmpty { createdCandidates = [] attachedCandidates = [] - referencedCandidates = ordering.search(items, query: normalizedQuery) + referencedCandidates = ordering.matching(stableItems, query: normalizedQuery) } else { - createdCandidates = ordering.sorted(items.filter { $0.provenance == .created }) - attachedCandidates = ordering.sorted(items.filter { $0.provenance == .attached }) - referencedCandidates = ordering.sorted(items.filter { $0.provenance == .referenced }) + createdCandidates = stableItems.filter { $0.provenance == .created } + attachedCandidates = stableItems.filter { $0.provenance == .attached } + referencedCandidates = stableItems.filter { $0.provenance == .referenced } } let count = max(1, pageSize) let starts = pageStarts( diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryOrdering.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryOrdering.swift index 058f73133291..2207ef41a908 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryOrdering.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryOrdering.swift @@ -45,10 +45,17 @@ public struct ChatArtifactGalleryOrdering: Sendable { public func search( _ items: [ChatArtifactIndexedReference], query: String + ) -> [ChatArtifactIndexedReference] { + matching(sorted(items), query: query) + } + + func matching( + _ orderedItems: [ChatArtifactIndexedReference], + query: String ) -> [ChatArtifactIndexedReference] { let needle = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !needle.isEmpty else { return sorted(items) } - return sorted(items).filter { item in + guard !needle.isEmpty else { return orderedItems } + return orderedItems.filter { item in item.path.range(of: needle, options: [.caseInsensitive, .diacriticInsensitive]) != nil || URL(fileURLWithPath: item.path).lastPathComponent.range( of: needle, diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryOrderingCache.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryOrderingCache.swift new file mode 100644 index 000000000000..bd7ef57fac96 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryOrderingCache.swift @@ -0,0 +1,59 @@ +/// Reuses the stable artifact ordering for repeated pages of one index generation. +public actor ChatArtifactGalleryOrderingCache { + private struct Key: Hashable { + let indexID: String + let generation: String + } + + private struct Entry { + let items: [ChatArtifactIndexedReference] + let access: UInt64 + } + + private let maximumEntryCount: Int + private var entries: [Key: Entry] = [:] + private var accessCounter: UInt64 = 0 + + /// Creates a bounded ordering cache. + /// + /// - Parameter maximumEntryCount: Maximum index generations retained. + public init(maximumEntryCount: Int = 8) { + self.maximumEntryCount = max(0, maximumEntryCount) + } + + /// Returns one newest-first ordering, reusing it for the same index generation. + /// + /// A new generation for an index invalidates that index's previous ordering. + /// + /// - Parameters: + /// - items: De-duplicated references from the authoritative index snapshot. + /// - indexID: Stable identity of the transcript index. + /// - generation: File generation represented by `items`. + /// - Returns: References in stable gallery order. + public func ordered( + _ items: [ChatArtifactIndexedReference], + indexID: String, + generation: String + ) -> [ChatArtifactIndexedReference] { + let key = Key(indexID: indexID, generation: generation) + accessCounter &+= 1 + if let cached = entries[key] { + entries[key] = Entry(items: cached.items, access: accessCounter) + return cached.items + } + + entries = entries.filter { $0.key.indexID != indexID } + let ordered = ChatArtifactGalleryOrdering().sorted(items) + guard maximumEntryCount > 0 else { return ordered } + entries[key] = Entry(items: ordered, access: accessCounter) + evictIfNeeded() + return ordered + } + + private func evictIfNeeded() { + while entries.count > maximumEntryCount, + let leastRecent = entries.min(by: { $0.value.access < $1.value.access })?.key { + entries.removeValue(forKey: leastRecent) + } + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryOrderingCacheTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryOrderingCacheTests.swift new file mode 100644 index 000000000000..73273a05a9db --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryOrderingCacheTests.swift @@ -0,0 +1,44 @@ +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact gallery ordering cache") +struct ChatArtifactGalleryOrderingCacheTests { + @Test("same generation reuses its ordering and a new generation invalidates it") + func generationReuseAndInvalidation() async { + let cache = ChatArtifactGalleryOrderingCache() + let original = [ + item("/older", seq: 1), + item("/newer", seq: 2), + ] + let changedWithoutGeneration = [item("/unexpected", seq: 100)] + let nextGeneration = [ + item("/latest", seq: 3), + item("/newer", seq: 2), + ] + + let first = await cache.ordered(original, indexID: "session", generation: "one") + let reused = await cache.ordered( + changedWithoutGeneration, + indexID: "session", + generation: "one" + ) + let invalidated = await cache.ordered( + nextGeneration, + indexID: "session", + generation: "two" + ) + + #expect(first.map(\.path) == ["/newer", "/older"]) + #expect(reused == first) + #expect(invalidated.map(\.path) == ["/latest", "/newer"]) + } + + private func item(_ path: String, seq: Int) -> ChatArtifactIndexedReference { + ChatArtifactIndexedReference( + path: path, + provenance: .referenced, + lastReferencedSeq: seq + ) + } +} diff --git a/Sources/TerminalController+MobileChatArtifacts.swift b/Sources/TerminalController+MobileChatArtifacts.swift index 156797da601a..f2fbc8520247 100644 --- a/Sources/TerminalController+MobileChatArtifacts.swift +++ b/Sources/TerminalController+MobileChatArtifacts.swift @@ -4,6 +4,7 @@ import Foundation private enum TerminalControllerChatArtifactIndexProvider { static let shared = AgentChatArtifactIndex() + static let ordering = ChatArtifactGalleryOrderingCache() } extension TerminalController { @@ -43,10 +44,16 @@ extension TerminalController { let pageSize = min(max(v2Int(params, "page_size") ?? 60, 1), 100) let query = v2RawString(params, "query") let includeDirectories = v2Bool(params, "include_directories") ?? false + let orderedItems = await TerminalControllerChatArtifactIndexProvider.ordering.ordered( + indexedSession.snapshot.artifacts, + indexID: indexedSession.sessionID, + generation: indexedSession.snapshot.generation + ) let page = await Task.detached(priority: .utility) { AgentChatArtifactGalleryBuilder().page( sessionID: indexedSession.sessionID, items: indexedSession.snapshot.artifacts, + orderedItems: orderedItems, generation: indexedSession.snapshot.generation, cursor: cursor, pageSize: pageSize, From e790a3a3b8956b010d185a40c0f8f4bebceb7bb1 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 17:56:35 -0700 Subject: [PATCH 44/92] fix: restart gallery paging across generations --- .../ChatArtifactGalleryBuilder.swift | 7 ++ .../ChatArtifactGalleryEagerPager.swift | 7 ++ ...ChatArtifactGalleryEagerPagingResult.swift | 7 +- .../Artifacts/ChatArtifactGalleryPage.swift | 11 +++- .../ChatArtifactGallerySnapshot.swift | 51 ++++++++++++++ .../ChatArtifactGalleryEagerPagerTests.swift | 17 +++++ .../ChatArtifactGallerySnapshotTests.swift | 32 +++++++++ .../ChatArtifactGalleryTests.swift | 29 ++++++++ .../TerminalArtifactFilesSheet.swift | 66 +++++++++++++++++++ 9 files changed, 224 insertions(+), 3 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift index dbd52bfae2a7..ede7b0bc9754 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift @@ -28,6 +28,13 @@ public struct ChatArtifactGalleryBuilder: Sendable { query: String?, includeDirectories: Bool = false ) -> ChatArtifactGalleryPage { + if let cursor, cursor.generation != generation { + return ChatArtifactGalleryPage( + sessionID: sessionID, + generation: generation, + requiresPagingRestart: true + ) + } let ordering = ChatArtifactGalleryOrdering() let stableItems = orderedItems ?? ordering.sorted(items) let normalizedQuery = query?.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPager.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPager.swift index 4be476a1db1b..50bcfcfdb8b9 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPager.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPager.swift @@ -38,6 +38,13 @@ public struct ChatArtifactGalleryEagerPager: Sendable { try Task.checkCancellation() let page = try await fetchPage(cursor) try Task.checkCancellation() + if page.requiresPagingRestart { + return ChatArtifactGalleryEagerPagingResult( + snapshot: initialSnapshot, + reachedSafetyCap: false, + requiresPagingRestart: true + ) + } let appended = snapshot.appending(page) truncatedRows = truncatedRows || appended.referenced.count > maximumReferencedRows snapshot = appended.limitingReferenced(to: maximumReferencedRows) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPagingResult.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPagingResult.swift index 24723c5d5331..d56af4301de0 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPagingResult.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPagingResult.swift @@ -4,17 +4,22 @@ public struct ChatArtifactGalleryEagerPagingResult: Sendable, Equatable { public let snapshot: ChatArtifactGallerySnapshot /// Whether loading stopped because more than the configured row cap exists. public let reachedSafetyCap: Bool + /// Whether the host rejected an obsolete generation cursor. + public let requiresPagingRestart: Bool /// Creates an eager-paging result. /// /// - Parameters: /// - snapshot: Accumulated rows and remaining cursor. /// - reachedSafetyCap: Whether the row cap prevented complete loading. + /// - requiresPagingRestart: Whether paging must restart from a first page. public init( snapshot: ChatArtifactGallerySnapshot, - reachedSafetyCap: Bool + reachedSafetyCap: Bool, + requiresPagingRestart: Bool = false ) { self.snapshot = snapshot self.reachedSafetyCap = reachedSafetyCap + self.requiresPagingRestart = requiresPagingRestart } } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift index cae487475300..a525f3bd4f76 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift @@ -18,6 +18,8 @@ public struct ChatArtifactGalleryPage: Sendable, Equatable, Codable { public let nextCursor: String? /// Snapshot generation that served this response. public let generation: String + /// Whether the request cursor belongs to an obsolete generation. + public let requiresPagingRestart: Bool /// Creates one gallery response page. public init( @@ -29,7 +31,8 @@ public struct ChatArtifactGalleryPage: Sendable, Equatable, Codable { referenced: [ChatArtifactGalleryItem] = [], referencedTotal: Int = 0, nextCursor: String? = nil, - generation: String = "" + generation: String = "", + requiresPagingRestart: Bool = false ) { self.sessionID = sessionID self.created = created @@ -40,6 +43,7 @@ public struct ChatArtifactGalleryPage: Sendable, Equatable, Codable { self.referencedTotal = referencedTotal self.nextCursor = nextCursor self.generation = generation + self.requiresPagingRestart = requiresPagingRestart } private enum CodingKeys: String, CodingKey { @@ -52,6 +56,7 @@ public struct ChatArtifactGalleryPage: Sendable, Equatable, Codable { case referencedTotal = "referenced_total" case nextCursor = "next_cursor" case generation + case requiresPagingRestart = "requires_paging_restart" } public init(from decoder: any Decoder) throws { @@ -65,6 +70,7 @@ public struct ChatArtifactGalleryPage: Sendable, Equatable, Codable { referencedTotal = (try? container.decode(Int.self, forKey: .referencedTotal)) ?? referenced.count nextCursor = try? container.decode(String.self, forKey: .nextCursor) generation = (try? container.decode(String.self, forKey: .generation)) ?? "" + requiresPagingRestart = (try? container.decode(Bool.self, forKey: .requiresPagingRestart)) ?? false } /// Returns a compatibility view with directory rows removed. @@ -84,7 +90,8 @@ public struct ChatArtifactGalleryPage: Sendable, Equatable, Codable { referenced: filteredReferenced, referencedTotal: max(0, referencedTotal - (referenced.count - filteredReferenced.count)), nextCursor: nextCursor, - generation: generation + generation: generation, + requiresPagingRestart: requiresPagingRestart ) } } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift index edc66aed337f..bd710dece1ae 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift @@ -43,6 +43,7 @@ public struct ChatArtifactGallerySnapshot: Sendable, Equatable { /// - Parameter page: The next sectioned gallery page. /// - Returns: A snapshot containing only path-unique appended rows. public func appending(_ page: ChatArtifactGalleryPage) -> ChatArtifactGallerySnapshot { + guard !page.requiresPagingRestart else { return self } var seenPaths = Set((created + attached + referenced).map(\.path)) let uniqueCreated = page.created.filter { item in seenPaths.insert(item.path).inserted @@ -65,6 +66,56 @@ public struct ChatArtifactGallerySnapshot: Sendable, Equatable { ) } + /// Rebinds paging to a fresh first page without moving already visible rows. + /// + /// Existing rows retain their exact section order. Newly discovered first-page + /// rows are appended, while the fresh generation and cursor become authoritative. + /// This is used for background stale-cursor recovery where leading insertions + /// could otherwise move the reader's scroll position. + /// + /// - Parameter fresh: First page from the current host generation. + /// - Returns: A stable-order snapshot ready to continue fresh paging. + public func restartingPaging( + withFreshFirstPage fresh: ChatArtifactGallerySnapshot + ) -> ChatArtifactGallerySnapshot { + var seenPaths = Set((created + attached + referenced).map(\.path)) + let newCreated = fresh.created.filter { seenPaths.insert($0.path).inserted } + let newAttached = fresh.attached.filter { seenPaths.insert($0.path).inserted } + let newReferenced = fresh.referenced.filter { seenPaths.insert($0.path).inserted } + return ChatArtifactGallerySnapshot( + created: created + newCreated, + createdTotal: max(fresh.createdTotal, created.count + newCreated.count), + attached: attached + newAttached, + attachedTotal: max(fresh.attachedTotal, attached.count + newAttached.count), + referenced: referenced + newReferenced, + referencedTotal: max(fresh.referencedTotal, referenced.count + newReferenced.count), + nextCursor: fresh.nextCursor, + generation: fresh.generation + ) + } + + /// Adopts a fresh generation cursor without changing any visible row array. + /// + /// This supports a deferred live-refresh pill: paging can stop using the + /// rejected cursor immediately while every rendered row remains fixed. + /// + /// - Parameter fresh: First page from the current host generation. + /// - Returns: The unchanged rows with fresh totals and paging identity. + public func rebasingPaging( + ontoFreshFirstPage fresh: ChatArtifactGallerySnapshot + ) -> ChatArtifactGallerySnapshot { + ChatArtifactGallerySnapshot( + created: created, + createdTotal: max(fresh.createdTotal, created.count), + attached: attached, + attachedTotal: max(fresh.attachedTotal, attached.count), + referenced: referenced, + referencedTotal: max(fresh.referencedTotal, referenced.count), + nextCursor: fresh.nextCursor, + generation: fresh.generation + ) + } + /// Reconciles a newer first page without discarding rows already loaded. /// /// Fresh rows lead their provenance sections, while previously loaded rows diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryEagerPagerTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryEagerPagerTests.swift index 209988d4aef7..15cf28acb1d4 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryEagerPagerTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryEagerPagerTests.swift @@ -78,6 +78,23 @@ struct ChatArtifactGalleryEagerPagerTests { #expect(await script.requestedCursors() == ["cursor-1"]) } + @Test("surfaces a stale-generation restart without changing accumulated rows") + func staleGenerationRestart() async throws { + let initial = snapshot(paths: ["/one"], nextCursor: "cursor-1", total: 3) + + let result = try await ChatArtifactGalleryEagerPager().loadRemaining(from: initial) { _ in + ChatArtifactGalleryPage( + sessionID: "session", + generation: "new", + requiresPagingRestart: true + ) + } + + #expect(result.requiresPagingRestart) + #expect(!result.reachedSafetyCap) + #expect(result.snapshot == initial) + } + private func snapshot( paths: [String], nextCursor: String?, diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySnapshotTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySnapshotTests.swift index 834c99623ea3..0e88dcea3e12 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySnapshotTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySnapshotTests.swift @@ -40,6 +40,38 @@ struct ChatArtifactGallerySnapshotTests { #expect(snapshot.attachedTotal == 2) } + @Test("stale response is ignored and fresh rebase preserves exact row order") + func staleRestartRebase() { + let oldA = item(path: "/old-a", provenance: .referenced) + let oldB = item(path: "/old-b", provenance: .referenced) + let current = ChatArtifactGallerySnapshot(page: ChatArtifactGalleryPage( + sessionID: "session", + referenced: [oldA, oldB], + referencedTotal: 4, + nextCursor: "stale", + generation: "old" + )) + let stale = ChatArtifactGalleryPage( + sessionID: "session", + generation: "new", + requiresPagingRestart: true + ) + let fresh = ChatArtifactGallerySnapshot(page: ChatArtifactGalleryPage( + sessionID: "session", + referenced: [item(path: "/new", provenance: .referenced), oldA], + referencedTotal: 5, + nextCursor: "fresh", + generation: "new" + )) + + #expect(current.appending(stale) == current) + let rebased = current.rebasingPaging(ontoFreshFirstPage: fresh) + #expect(rebased.referenced == current.referenced) + #expect(rebased.nextCursor == "fresh") + #expect(rebased.generation == "new") + #expect(rebased.referencedTotal == 5) + } + private func item( path: String, provenance: ChatArtifactProvenance diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift index a70f319fada9..3725b541a5a9 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift @@ -183,6 +183,35 @@ struct ChatArtifactGalleryTests { #expect(snapshot.referenced.count == 3) } + @Test("a cursor from another generation requests a fresh paging restart") + func staleGenerationCursor() throws { + let staleCursor = ChatArtifactGalleryCursor( + generation: "old", + seq: 10, + path: "/old" + ) + + let page = ChatArtifactGalleryBuilder().page( + sessionID: "session", + items: [ChatArtifactIndexedReference( + path: "/new", + provenance: .referenced, + lastReferencedSeq: 20 + )], + generation: "new", + cursor: staleCursor, + pageSize: 10, + query: nil + ) + + #expect(page.requiresPagingRestart) + #expect(page.generation == "new") + #expect(page.created.isEmpty) + #expect(page.attached.isEmpty) + #expect(page.referenced.isEmpty) + #expect(page.nextCursor == nil) + } + @Test("directory child counts stop at the listing cap") func directoryChildCountCap() throws { let root = FileManager.default.temporaryDirectory diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift index 760145d1f457..d0ba17ae1168 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift @@ -277,11 +277,19 @@ struct TerminalArtifactFilesSheet: View { guard query == searchQuery.trimmingCharacters(in: .whitespacesAndNewlines), case .loaded(let current) = searchState, current.nextCursor == cursor else { return } + if page.requiresPagingRestart { + await restartPaging(after: cursor, query: query) + return + } searchState = .loaded(current.appending(page)) } else { guard searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, case .loaded(let current) = sessionState, current.nextCursor == cursor else { return } + if page.requiresPagingRestart { + await restartPaging(after: cursor, query: nil) + return + } sessionState = .loaded(current.appending(page)) } startThumbnailPrefetch(page.referenced) @@ -324,6 +332,13 @@ struct TerminalArtifactFilesSheet: View { query: expectedQuery ) } + if result.requiresPagingRestart { + await restartPaging( + after: initialSnapshot.nextCursor, + query: expectedQuery + ) + return + } let currentQuery = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) let queryIsCurrent = expectedQuery.map { $0 == currentQuery } ?? currentQuery.isEmpty @@ -362,6 +377,57 @@ struct TerminalArtifactFilesSheet: View { } } + private func restartPaging(after staleCursor: String?, query: String?) async { + guard let staleCursor, let source, let sessionID else { return } + let currentQuery = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard query.map({ $0 == currentQuery }) ?? currentQuery.isEmpty else { return } + let state = query == nil ? sessionState : searchState + guard case .loaded(let displayed) = state, + displayed.nextCursor == staleCursor else { return } + do { + let page = try await source.chatArtifactGallery( + sessionID: sessionID, + cursor: nil, + pageSize: Self.pageSize, + query: query + ) + guard !Task.isCancelled else { return } + let currentState = query == nil ? sessionState : searchState + guard case .loaded(let current) = currentState, + current.nextCursor == staleCursor else { return } + let fresh = SessionGallerySnapshot(page: page) + if query == nil { + if sessionViewportIsAtTopOrFits { + liveRefreshState.reset() + withAnimation(.easeInOut(duration: 0.25)) { + sessionState = .loaded(current.reconciling(withFreshFirstPage: fresh)) + } + } else { + _ = liveRefreshState.receive( + fresh: fresh, + displayed: current, + isAtTopOrFits: false + ) + sessionState = .loaded(current.rebasingPaging(ontoFreshFirstPage: fresh)) + } + } else { + searchState = .loaded(current.restartingPaging(withFreshFirstPage: fresh)) + } + eagerPagingState = .idle + eagerPagingRevision += 1 + let currentPaths = Set((current.created + current.attached + current.referenced).map(\.path)) + startThumbnailPrefetch( + (fresh.created + fresh.attached + fresh.referenced).filter { + !currentPaths.contains($0.path) + } + ) + } catch is CancellationError { + return + } catch { + eagerPagingState = .failed + } + } + private func startThumbnailPrefetch(_ items: [ChatArtifactGalleryItem]) { let loader = sessionLoader let task = Task(priority: .low) { From 2593f143f352fc996f25eb6ef6fa9cbace01c15f Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 17:58:57 -0700 Subject: [PATCH 45/92] perf: bound artifact index snapshots with LRU --- .../Artifacts/ChatArtifactLRUCache.swift | 50 +++++++++++++++++++ .../ChatArtifactLRUCacheTests.swift | 21 ++++++++ .../AgentChat/AgentChatArtifactIndex.swift | 6 +-- 3 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactLRUCache.swift create mode 100644 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactLRUCacheTests.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactLRUCache.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactLRUCache.swift new file mode 100644 index 000000000000..e45e6a2a397f --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactLRUCache.swift @@ -0,0 +1,50 @@ +/// A small value-type least-recently-used cache for artifact infrastructure. +public struct ChatArtifactLRUCache: Sendable { + private struct Entry: Sendable { + let value: Value + let access: UInt64 + } + + private let capacity: Int + private var entries: [Key: Entry] = [:] + private var accessCounter: UInt64 = 0 + + /// Number of currently retained entries. + public var count: Int { entries.count } + + /// Creates a bounded cache. + /// + /// - Parameter capacity: Maximum retained entry count. + public init(capacity: Int) { + self.capacity = max(0, capacity) + } + + /// Returns and marks a value as most recently used. + /// + /// - Parameter key: Cache identity to look up. + /// - Returns: The retained value, or `nil`. + public mutating func value(forKey key: Key) -> Value? { + guard let entry = entries[key] else { return nil } + accessCounter &+= 1 + entries[key] = Entry(value: entry.value, access: accessCounter) + return entry.value + } + + /// Inserts or replaces a value and evicts the least recently used entry. + /// + /// - Parameters: + /// - value: Value to retain. + /// - key: Cache identity for the value. + public mutating func insert(_ value: Value, forKey key: Key) { + guard capacity > 0 else { + entries.removeAll(keepingCapacity: false) + return + } + accessCounter &+= 1 + entries[key] = Entry(value: value, access: accessCounter) + while entries.count > capacity, + let leastRecent = entries.min(by: { $0.value.access < $1.value.access })?.key { + entries.removeValue(forKey: leastRecent) + } + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactLRUCacheTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactLRUCacheTests.swift new file mode 100644 index 000000000000..8cacb578d3d5 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactLRUCacheTests.swift @@ -0,0 +1,21 @@ +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact LRU cache") +struct ChatArtifactLRUCacheTests { + @Test("a cache hit protects the entry from capacity eviction") + func hitUpdatesRecency() { + var cache = ChatArtifactLRUCache(capacity: 2) + cache.insert(1, forKey: "one") + cache.insert(2, forKey: "two") + + #expect(cache.value(forKey: "one") == 1) + cache.insert(3, forKey: "three") + + #expect(cache.count == 2) + #expect(cache.value(forKey: "one") == 1) + #expect(cache.value(forKey: "two") == nil) + #expect(cache.value(forKey: "three") == 3) + } +} diff --git a/Sources/Mobile/AgentChat/AgentChatArtifactIndex.swift b/Sources/Mobile/AgentChat/AgentChatArtifactIndex.swift index a5d6e73c717e..23d0d963288f 100644 --- a/Sources/Mobile/AgentChat/AgentChatArtifactIndex.swift +++ b/Sources/Mobile/AgentChat/AgentChatArtifactIndex.swift @@ -36,7 +36,7 @@ actor AgentChatArtifactIndex { let snapshot: Snapshot } - private var cacheBySessionID: [String: CacheEntry] = [:] + private var cacheBySessionID = ChatArtifactLRUCache(capacity: 8) func snapshot( sessionID: String, @@ -45,7 +45,7 @@ actor AgentChatArtifactIndex { workingDirectory: String? ) async throws -> Snapshot { let key = try Self.cacheKey(transcriptPath: transcriptPath, workingDirectory: workingDirectory) - if let cached = cacheBySessionID[sessionID], cached.key == key { + if let cached = cacheBySessionID.value(forKey: sessionID), cached.key == key { return cached.snapshot } let snapshot = try Self.buildSnapshot( @@ -54,7 +54,7 @@ actor AgentChatArtifactIndex { workingDirectory: workingDirectory, generation: key.generation ) - cacheBySessionID[sessionID] = CacheEntry(key: key, snapshot: snapshot) + cacheBySessionID.insert(CacheEntry(key: key, snapshot: snapshot), forKey: sessionID) return snapshot } From e357450d87062e4634c3368e145aa33032222346 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 18:00:14 -0700 Subject: [PATCH 46/92] perf: index chat sessions by terminal surface --- .../Session/ChatSessionSurfaceIndex.swift | 37 ++++++++++++++ .../ChatSessionSurfaceIndexTests.swift | 50 +++++++++++++++++++ .../AgentChat/AgentChatSessionRegistry.swift | 28 +++++++---- 3 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Session/ChatSessionSurfaceIndex.swift create mode 100644 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionSurfaceIndexTests.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Session/ChatSessionSurfaceIndex.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Session/ChatSessionSurfaceIndex.swift new file mode 100644 index 000000000000..afd77f6f18b3 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Session/ChatSessionSurfaceIndex.swift @@ -0,0 +1,37 @@ +/// A mutation-maintained index from terminal surfaces to chat session IDs. +public struct ChatSessionSurfaceIndex: Sendable { + private var sessionIDsBySurfaceID: [String: Set] = [:] + + /// Creates an empty surface index. + public init() {} + + /// Reconciles one session's previous and current surface bindings. + /// + /// - Parameters: + /// - sessionID: Stable identity of the changed session. + /// - previousSurfaceID: Surface before the mutation, or `nil`. + /// - currentSurfaceID: Surface after the mutation, or `nil`. + public mutating func update( + sessionID: SessionID, + previousSurfaceID: String?, + currentSurfaceID: String? + ) { + if let previousSurfaceID, previousSurfaceID != currentSurfaceID { + sessionIDsBySurfaceID[previousSurfaceID]?.remove(sessionID) + if sessionIDsBySurfaceID[previousSurfaceID]?.isEmpty == true { + sessionIDsBySurfaceID.removeValue(forKey: previousSurfaceID) + } + } + if let currentSurfaceID { + sessionIDsBySurfaceID[currentSurfaceID, default: []].insert(sessionID) + } + } + + /// Returns all sessions currently bound to a surface. + /// + /// - Parameter surfaceID: Terminal surface UUID string. + /// - Returns: Session IDs associated with the surface. + public func sessionIDs(surfaceID: String) -> Set { + sessionIDsBySurfaceID[surfaceID] ?? [] + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionSurfaceIndexTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionSurfaceIndexTests.swift new file mode 100644 index 000000000000..4b4d307b8774 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionSurfaceIndexTests.swift @@ -0,0 +1,50 @@ +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat session surface index") +struct ChatSessionSurfaceIndexTests { + private struct Record { + let sessionID: String + var surfaceID: String? + } + + @Test("indexed candidates stay equivalent to a full record scan") + func behaviorParityWithScan() { + var records: [String: Record] = [:] + var index = ChatSessionSurfaceIndex() + + func expectParity(surfaceID: String) { + let scanned = Set(records.values.lazy + .filter { $0.surfaceID == surfaceID } + .map(\.sessionID)) + #expect(index.sessionIDs(surfaceID: surfaceID) == scanned) + } + + records["one"] = Record(sessionID: "one", surfaceID: "surface-a") + index.update(sessionID: "one", previousSurfaceID: nil, currentSurfaceID: "surface-a") + records["two"] = Record(sessionID: "two", surfaceID: "surface-a") + index.update(sessionID: "two", previousSurfaceID: nil, currentSurfaceID: "surface-a") + records["unbound"] = Record(sessionID: "unbound", surfaceID: nil) + index.update(sessionID: "unbound", previousSurfaceID: nil, currentSurfaceID: nil) + expectParity(surfaceID: "surface-a") + + records["one"]?.surfaceID = "surface-b" + index.update( + sessionID: "one", + previousSurfaceID: "surface-a", + currentSurfaceID: "surface-b" + ) + expectParity(surfaceID: "surface-a") + expectParity(surfaceID: "surface-b") + + let removed = records.removeValue(forKey: "two") + index.update( + sessionID: "two", + previousSurfaceID: removed?.surfaceID, + currentSurfaceID: nil + ) + expectParity(surfaceID: "surface-a") + expectParity(surfaceID: "surface-b") + } +} diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index db729eb3f093..573e897e44c5 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -7,6 +7,7 @@ import Foundation @MainActor final class AgentChatSessionRegistry { private var records: [String: AgentChatSessionRecord] = [:] + private var sessionSurfaceIndex = ChatSessionSurfaceIndex() private var liveSessionIDBySurfaceID: [String: String] = [:] private var liveClaudeSessionIDsBySurfaceID: [String: Set] = [:] private let hookStore: AgentChatHookSessionStore @@ -140,7 +141,7 @@ final class AgentChatSessionRegistry { stampVersion(&record) records[targetSessionID] = record syncProcessExitWatch(for: record) - updateLiveSessionIndex(previous: nil, current: record) + updateSessionIndexes(previous: nil, current: record) onRecordChanged?(record, nil) } else { guard let current = records[targetSessionID] else { continue } @@ -259,7 +260,8 @@ final class AgentChatSessionRegistry { if let live = liveSession(surfaceID: surfaceID) { return live } - return records.values + return sessionSurfaceIndex.sessionIDs(surfaceID: surfaceID) + .compactMap { records[$0] } .filter { $0.surfaceID == surfaceID } .max { $0.lastActivityAt < $1.lastActivityAt } } @@ -310,7 +312,7 @@ final class AgentChatSessionRegistry { } #endif syncProcessExitWatch(for: record) - updateLiveSessionIndex(previous: previous, current: record) + updateSessionIndexes(previous: previous, current: record) onRecordChanged?(record, previous) } @@ -388,7 +390,7 @@ final class AgentChatSessionRegistry { stampVersion(&record) records[sessionID] = record syncProcessExitWatch(for: record) - updateLiveSessionIndex(previous: nil, current: record) + updateSessionIndexes(previous: nil, current: record) onRecordChanged?(record, nil) } } @@ -476,7 +478,7 @@ final class AgentChatSessionRegistry { stampVersion(&record) records[sessionID] = record syncProcessExitWatch(for: record) - updateLiveSessionIndex(previous: previous, current: record) + updateSessionIndexes(previous: previous, current: record) onRecordChanged?(record, previous) if shouldConsultStore { backfillBindingsFromStore( @@ -559,7 +561,7 @@ final class AgentChatSessionRegistry { exitWatchers[alias]?.source.cancel() exitWatchers[alias] = nil hookStoreConsultedAt.removeValue(forKey: alias) - updateLiveSessionIndex(previous: record, current: nil) + updateSessionIndexes(previous: record, current: nil) onRecordRemoved?(record) } if let indexed = liveSessionIDBySurfaceID[surfaceID], @@ -650,7 +652,7 @@ final class AgentChatSessionRegistry { stampVersion(&record) records[sessionID] = record syncProcessExitWatch(for: record) - updateLiveSessionIndex(previous: nil, current: record) + updateSessionIndexes(previous: nil, current: record) onRecordChanged?(record, nil) } @@ -692,10 +694,17 @@ final class AgentChatSessionRegistry { } } - private func updateLiveSessionIndex( + private func updateSessionIndexes( previous: AgentChatSessionRecord?, current: AgentChatSessionRecord? ) { + if let sessionID = current?.sessionID ?? previous?.sessionID { + sessionSurfaceIndex.update( + sessionID: sessionID, + previousSurfaceID: previous?.surfaceID, + currentSurfaceID: current?.surfaceID + ) + } updateLiveClaudeSessionIndex(previous: previous, current: current) let previousSurfaceID = Self.liveSurfaceID(previous) let currentSurfaceID = Self.liveSurfaceID(current) @@ -736,7 +745,8 @@ final class AgentChatSessionRegistry { private func rebuildLiveSessionIndex(surfaceID: String?) { guard let surfaceID else { return } - if let newest = records.values + if let newest = sessionSurfaceIndex.sessionIDs(surfaceID: surfaceID) + .compactMap({ records[$0] }) .filter({ $0.surfaceID == surfaceID && $0.state != .ended }) .max(by: { $0.lastActivityAt < $1.lastActivityAt }) { liveSessionIDBySurfaceID[surfaceID] = newest.sessionID From db87311bf47338db61f640689c9c343daee3172c Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 18:01:28 -0700 Subject: [PATCH 47/92] fix: measure artifact tap targets in terminal cells --- .../TerminalArtifactTapHitTester.swift | 55 +++++++++++++++++-- .../TerminalArtifactTapHitTesterTests.swift | 14 +++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactTapHitTester.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactTapHitTester.swift index 9c8bbe837858..50861fed1460 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactTapHitTester.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactTapHitTester.swift @@ -45,7 +45,7 @@ public struct TerminalArtifactTapHitTester: Sendable { var currentRawEndColumn = token.rawEndColumn while currentRawEndColumn >= columns, - lines[currentRow].count >= columns, + Self.cellWidth(of: lines[currentRow]) >= columns, currentRow + 1 < lines.count, let continuation = leadingContinuation(in: lines[currentRow + 1]) { currentRow += 1 @@ -79,14 +79,15 @@ public struct TerminalArtifactTapHitTester: Sendable { guard let path = TerminalArtifactPathDetector().tokens(in: raw).first?.path else { continue } - let leadingTrim = raw.count - raw.drop(while: Self.leadingTrimCharacters.contains).count - let startColumn = line.distance(from: line.startIndex, to: tokenStart) + leadingTrim + let leadingTrim = String(raw.prefix(while: Self.leadingTrimCharacters.contains)) + let startColumn = Self.cellWidth(of: String(line[.. Int { + text.reduce(0) { $0 + cellWidth(of: $1) } + } + + private static func cellWidth(of character: Character) -> Int { + let scalars = character.unicodeScalars + guard scalars.contains(where: { !isZeroWidth($0) }) else { return 0 } + if scalars.contains(where: { isWide($0) || $0.properties.isEmojiPresentation }) { + return 2 + } + return 1 + } + + private static func isZeroWidth(_ scalar: UnicodeScalar) -> Bool { + switch scalar.properties.generalCategory { + case .nonspacingMark, .spacingMark, .enclosingMark, .control, .format: + return true + default: + return false + } + } + + private static func isWide(_ scalar: UnicodeScalar) -> Bool { + switch scalar.value { + case 0x1100...0x115F, + 0x2329...0x232A, + 0x2E80...0xA4CF, + 0xAC00...0xD7A3, + 0xF900...0xFAFF, + 0xFE10...0xFE19, + 0xFE30...0xFE6F, + 0xFF00...0xFF60, + 0xFFE0...0xFFE6, + 0x16FE0...0x18DFF, + 0x1AFF0...0x1B2FF, + 0x20000...0x3FFFD: + return true + default: + return false + } } private static func isPathContinuation(_ character: Character) -> Bool { diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactTapHitTesterTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactTapHitTesterTests.swift index 3c0650b0688d..30a21dc314a5 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactTapHitTesterTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactTapHitTesterTests.swift @@ -85,6 +85,20 @@ struct TerminalArtifactTapHitTesterTests { #expect(resolved == path) } + @Test("uses terminal cell width for CJK text before a path") + func cjkPrefixUsesTerminalColumns() { + let path = "/tmp/note.txt" + + let resolved = TerminalArtifactTapHitTester().path( + in: "漢字 open \(path)", + col: 22, + row: 0, + columns: 80 + ) + + #expect(resolved == path) + } + @Test("does not stitch a full-width token to a new prompt") func doesNotStitchPrompt() { let path = "/tmp/exact.md" From c16c4820e1617297228e72cc32ce54bea02e05be Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 18:17:01 -0700 Subject: [PATCH 48/92] fix(ios): isolate unscoped artifact content streams --- .../Artifacts/ChatArtifactLoader.swift | 4 ++- .../ChatArtifactContentCacheTests.swift | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift index 2b1d53c37c40..35f3b0aa29d2 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift @@ -79,6 +79,7 @@ public struct ChatArtifactLoader: Sendable { public let supportsArtifacts: Bool /// Whether directory stat results may route into a navigable folder browser. public let supportsDirectoryBrowsing: Bool + /// Authorization and cache namespace for artifact operations. public let scope: ChatArtifactLoaderScope private let statHandler: @Sendable (_ path: String) async throws -> ChatArtifactStat @@ -272,7 +273,8 @@ public struct ChatArtifactLoader: Sendable { size: Int64? = nil, onChunk: @escaping @Sendable (ChatArtifactChunk) async throws -> Void ) async throws { - guard let key = ChatArtifactContentCache.key( + guard scope != .unsupported, + let key = ChatArtifactContentCache.key( scopeKey: scope.cacheNamespace, path: path, modifiedAt: modifiedAt, diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactContentCacheTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactContentCacheTests.swift index 6fccfe8a1d47..e50daf415b6f 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactContentCacheTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactContentCacheTests.swift @@ -95,6 +95,32 @@ struct ChatArtifactContentCacheTests { #expect(await source.fetchCount(for: "artifact") == 1) } + @Test("unsupported loader scopes never share cached bytes") + func unsupportedScopeBypassesCache() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let bytes = Data("fixture".utf8) + let source = CountingContentSource(values: ["fixture": bytes]) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + contentCache: ChatArtifactContentCache(directory: directory), + stream: { _, receive in + try await source.fetch(key: "fixture", receive: receive) + } + ) + + for _ in 0..<2 { + try await loader.stream( + path: "/tmp/fixture.txt", + modifiedAt: Date(timeIntervalSince1970: 100), + size: Int64(bytes.count), + onChunk: { _ in } + ) + } + + #expect(await source.fetchCount(for: "fixture") == 2) + } + private func stream( key: String, at seconds: TimeInterval, From 65a8bcf1deb06a62c931728e53275ef3019241d6 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 19:03:09 -0700 Subject: [PATCH 49/92] fix: fill gallery pages sequentially across sections Per-section page slices meant a bottom-of-list load inserted rows into groups the user had already scrolled past; pages now fill created, then attached, then referenced, so loads only extend the list bottom. Co-Authored-By: Claude Fable 5 --- .../ChatArtifactGalleryBuilder.swift | 13 ++++-- .../ChatArtifactGalleryTests.swift | 46 +++++++++++++++---- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift index 64230af37dc8..045bfe15de82 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift @@ -59,9 +59,16 @@ public struct ChatArtifactGalleryBuilder: Sendable { referenced: referencedCandidates, ordering: ordering ) - let pageCreated = Array(createdCandidates.dropFirst(starts.created).prefix(count)) - let pageAttached = Array(attachedCandidates.dropFirst(starts.attached).prefix(count)) - let pageReferenced = Array(referencedCandidates.dropFirst(starts.referenced).prefix(count)) + // Sequential fill: a page extends the grouped list strictly at its + // bottom (created, then attached, then referenced), so a + // scroll-triggered load can never insert rows into a group the user + // has already scrolled past. + var remaining = count + let pageCreated = Array(createdCandidates.dropFirst(starts.created).prefix(remaining)) + remaining -= pageCreated.count + let pageAttached = Array(attachedCandidates.dropFirst(starts.attached).prefix(remaining)) + remaining -= pageAttached.count + let pageReferenced = Array(referencedCandidates.dropFirst(starts.referenced).prefix(remaining)) let nextCreatedOffset = starts.created + pageCreated.count let nextAttachedOffset = starts.attached + pageAttached.count let nextReferencedOffset = starts.referenced + pageReferenced.count diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift index 3725b541a5a9..225e7b32971f 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift @@ -124,7 +124,7 @@ struct ChatArtifactGalleryTests { #expect(oldHostPage.referencedTotal == 0) } - @Test("each provenance section stats only one bounded page at a time") + @Test("pages fill sections sequentially so loads only extend the list bottom") func allSectionsPageLazily() throws { let items = (1...3).flatMap { index in [ @@ -168,19 +168,49 @@ struct ChatArtifactGalleryTests { ) let snapshot = ChatArtifactGallerySnapshot(page: first).appending(second) + // Sequential fill: the first page is created rows only, and every + // later page extends the grouped list strictly at its bottom, so a + // scroll-triggered load never inserts rows into an earlier group. #expect(first.created.count == 2) - #expect(first.attached.count == 2) - #expect(first.referenced.count == 2) + #expect(first.attached.isEmpty) + #expect(first.referenced.isEmpty) #expect(first.createdTotal == 3) #expect(first.attachedTotal == 3) #expect(first.referencedTotal == 3) #expect(second.created.count == 1) #expect(second.attached.count == 1) - #expect(second.referenced.count == 1) - #expect(second.nextCursor == nil) - #expect(snapshot.created.count == 3) - #expect(snapshot.attached.count == 3) - #expect(snapshot.referenced.count == 3) + #expect(second.referenced.isEmpty) + + var accumulated = snapshot + var nextToken = second.nextCursor + var guardCounter = 0 + while let token = nextToken, guardCounter < 8 { + let cursor = try #require(ChatArtifactGalleryCursor(token: token)) + let page = builder.page( + sessionID: "session", + items: items, + generation: "generation", + cursor: cursor, + pageSize: 2, + query: nil, + includeDirectories: true + ) + if !accumulated.attached.isEmpty { + #expect(page.created.isEmpty) + } + if !accumulated.referenced.isEmpty { + #expect(page.created.isEmpty) + #expect(page.attached.isEmpty) + } + accumulated = accumulated.appending(page) + nextToken = page.nextCursor + guardCounter += 1 + } + + #expect(nextToken == nil) + #expect(accumulated.created.count == 3) + #expect(accumulated.attached.count == 3) + #expect(accumulated.referenced.count == 3) } @Test("a cursor from another generation requests a fresh paging restart") From 24ed390089e954af7d612bedeec6cea579b43e36 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 19:42:51 -0700 Subject: [PATCH 50/92] fix(ios): push artifact folders inline --- .../Artifacts/ChatArtifactFolderView.swift | 4 +- .../ChatArtifactViewerDestination.swift | 38 +++++++++++++++++++ .../Artifacts/ChatArtifactViewerPager.swift | 28 +++++++------- .../ChatArtifactViewerRouteView.swift | 1 - .../Artifacts/ChatArtifactViewerSheet.swift | 14 ++++--- .../CmuxAgentChatUI/Screen/ChatScreen.swift | 20 ++++++++++ .../Transcript/ChatBlockDetailSheetView.swift | 38 +++++++++++++++---- .../Transcript/ChatMessageRowView.swift | 3 +- .../Transcript/ChatRowActions.swift | 6 +++ .../Rows/ChatAttachmentBubbleView.swift | 17 +++++++-- .../TerminalArtifactFilesSheet.swift | 35 +++++++++++------ .../WorkspaceDetailView.swift | 32 ++++++++++++---- 12 files changed, 179 insertions(+), 57 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerDestination.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift index 078b2d50b194..62416dd1a7a0 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift @@ -10,7 +10,6 @@ import AppKit struct ChatArtifactFolderView: View { let path: String let scope: ChatArtifactViewerScope - let onImageMinimumZoomChanged: (Bool) -> Void let onDone: () -> Void @Environment(\.chatArtifactLoader) private var loader @@ -43,10 +42,9 @@ struct ChatArtifactFolderView: View { Section { ForEach(listing.entries) { entry in NavigationLink { - ChatArtifactViewerRouteView( + ChatArtifactViewerDestination( path: childPath(named: entry.name), scope: scope, - onImageMinimumZoomChanged: onImageMinimumZoomChanged, onDone: onDone ) } label: { diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerDestination.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerDestination.swift new file mode 100644 index 000000000000..e6f8ace14e9b --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerDestination.swift @@ -0,0 +1,38 @@ +import CmuxAgentChat +import SwiftUI + +/// The shared inline navigation destination for a Mac-hosted artifact path. +public struct ChatArtifactViewerDestination: View { + private let path: String + private let scope: ChatArtifactViewerScope + private let swipeOrder: ChatArtifactGallerySwipeOrder + private let onDone: () -> Void + + /// Creates an inline artifact viewer for an existing navigation stack. + /// + /// - Parameters: + /// - path: Initially selected artifact path. + /// - scope: Authorization and navigation context for the artifact. + /// - swipeOrder: Visible gallery-file order available for horizontal paging. + /// - onDone: Dismisses or pops the navigation container that owns the destination. + public init( + path: String, + scope: ChatArtifactViewerScope = .chat, + swipeOrder: ChatArtifactGallerySwipeOrder = ChatArtifactGallerySwipeOrder(items: []), + onDone: @escaping () -> Void + ) { + self.path = path + self.scope = scope + self.swipeOrder = swipeOrder + self.onDone = onDone + } + + public var body: some View { + ChatArtifactViewerPager( + initialPath: path, + scope: scope, + swipeOrder: swipeOrder, + onDone: onDone + ) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift index 8b83ae689f3c..54b03fb0f6e4 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift @@ -46,21 +46,19 @@ struct ChatArtifactViewerPager: View { } private func viewer(path: String) -> some View { - NavigationStack { - ChatArtifactViewerRouteView( - path: path, - scope: scope, - onImageMinimumZoomChanged: { isAtMinimum in - if isAtMinimum { - if zoomedPath == path { - zoomedPath = nil - } - } else { - zoomedPath = path + ChatArtifactViewerRouteView( + path: path, + scope: scope, + onImageMinimumZoomChanged: { isAtMinimum in + if isAtMinimum { + if zoomedPath == path { + zoomedPath = nil } - }, - onDone: onDone - ) - } + } else { + zoomedPath = path + } + }, + onDone: onDone + ) } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index e19ccea5e217..11087ae14935 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -358,7 +358,6 @@ struct ChatArtifactViewerRouteView: View { ChatArtifactFolderView( path: path, scope: scope, - onImageMinimumZoomChanged: onImageMinimumZoomChanged, onDone: onDone ) case .image(let data): diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerSheet.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerSheet.swift index 0bf623879d56..26e45f7960bc 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerSheet.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerSheet.swift @@ -25,12 +25,14 @@ public struct ChatArtifactViewerSheet: View { } public var body: some View { - ChatArtifactViewerPager( - initialPath: path, - scope: scope, - swipeOrder: swipeOrder - ) { - dismiss() + NavigationStack { + ChatArtifactViewerDestination( + path: path, + scope: scope, + swipeOrder: swipeOrder + ) { + dismiss() + } } } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift index 3d4beb62ddd2..b52c44cb66b2 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift @@ -17,6 +17,7 @@ public struct ChatScreen: View { @State private var renderer = ChatMarkdownRenderer() @State private var contentCache = ChatContentCache() @State private var selectedBlockSelection: ChatBlockSelection? + @State private var selectedArtifact: ChatArtifactPathSelection? private let detailBuilder = ChatBlockDetailBuilder() @Binding private var draft: String @@ -91,6 +92,13 @@ public struct ChatScreen: View { ) } } + .navigationDestination(isPresented: artifactIsPresented) { + if let selectedArtifact { + ChatArtifactViewerDestination(path: selectedArtifact.path) { + self.selectedArtifact = nil + } + } + } .task { guard runsStoreTask else { return } await store.run() @@ -101,6 +109,15 @@ public struct ChatScreen: View { #endif } + private var artifactIsPresented: Binding { + Binding( + get: { selectedArtifact != nil }, + set: { isPresented in + if !isPresented { selectedArtifact = nil } + } + ) + } + @ViewBuilder private var errorBanner: some View { if let error = store.lastErrorDescription { @@ -296,6 +313,9 @@ public struct ChatScreen: View { store.discard(pendingID: id) }, openTerminal: onOpenTerminal, + openArtifact: { path in + selectedArtifact = ChatArtifactPathSelection(path: path) + }, showMessageDetail: { message in selectedBlockSelection = .message(id: message.id) }, diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatBlockDetailSheetView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatBlockDetailSheetView.swift index cbad4ef5abe4..ef2b6adde562 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatBlockDetailSheetView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatBlockDetailSheetView.swift @@ -13,6 +13,7 @@ struct ChatBlockDetailSheetView: View { @Environment(\.dismiss) private var dismiss @Environment(\.chatArtifactLoader) private var artifactLoader + @State private var selectedArtifact: ChatArtifactPathSelection? init(detail: ChatBlockDetail, onOpenTerminal: (() -> Void)? = nil) { self.detail = detail @@ -33,7 +34,12 @@ struct ChatBlockDetailSheetView: View { ChatBlockDetailSectionView(section: section) } if artifactLoader.supportsArtifacts, !detail.artifactPaths.isEmpty { - ChatBlockDetailArtifactActions(paths: detail.artifactPaths) + ChatBlockDetailArtifactActions( + paths: detail.artifactPaths, + onOpenArtifact: { path in + selectedArtifact = ChatArtifactPathSelection(path: path) + } + ) } } .padding(16) @@ -62,10 +68,26 @@ struct ChatBlockDetailSheetView: View { } #endif } + .navigationDestination(isPresented: artifactIsPresented) { + if let selectedArtifact { + ChatArtifactViewerDestination(path: selectedArtifact.path) { + dismiss() + } + } + } } .accessibilityIdentifier("ChatBlockDetailSheet") } + private var artifactIsPresented: Binding { + Binding( + get: { selectedArtifact != nil }, + set: { isPresented in + if !isPresented { selectedArtifact = nil } + } + ) + } + @ViewBuilder private var openTerminalButton: some View { if let onOpenTerminal { @@ -106,6 +128,7 @@ struct ChatBlockDetailSheetView: View { private struct ChatBlockDetailArtifactActions: View { let paths: [String] + let onOpenArtifact: (String) -> Void var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -113,7 +136,10 @@ private struct ChatBlockDetailArtifactActions: View { .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) ForEach(deduplicatedPaths, id: \.self) { path in - ChatBlockDetailArtifactActionRow(path: path) + ChatBlockDetailArtifactActionRow( + path: path, + onOpenArtifact: onOpenArtifact + ) } } } @@ -130,8 +156,7 @@ private struct ChatBlockDetailArtifactActions: View { private struct ChatBlockDetailArtifactActionRow: View { let path: String - - @State private var selectedArtifact: ChatArtifactPathSelection? + let onOpenArtifact: (String) -> Void var body: some View { HStack(spacing: 8) { @@ -148,7 +173,7 @@ private struct ChatBlockDetailArtifactActionRow: View { } Spacer(minLength: 8) Button { - selectedArtifact = ChatArtifactPathSelection(path: path) + onOpenArtifact(path) } label: { Label( String(localized: "chat.artifact.open_item", defaultValue: "Open item", bundle: .module), @@ -159,8 +184,5 @@ private struct ChatBlockDetailArtifactActionRow: View { } .padding(10) .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 8)) - .sheet(item: $selectedArtifact) { selection in - ChatArtifactViewerSheet(path: selection.path) - } } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatMessageRowView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatMessageRowView.swift index 52b9bed98716..cb19b4c88be7 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatMessageRowView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatMessageRowView.swift @@ -69,7 +69,8 @@ public struct ChatMessageRowView: View { attachment: attachment, groupPosition: snapshot.groupPosition, showsTimestamp: snapshot.showsTimestamp, - timestamp: snapshot.message.timestamp + timestamp: snapshot.message.timestamp, + onOpenArtifact: actions.openArtifact ) case .unsupported(let payload): ChatUnsupportedRowView(payload: payload) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatRowActions.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatRowActions.swift index 883100f25a2f..07b5c8f602c0 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatRowActions.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatRowActions.swift @@ -19,6 +19,9 @@ public struct ChatRowActions { /// Opens the session's raw terminal (the escape hatch). public var openTerminal: () -> Void + /// Pushes a Mac-hosted artifact in the conversation's navigation stack. + public var openArtifact: (String) -> Void + /// Shows a non-resizing detail sheet for a compact message row. public var showMessageDetail: (ChatMessage) -> Void @@ -35,6 +38,7 @@ public struct ChatRowActions { /// - retryPending: Retries a failed pending send by pending id. /// - discardPending: Discards a failed pending send by pending id. /// - openTerminal: Opens the session's raw terminal. + /// - openArtifact: Pushes a Mac-hosted artifact path inline. /// - showMessageDetail: Presents full details for compact message rows. /// - showTerminalCommandDetail: Presents full details for terminal rows. /// - showCodeBlockDetail: Presents full details for prose code blocks. @@ -43,6 +47,7 @@ public struct ChatRowActions { retryPending: @escaping (String) -> Void = { _ in }, discardPending: @escaping (String) -> Void = { _ in }, openTerminal: @escaping () -> Void = {}, + openArtifact: @escaping (String) -> Void = { _ in }, showMessageDetail: @escaping (ChatMessage) -> Void = { _ in }, showTerminalCommandDetail: @escaping (TerminalCommandBlock) -> Void = { _ in }, showCodeBlockDetail: @escaping (String, Int) -> Void = { _, _ in } @@ -51,6 +56,7 @@ public struct ChatRowActions { self.retryPending = retryPending self.discardPending = discardPending self.openTerminal = openTerminal + self.openArtifact = openArtifact self.showMessageDetail = showMessageDetail self.showTerminalCommandDetail = showTerminalCommandDetail self.showCodeBlockDetail = showCodeBlockDetail diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift index 5cf801a87267..7d03df8cba80 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift @@ -14,6 +14,7 @@ public struct ChatAttachmentBubbleView: View { private let groupPosition: ChatGroupPosition private let showsTimestamp: Bool private let timestamp: Date + private let onOpenArtifact: ((String) -> Void)? @Environment(\.chatTheme) private var theme @Environment(\.chatBubbleMaxWidth) private var bubbleMaxWidth @@ -22,7 +23,7 @@ public struct ChatAttachmentBubbleView: View { @State private var thumbnailData: Data? @State private var thumbnailFailed = false @State private var thumbnailPath: String? - @State private var selectedArtifact: ChatArtifactPathSelection? + @State private var fallbackSelection: ChatArtifactPathSelection? /// Creates an attachment bubble. /// @@ -32,16 +33,20 @@ public struct ChatAttachmentBubbleView: View { /// - showsTimestamp: Whether the group timestamp renders under this /// bubble. /// - timestamp: When the attachment was sent. + /// - onOpenArtifact: Pushes the host path inline when the caller owns a + /// navigation stack. When omitted, the standalone bubble uses a sheet. public init( attachment: ChatAttachment, groupPosition: ChatGroupPosition, showsTimestamp: Bool, - timestamp: Date + timestamp: Date, + onOpenArtifact: ((String) -> Void)? = nil ) { self.attachment = attachment self.groupPosition = groupPosition self.showsTimestamp = showsTimestamp self.timestamp = timestamp + self.onOpenArtifact = onOpenArtifact } public var body: some View { @@ -60,7 +65,7 @@ public struct ChatAttachmentBubbleView: View { .accessibilityElement(children: .combine) } .frame(maxWidth: .infinity, alignment: .trailing) - .sheet(item: $selectedArtifact) { selection in + .sheet(item: $fallbackSelection) { selection in ChatArtifactViewerSheet(path: selection.path) } } @@ -69,7 +74,11 @@ public struct ChatAttachmentBubbleView: View { private var artifactAwareBubble: some View { if artifactLoader.supportsArtifacts, let hostPath = attachment.hostPath, !hostPath.isEmpty { Button { - selectedArtifact = ChatArtifactPathSelection(path: hostPath) + if let onOpenArtifact { + onOpenArtifact(hostPath) + } else { + fallbackSelection = ChatArtifactPathSelection(path: hostPath) + } } label: { if thumbnailFailed { bubble diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift index d0ba17ae1168..30f98609d314 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift @@ -94,6 +94,21 @@ struct TerminalArtifactFilesSheet: View { viewModePicker } } + .navigationDestination(isPresented: artifactIsPresented) { + if let selection { + ChatArtifactViewerDestination( + path: selection.path, + scope: selection.scope == .session ? .chat : .terminal, + swipeOrder: selection.swipeOrder + ) { + dismiss() + } + .environment( + \.chatArtifactLoader, + selection.scope == .session ? sessionLoader : loader + ) + } + } } .frame(idealWidth: 380, idealHeight: 520) .task(id: "\(workspaceID)#\(surfaceID)") { @@ -102,23 +117,21 @@ struct TerminalArtifactFilesSheet: View { .task(id: liveRefreshTaskID) { await refreshSessionForLiveSignal() } - .sheet(item: $selection) { selection in - ChatArtifactViewerSheet( - path: selection.path, - scope: selection.scope == .session ? .chat : .terminal, - swipeOrder: selection.swipeOrder - ) - .environment( - \.chatArtifactLoader, - selection.scope == .session ? sessionLoader : loader - ) - } .onDisappear { thumbnailPrefetchTasks.forEach { $0.cancel() } thumbnailPrefetchTasks.removeAll() } } + private var artifactIsPresented: Binding { + Binding( + get: { selection != nil }, + set: { isPresented in + if !isPresented { selection = nil } + } + ) + } + private func loadInitial() async { guard let source else { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 3a2c0503a2f5..50a8eff21f98 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -261,15 +261,22 @@ struct WorkspaceDetailView: View { TerminalPalette.background .ignoresSafeArea(.container, edges: [.horizontal, .top, .bottom]) } - .sheet(item: $selectedTerminalArtifact) { selection in - ChatArtifactViewerSheet(path: selection.path, scope: .terminal) - .environment( - \.chatArtifactLoader, - terminalArtifactLoader( - workspaceID: selection.workspaceID, - surfaceID: selection.surfaceID + .navigationDestination(isPresented: terminalArtifactIsPresented) { + if let selectedTerminalArtifact { + ChatArtifactViewerDestination( + path: selectedTerminalArtifact.path, + scope: .terminal + ) { + self.selectedTerminalArtifact = nil + } + .environment( + \.chatArtifactLoader, + terminalArtifactLoader( + workspaceID: selectedTerminalArtifact.workspaceID, + surfaceID: selectedTerminalArtifact.surfaceID + ) ) - ) + } } #else .background(TerminalPalette.background) @@ -286,6 +293,15 @@ struct WorkspaceDetailView: View { } #if os(iOS) + private var terminalArtifactIsPresented: Binding { + Binding( + get: { selectedTerminalArtifact != nil }, + set: { isPresented in + if !isPresented { selectedTerminalArtifact = nil } + } + ) + } + func terminalArtifactLoader(workspaceID: String, surfaceID: String) -> ChatArtifactLoader { guard let source = store.makeChatEventSource() else { return .unsupported(cache: terminalArtifactThumbnailCache) From 024cbfd907196697f1f5cdfcc32841671dcc1f82 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 19:43:03 -0700 Subject: [PATCH 51/92] fix(ios): animate text viewer jump to top --- .../CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift index 878197da6699..10cdb7dd50ea 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift @@ -105,11 +105,11 @@ struct ChatArtifactTextView: UIViewRepresentable { containerView.updateLineNumbers(index: lineIndex, isVisible: showsLineNumbers) if isNewDocument { - Self.scrollToTop(textView) + Self.scrollToTop(textView, animated: false) } else { if context.coordinator.handledTopRequestID != topRequestID { context.coordinator.handledTopRequestID = topRequestID - Self.scrollToTop(textView) + Self.scrollToTop(textView, animated: true) } if context.coordinator.handledBottomRequestID != bottomRequestID { context.coordinator.handledBottomRequestID = bottomRequestID @@ -127,13 +127,13 @@ struct ChatArtifactTextView: UIViewRepresentable { } } - private static func scrollToTop(_ textView: UITextView) { + private static func scrollToTop(_ textView: UITextView, animated: Bool) { textView.setContentOffset( CGPoint( x: -textView.adjustedContentInset.left, y: -textView.adjustedContentInset.top ), - animated: false + animated: animated ) } } From 0df1e97154f2464f95ba7ec8b3f7437efebcc904 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 19:44:59 -0700 Subject: [PATCH 52/92] fix(ios): move artifact viewer Done action trailing --- .../ChatArtifactViewerRouteView.swift | 342 +++++++++--------- .../Resources/Localizable.xcstrings | 17 + 2 files changed, 197 insertions(+), 162 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index 11087ae14935..dada838060c2 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -70,141 +70,16 @@ struct ChatArtifactViewerRouteView: View { .navigationBarTitleDisplayMode(.inline) #endif .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(String(localized: "chat.artifact.done", defaultValue: "Done", bundle: .module)) { - onDone() - } - } #if os(iOS) - if model.hasFileActions { - ToolbarItem(placement: .primaryAction) { - fileActionsMenu + ToolbarItemGroup(placement: .topBarTrailing) { + if hasViewerActions { + viewerActionsMenu } + doneButton } - if shouldShowTextJumpControls { - ToolbarItemGroup(placement: .primaryAction) { - Button { - withAnimation(.snappy) { - if isSearchPresented { - dismissSearch() - } else { - dismissGoToLine() - isSearchPresented = true - } - } - } label: { - Label( - String( - localized: "chat.artifact.search.title", - defaultValue: "Search", - bundle: .module - ), - systemImage: "magnifyingglass" - ) - } - Menu { - Button { - withAnimation(.snappy) { - if isGoToLinePresented { - dismissGoToLine() - } else { - dismissSearch() - isGoToLinePresented = true - } - } - } label: { - Label( - String( - localized: "chat.artifact.line.goto", - defaultValue: "Go to line", - bundle: .module - ), - systemImage: "text.line.first.and.arrowtriangle.forward" - ) - } - Button { - showsLineNumbers.toggle() - } label: { - Label( - String( - localized: "chat.artifact.line.numbers", - defaultValue: "Line numbers", - bundle: .module - ), - systemImage: showsLineNumbers ? "checkmark" : "number" - ) - } - Button { - wrapsLines.toggle() - textPreferences.setWrapsLines( - wrapsLines, - for: textLayoutKind - ) - } label: { - Label( - String( - localized: "chat.artifact.wrap", - defaultValue: "Word wrap", - bundle: .module - ), - systemImage: wrapsLines ? "checkmark" : "text.justify.left" - ) - } - } label: { - Label( - String( - localized: "chat.artifact.text_options", - defaultValue: "Text options", - bundle: .module - ), - systemImage: "textformat" - ) - } - Button { - topRequestID += 1 - } label: { - Label( - String( - localized: "chat.artifact.jump.top", - defaultValue: "Top", - bundle: .module - ), - systemImage: "arrow.up.to.line" - ) - } - Button { - bottomRequestID += 1 - } label: { - Label(jumpToBottomTitle, systemImage: "arrow.down.to.line") - } - } - } - if model.state == .markdown, - model.markdownPresentation.isRenderedAvailable { - ToolbarItem(placement: .primaryAction) { - Picker( - String( - localized: "chat.artifact.markdown.view", - defaultValue: "Markdown view", - bundle: .module - ), - selection: markdownModeBinding - ) { - Text(String( - localized: "chat.artifact.markdown.raw", - defaultValue: "Raw", - bundle: .module - )) - .tag(ChatArtifactMarkdownMode.raw) - Text(String( - localized: "chat.artifact.markdown.rendered", - defaultValue: "Rendered", - bundle: .module - )) - .tag(ChatArtifactMarkdownMode.rendered) - } - .pickerStyle(.segmented) - } + #else + ToolbarItem(placement: .cancellationAction) { + doneButton } #endif } @@ -242,51 +117,194 @@ struct ChatArtifactViewerRouteView: View { } } + private var doneButton: some View { + Button(String(localized: "chat.artifact.done", defaultValue: "Done", bundle: .module)) { + onDone() + } + } + #if os(iOS) - private var fileActionsMenu: some View { + private var hasViewerActions: Bool { + model.hasFileActions + || shouldShowTextJumpControls + || (model.state == .markdown && model.markdownPresentation.isRenderedAvailable) + } + + private var viewerActionsMenu: some View { Menu { - Button { - prepareFileAction(.share) - } label: { - Label( - String(localized: "chat.artifact.share", defaultValue: "Share", bundle: .module), - systemImage: "square.and.arrow.up" - ) + if model.hasFileActions { + Section { + fileActionButtons + } } - Button { - prepareFileAction(.save) - } label: { - Label( - String(localized: "chat.artifact.save_to_files", defaultValue: "Save to Files", bundle: .module), - systemImage: "folder.badge.plus" - ) + if shouldShowTextJumpControls { + Section { + textViewerActionButtons + } } - if model.isTextFile { - Button { - UIPasteboard.general.string = model.renderedText - } label: { - Label( - String(localized: "chat.artifact.copy_contents", defaultValue: "Copy contents", bundle: .module), - systemImage: "doc.on.doc" - ) + if model.state == .markdown, + model.markdownPresentation.isRenderedAvailable { + Section { + Picker( + String( + localized: "chat.artifact.markdown.view", + defaultValue: "Markdown view", + bundle: .module + ), + selection: markdownModeBinding + ) { + Text(String( + localized: "chat.artifact.markdown.raw", + defaultValue: "Raw", + bundle: .module + )) + .tag(ChatArtifactMarkdownMode.raw) + Text(String( + localized: "chat.artifact.markdown.rendered", + defaultValue: "Rendered", + bundle: .module + )) + .tag(ChatArtifactMarkdownMode.rendered) + } } - .disabled(!model.canCopyContents) } + } label: { + Label( + String( + localized: "chat.artifact.viewer.actions", + defaultValue: "Viewer actions", + bundle: .module + ), + systemImage: "ellipsis.circle" + ) + } + .disabled(isFileActionRunning) + } + + @ViewBuilder + private var fileActionButtons: some View { + Button { + prepareFileAction(.share) + } label: { + Label( + String(localized: "chat.artifact.share", defaultValue: "Share", bundle: .module), + systemImage: "square.and.arrow.up" + ) + } + Button { + prepareFileAction(.save) + } label: { + Label( + String(localized: "chat.artifact.save_to_files", defaultValue: "Save to Files", bundle: .module), + systemImage: "folder.badge.plus" + ) + } + if model.isTextFile { Button { - UIPasteboard.general.string = path + UIPasteboard.general.string = model.renderedText } label: { Label( - String(localized: "chat.artifact.copy_path", defaultValue: "Copy path", bundle: .module), - systemImage: "link" + String(localized: "chat.artifact.copy_contents", defaultValue: "Copy contents", bundle: .module), + systemImage: "doc.on.doc" ) } + .disabled(!model.canCopyContents) + } + Button { + UIPasteboard.general.string = path } label: { Label( - String(localized: "chat.artifact.file_actions", defaultValue: "File actions", bundle: .module), - systemImage: "ellipsis.circle" + String(localized: "chat.artifact.copy_path", defaultValue: "Copy path", bundle: .module), + systemImage: "link" + ) + } + } + + @ViewBuilder + private var textViewerActionButtons: some View { + Button { + withAnimation(.snappy) { + if isSearchPresented { + dismissSearch() + } else { + dismissGoToLine() + isSearchPresented = true + } + } + } label: { + Label( + String( + localized: "chat.artifact.search.title", + defaultValue: "Search", + bundle: .module + ), + systemImage: "magnifyingglass" + ) + } + Button { + withAnimation(.snappy) { + if isGoToLinePresented { + dismissGoToLine() + } else { + dismissSearch() + isGoToLinePresented = true + } + } + } label: { + Label( + String( + localized: "chat.artifact.line.goto", + defaultValue: "Go to line", + bundle: .module + ), + systemImage: "text.line.first.and.arrowtriangle.forward" + ) + } + Button { + topRequestID += 1 + } label: { + Label( + String( + localized: "chat.artifact.jump.top", + defaultValue: "Top", + bundle: .module + ), + systemImage: "arrow.up.to.line" + ) + } + Button { + bottomRequestID += 1 + } label: { + Label(jumpToBottomTitle, systemImage: "arrow.down.to.line") + } + Button { + showsLineNumbers.toggle() + } label: { + Label( + String( + localized: "chat.artifact.line.numbers", + defaultValue: "Line numbers", + bundle: .module + ), + systemImage: showsLineNumbers ? "checkmark" : "number" + ) + } + Button { + wrapsLines.toggle() + textPreferences.setWrapsLines( + wrapsLines, + for: textLayoutKind + ) + } label: { + Label( + String( + localized: "chat.artifact.wrap", + defaultValue: "Word wrap", + bundle: .module + ), + systemImage: wrapsLines ? "checkmark" : "text.justify.left" ) } - .disabled(isFileActionRunning) } private enum PreparedFileAction { diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index d865d1aea9ce..be7b6790977d 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -2437,6 +2437,23 @@ } } }, + "chat.artifact.viewer.actions" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Viewer actions" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ビューア操作" + } + } + } + }, "chat.artifact.wrap" : { "extractionState" : "manual", "localizations" : { From 063b3b88aa849f29965fe2b354026487d4f78f95 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 19:45:26 -0700 Subject: [PATCH 53/92] test: cover missing gallery files by default --- ...ChatArtifactGalleryPresentationTests.swift | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPresentationTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPresentationTests.swift index 54e6467338a9..ffdf501e688a 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPresentationTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPresentationTests.swift @@ -49,6 +49,29 @@ struct ChatArtifactGalleryPresentationTests { #expect(sized.items(in: .referenced).map(\.displayName) == ["a.txt", "b.txt"]) } + @Test("missing files are omitted from every group by default") + func omitsMissingFilesByDefault() { + let snapshot = gallerySnapshot( + created: [ + item("/created/present.txt", kind: .text, size: 1), + item("/created/missing.txt", kind: .text, size: nil, exists: false), + ], + attached: [ + item("/attached/missing.png", kind: .image, size: nil, exists: false), + ], + referenced: [ + item("/referenced/present.log", kind: .text, size: 2), + item("/referenced/missing.log", kind: .text, size: nil, exists: false), + ] + ) + + let presentation = ChatArtifactGalleryPresentation(snapshot: snapshot) + + #expect(presentation.items(in: .created).map(\.displayName) == ["present.txt"]) + #expect(presentation.items(in: .attached).isEmpty) + #expect(presentation.items(in: .referenced).map(\.displayName) == ["present.log"]) + } + private func gallerySnapshot( created: [ChatArtifactGalleryItem] = [], attached: [ChatArtifactGalleryItem] = [], @@ -67,13 +90,15 @@ struct ChatArtifactGalleryPresentationTests { private func item( _ path: String, kind: ChatArtifactKind, - size: Int64? + size: Int64?, + exists: Bool = true ) -> ChatArtifactGalleryItem { ChatArtifactGalleryItem( path: path, kind: kind, displayName: URL(fileURLWithPath: path).lastPathComponent, - size: size + size: size, + exists: exists ) } } From 246b090441a5879af2d3a3cbaa36f3ca2ea8f6e2 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 19:53:41 -0700 Subject: [PATCH 54/92] feat(ios): hide missing gallery files by default --- .../ChatArtifactGalleryPresentation.swift | 35 ++++++++++++++--- ...ChatArtifactGalleryPresentationTests.swift | 28 +++++++++++++ .../MobileDisplaySettings.swift | 12 +++++- .../MobileSettingsView.swift | 8 ++++ .../TerminalArtifactFilesSheet+Content.swift | 39 ++++++++++++------- .../TerminalArtifactFilesSheet.swift | 8 +++- .../MobileDisplaySettingsTests.swift | 16 ++++++++ ios/cmux/Resources/Localizable.xcstrings | 17 ++++++++ 8 files changed, 142 insertions(+), 21 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPresentation.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPresentation.swift index 163e9eab6ab0..1ef806b71bed 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPresentation.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPresentation.swift @@ -5,31 +5,54 @@ public struct ChatArtifactGalleryPresentation: Sendable, Equatable { /// Created, Attached, and Referenced groups in their fixed display order. public let groups: [ChatArtifactGalleryGroup] + /// Whether every projected group has no visible rows. + public var isEmpty: Bool { groups.allSatisfy(\.items.isEmpty) } + /// Creates a grouped presentation without changing the source snapshot. /// /// - Parameters: /// - snapshot: Accumulated gallery rows to project. /// - filter: Sheet-wide kind filter. /// - sort: Ordering applied independently within each group. + /// - includesMissingFiles: Whether rows absent from the Mac remain visible. /// - classifier: Classifier used for extension-based buckets. public init( snapshot: ChatArtifactGallerySnapshot, filter: ChatArtifactGalleryFilter = .all, sort: ChatArtifactGallerySort = .recent, + includesMissingFiles: Bool = false, classifier: ChatArtifactGalleryClassifier = ChatArtifactGalleryClassifier() ) { groups = [ ChatArtifactGalleryGroup( kind: .created, - items: Self.project(snapshot.created, filter: filter, sort: sort, classifier: classifier) + items: Self.project( + snapshot.created, + filter: filter, + sort: sort, + includesMissingFiles: includesMissingFiles, + classifier: classifier + ) ), ChatArtifactGalleryGroup( kind: .attached, - items: Self.project(snapshot.attached, filter: filter, sort: sort, classifier: classifier) + items: Self.project( + snapshot.attached, + filter: filter, + sort: sort, + includesMissingFiles: includesMissingFiles, + classifier: classifier + ) ), ChatArtifactGalleryGroup( kind: .referenced, - items: Self.project(snapshot.referenced, filter: filter, sort: sort, classifier: classifier) + items: Self.project( + snapshot.referenced, + filter: filter, + sort: sort, + includesMissingFiles: includesMissingFiles, + classifier: classifier + ) ), ] } @@ -46,11 +69,13 @@ public struct ChatArtifactGalleryPresentation: Sendable, Equatable { _ items: [ChatArtifactGalleryItem], filter: ChatArtifactGalleryFilter, sort: ChatArtifactGallerySort, + includesMissingFiles: Bool, classifier: ChatArtifactGalleryClassifier ) -> [ChatArtifactGalleryItem] { + let visible = includesMissingFiles ? items : items.filter(\.exists) let filtered = filter == .all - ? items - : items.filter { classifier.filter(for: $0) == filter } + ? visible + : visible.filter { classifier.filter(for: $0) == filter } switch sort { case .recent: return filtered diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPresentationTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPresentationTests.swift index ffdf501e688a..2dd3fbed7c7b 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPresentationTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPresentationTests.swift @@ -70,6 +70,34 @@ struct ChatArtifactGalleryPresentationTests { #expect(presentation.items(in: .created).map(\.displayName) == ["present.txt"]) #expect(presentation.items(in: .attached).isEmpty) #expect(presentation.items(in: .referenced).map(\.displayName) == ["present.log"]) + #expect(ChatArtifactGallerySwipeOrder(groups: presentation.groups).paths == [ + "/created/present.txt", + "/referenced/present.log", + ]) + } + + @Test("missing files can be restored across every group") + func includesMissingFilesOnRequest() { + let missingCreated = item("/created/missing.txt", kind: .text, size: nil, exists: false) + let missingAttached = item("/attached/missing.png", kind: .image, size: nil, exists: false) + let missingReferenced = item("/referenced/missing.log", kind: .text, size: nil, exists: false) + let presentation = ChatArtifactGalleryPresentation( + snapshot: gallerySnapshot( + created: [missingCreated], + attached: [missingAttached], + referenced: [missingReferenced] + ), + includesMissingFiles: true + ) + + #expect(presentation.items(in: .created) == [missingCreated]) + #expect(presentation.items(in: .attached) == [missingAttached]) + #expect(presentation.items(in: .referenced) == [missingReferenced]) + #expect(ChatArtifactGallerySwipeOrder(groups: presentation.groups).paths == [ + missingCreated.path, + missingAttached.path, + missingReferenced.path, + ]) } private func gallerySnapshot( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift index 0a6063d43bcd..9c731d76db64 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift @@ -22,6 +22,7 @@ public final class MobileDisplaySettings { private nonisolated(unsafe) let defaults: UserDefaults private static let wrapWorkspaceTitlesKey = "cmux.mobile.wrapWorkspaceTitles" private static let showAltScreenNoticeKey = "cmux.mobile.showAltScreenNotice" + private static let showMissingFilesKey = "cmux.mobile.showMissingFiles" private static let workspacePreviewLineCountKey = "cmux.mobile.workspacePreviewLineCount" private static let unreadIndicatorLeftShiftKey = "cmux.mobile.debug.unreadIndicatorLeftShift.v2" private static let profilePictureLeftShiftKey = "cmux.mobile.debug.profilePictureLeftShift" @@ -58,6 +59,13 @@ public final class MobileDisplaySettings { didSet { defaults.set(showAltScreenNotice, forKey: Self.showAltScreenNoticeKey) } } + /// Whether artifact galleries include paths that no longer exist on the + /// connected Mac. Defaults to `false`. Mutating this writes through to the + /// injected ``UserDefaults``. + public var showMissingFiles: Bool { + didSet { defaults.set(showMissingFiles, forKey: Self.showMissingFilesKey) } + } + /// How many lines a workspace row's activity preview shows (1 or 2). /// Defaults to 2. Mutating this clamps to the supported range and writes /// through to the injected ``UserDefaults``. @@ -103,11 +111,13 @@ public final class MobileDisplaySettings { /// - Parameter defaults: The store backing the persisted preferences. /// Defaults to `.standard`; tests pass a scoped suite. Stored properties /// are initialized from `defaults`; absent keys read as their default - /// (single-line titles, two preview lines) without a write. + /// (single-line titles, hidden missing files, two preview lines) without + /// a write. public init(defaults: UserDefaults = .standard) { self.defaults = defaults self.wrapWorkspaceTitles = defaults.bool(forKey: Self.wrapWorkspaceTitlesKey) self.showAltScreenNotice = defaults.object(forKey: Self.showAltScreenNoticeKey) as? Bool ?? true + self.showMissingFiles = defaults.bool(forKey: Self.showMissingFilesKey) let storedPreviewLines = defaults.object(forKey: Self.workspacePreviewLineCountKey) as? Int self.workspacePreviewLineCount = Self.clampedWorkspacePreviewLineCount( storedPreviewLines ?? Self.defaultWorkspacePreviewLineCount diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 719fed3e40bc..85d4fa4d80f2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -204,6 +204,14 @@ struct MobileSettingsView: View { #endif Section(L10n.string("mobile.settings.display", defaultValue: "Display")) { + Toggle(isOn: $displaySettings.showMissingFiles) { + Text(L10n.string( + "mobile.settings.showMissingFiles", + defaultValue: "Show missing files" + )) + } + .accessibilityIdentifier("MobileSettingsShowMissingFiles") + Toggle(isOn: $displaySettings.wrapWorkspaceTitles) { Text(L10n.string("mobile.settings.wrapTitles", defaultValue: "Wrap Workspace Titles")) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift index 83c97b4a551a..01db21669bfe 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift @@ -146,7 +146,16 @@ extension TerminalArtifactFilesSheet { case .failed: failureView { await loadFirstSessionPage(query: nil) } case .loaded(let snapshot): - if snapshot.isEmpty { + let visibleSnapshotIsEmpty = displaySettings.showMissingFiles + ? snapshot.isEmpty + : ChatArtifactGalleryPresentation(snapshot: snapshot).isEmpty + let presentation = ChatArtifactGalleryPresentation( + snapshot: snapshot, + filter: galleryFilter, + sort: gallerySort, + includesMissingFiles: displaySettings.showMissingFiles + ) + if visibleSnapshotIsEmpty { ScrollView { ContentUnavailableView( String( @@ -162,11 +171,6 @@ extension TerminalArtifactFilesSheet { await loadFirstSessionPage(query: nil, preservingContent: true) } } else { - let presentation = ChatArtifactGalleryPresentation( - snapshot: snapshot, - filter: galleryFilter, - sort: gallerySort - ) let created = presentation.items(in: .created) let attached = presentation.items(in: .attached) let referenced = presentation.items(in: .referenced) @@ -183,7 +187,9 @@ extension TerminalArtifactFilesSheet { defaultValue: "Created by agent", bundle: .module ), - count: usesCompleteSessionSnapshot ? created.count : snapshot.createdTotal, + count: displaySettings.showMissingFiles + ? (usesCompleteSessionSnapshot ? created.count : snapshot.createdTotal) + : nil, items: created, expanded: $createdExpanded, swipeOrder: swipeOrder @@ -194,7 +200,9 @@ extension TerminalArtifactFilesSheet { defaultValue: "You attached", bundle: .module ), - count: usesCompleteSessionSnapshot ? attached.count : snapshot.attachedTotal, + count: displaySettings.showMissingFiles + ? (usesCompleteSessionSnapshot ? attached.count : snapshot.attachedTotal) + : nil, items: attached, expanded: $attachedExpanded, swipeOrder: swipeOrder @@ -205,9 +213,11 @@ extension TerminalArtifactFilesSheet { defaultValue: "Referenced", bundle: .module ), - count: usesCompleteSessionSnapshot - ? referenced.count - : snapshot.referencedTotal, + count: displaySettings.showMissingFiles + ? (usesCompleteSessionSnapshot + ? referenced.count + : snapshot.referencedTotal) + : nil, items: referenced, expanded: $referencedExpanded, swipeOrder: swipeOrder, @@ -281,7 +291,8 @@ extension TerminalArtifactFilesSheet { let presentation = ChatArtifactGalleryPresentation( snapshot: snapshot, filter: galleryFilter, - sort: gallerySort + sort: gallerySort, + includesMissingFiles: displaySettings.showMissingFiles ) let items = presentation.items(in: .referenced) let swipeOrder = ChatArtifactGallerySwipeOrder(items: items) @@ -349,7 +360,7 @@ extension TerminalArtifactFilesSheet { private func artifactSection( title: String, - count: Int, + count: Int?, items: [ChatArtifactGalleryItem], expanded: Binding, swipeOrder: ChatArtifactGallerySwipeOrder, @@ -400,7 +411,7 @@ extension TerminalArtifactFilesSheet { .padding(.vertical, 12) } } label: { - Text(verbatim: "\(title) (\(count))") + Text(verbatim: count.map { "\(title) (\($0))" } ?? title) .font(.headline) } .padding(.horizontal, 16) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift index 30f98609d314..c9eb5ba17913 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift @@ -48,6 +48,7 @@ struct TerminalArtifactFilesSheet: View { @State var liveRefreshState = ChatArtifactGalleryLiveRefreshState() @State var sessionViewportIsAtTopOrFits = true @State var lastHandledRefreshSignal: TerminalArtifactGalleryRefreshSignal + @Environment(MobileDisplaySettings.self) var displaySettings @Environment(\.dismiss) private var dismiss init( @@ -332,6 +333,7 @@ struct TerminalArtifactFilesSheet: View { let expectedFilter = galleryFilter let expectedSort = gallerySort + let expectedShowMissingFiles = displaySettings.showMissingFiles let expectedQuery = query eagerPagingState = .loading do { @@ -358,6 +360,7 @@ struct TerminalArtifactFilesSheet: View { guard !Task.isCancelled, galleryFilter == expectedFilter, gallerySort == expectedSort, + displaySettings.showMissingFiles == expectedShowMissingFiles, queryIsCurrent else { return } let currentState = query == nil ? sessionState : searchState guard case .loaded(let currentSnapshot) = currentState, @@ -463,7 +466,9 @@ struct TerminalArtifactFilesSheet: View { static let pageSize = 60 var usesCompleteSessionSnapshot: Bool { - galleryFilter != .all || gallerySort != .recent + galleryFilter != .all + || gallerySort != .recent + || !displaySettings.showMissingFiles } var eagerPagingTaskID: String { @@ -478,6 +483,7 @@ struct TerminalArtifactFilesSheet: View { return [ galleryFilter.rawValue, gallerySort.rawValue, + String(displaySettings.showMissingFiles), query, cursor, String(eagerPagingRevision), diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift index 1b6bd15a8d64..7433db33919a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift @@ -42,6 +42,22 @@ import Testing #expect(MobileDisplaySettings(defaults: defaults).showAltScreenNotice) } + @Test func showMissingFilesDefaultsToFalseWithoutAWrite() throws { + let defaults = try makeDefaults("showMissingFilesDefaults") + let settings = MobileDisplaySettings(defaults: defaults) + #expect(!settings.showMissingFiles) + #expect(defaults.object(forKey: "cmux.mobile.showMissingFiles") == nil) + } + + @Test func showMissingFilesPersistsAcrossInstances() throws { + let defaults = try makeDefaults("showMissingFilesPersists") + let settings = MobileDisplaySettings(defaults: defaults) + settings.showMissingFiles = true + #expect(MobileDisplaySettings(defaults: defaults).showMissingFiles) + settings.showMissingFiles = false + #expect(!MobileDisplaySettings(defaults: defaults).showMissingFiles) + } + @Test func previewLineCountPersistsAcrossInstances() throws { let defaults = try makeDefaults("persists") let settings = MobileDisplaySettings(defaults: defaults) diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index ca5a7e32ab8d..23835e363300 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -5033,6 +5033,23 @@ } } }, + "mobile.settings.showMissingFiles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show missing files" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "見つからないファイルを表示" + } + } + } + }, "mobile.settings.wrapTitles": { "extractionState": "manual", "localizations": { From 0af7e1e60af6ed3ec8572d6e32ebb7935a3be884 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 20:57:05 -0700 Subject: [PATCH 55/92] test(ios): cover stable artifact viewer ownership --- .../ChatArtifactViewerPagerModelTests.swift | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPagerModelTests.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPagerModelTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPagerModelTests.swift new file mode 100644 index 000000000000..d13935a6308b --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPagerModelTests.swift @@ -0,0 +1,88 @@ +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact viewer pager ownership") +struct ChatArtifactViewerPagerModelTests { + @Test("keeps page identity stable across gallery snapshot updates") + @MainActor + func keepsStablePageIdentity() throws { + let defaults = try #require(UserDefaults(suiteName: "cmux.viewer-pager.\(UUID().uuidString)")) + let initialOrder = swipeOrder([ + item(path: "/logs/build.log", size: 11_000_000), + item(path: "/logs/test.log", size: 2_000), + ]) + let model = ChatArtifactViewerPagerModel( + initialPath: "/logs/build.log", + swipeOrder: initialOrder, + textPreferences: ChatArtifactTextPreferences(defaults: defaults) + ) + let initialIdentity = try #require(model.pageIdentity(for: "/logs/build.log")) + + model.update(swipeOrder: swipeOrder([ + item(path: "/logs/new.log", size: 40), + item(path: "/logs/build.log", size: 11_500_000), + item(path: "/logs/test.log", size: 2_000), + ])) + + #expect(model.pageIdentity(for: "/logs/build.log") == initialIdentity) + #expect(model.pageSnapshots.map(\.path) == ["/logs/new.log", "/logs/build.log"]) + } + + @Test("projects toolbar state from exactly the selected page") + @MainActor + func projectsOneToolbarState() throws { + let defaults = try #require(UserDefaults(suiteName: "cmux.viewer-toolbar.\(UUID().uuidString)")) + let model = ChatArtifactViewerPagerModel( + initialPath: "/first.txt", + swipeOrder: swipeOrder([ + item(path: "/first.txt", size: 10), + item(path: "/second.txt", size: 20), + item(path: "/third.txt", size: 30), + ]), + textPreferences: ChatArtifactTextPreferences(defaults: defaults) + ) + + #expect(model.toolbarSnapshot.path == "/first.txt") + model.select(path: "/second.txt") + #expect(model.toolbarSnapshot.path == "/second.txt") + #expect(model.pageSnapshots.count == 3) + } + + @Test("snapshot refreshes do not replay jump requests") + @MainActor + func keepsJumpRequestIdentity() throws { + let defaults = try #require(UserDefaults(suiteName: "cmux.viewer-jumps.\(UUID().uuidString)")) + let model = ChatArtifactViewerPagerModel( + initialPath: "/build.log", + swipeOrder: swipeOrder([item(path: "/build.log", size: 11_000_000)]), + textPreferences: ChatArtifactTextPreferences(defaults: defaults) + ) + + model.requestTop() + let requestID = model.toolbarSnapshot.topRequestID + model.update(swipeOrder: swipeOrder([ + item(path: "/build.log", size: 11_500_000), + item(path: "/new.log", size: 100), + ])) + + #expect(requestID == 1) + #expect(model.toolbarSnapshot.topRequestID == requestID) + } + + private func swipeOrder(_ items: [ChatArtifactGalleryItem]) -> ChatArtifactGallerySwipeOrder { + ChatArtifactGallerySwipeOrder(items: items) + } + + private func item(path: String, size: Int64) -> ChatArtifactGalleryItem { + ChatArtifactGalleryItem( + path: path, + kind: .text, + displayName: URL(fileURLWithPath: path).lastPathComponent, + size: size, + modifiedAt: Date(timeIntervalSince1970: Double(size)) + ) + } +} From 8362ddba15b872a32cb2c22ce0313919b229ffd8 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 14 Jul 2026 21:12:27 -0700 Subject: [PATCH 56/92] fix(ios): stabilize artifact viewer ownership --- .../ChatArtifactHighlightingStatusPill.swift | 10 +- .../ChatArtifactViewerFileActionState.swift | 8 + .../ChatArtifactViewerPageActions.swift | 17 + .../ChatArtifactViewerPageModel.swift | 220 ++++++++ .../ChatArtifactViewerPageSnapshot.swift | 54 ++ .../Artifacts/ChatArtifactViewerPager.swift | 327 +++++++++++- .../ChatArtifactViewerPagerModel.swift | 176 +++++++ .../ChatArtifactViewerRouteView.swift | 476 +++--------------- .../ChatArtifactViewerPagerModelTests.swift | 75 ++- 9 files changed, 923 insertions(+), 440 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerFileActionState.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageActions.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageSnapshot.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPagerModel.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightingStatusPill.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightingStatusPill.swift index d0a7687c4999..ca4f847b7001 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightingStatusPill.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightingStatusPill.swift @@ -10,7 +10,9 @@ struct ChatArtifactHighlightingStatusPill: View { var body: some View { Button { - isExpanded.toggle() + withAnimation(.snappy) { + isExpanded.toggle() + } } label: { HStack(alignment: .firstTextBaseline, spacing: 8) { Image(systemName: "paintbrush.slash") @@ -20,6 +22,7 @@ struct ChatArtifactHighlightingStatusPill: View { .font(.caption) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) + .transition(.opacity) } else { Text(String( localized: "chat.artifact.highlighting.off", @@ -27,6 +30,7 @@ struct ChatArtifactHighlightingStatusPill: View { bundle: .module )) .font(.caption.weight(.semibold)) + .transition(.opacity) } Image(systemName: isExpanded ? "xmark.circle.fill" : "chevron.right") .imageScale(.small) @@ -36,11 +40,11 @@ struct ChatArtifactHighlightingStatusPill: View { .padding(.horizontal, 12) .padding(.vertical, 8) .frame(maxWidth: isExpanded ? 360 : nil, alignment: .leading) - .background(.thinMaterial, in: Capsule()) + .background(Color.secondary.opacity(0.12), in: Capsule()) + .clipShape(Capsule()) .contentShape(Capsule()) } .buttonStyle(.plain) - .animation(.snappy, value: isExpanded) } private var explanation: String { diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerFileActionState.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerFileActionState.swift new file mode 100644 index 000000000000..144b7fbd07db --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerFileActionState.swift @@ -0,0 +1,8 @@ +/// Immutable system-presentation state owned by one artifact page. +struct ChatArtifactViewerFileActionState: Equatable { + #if os(iOS) + var presentation: ChatArtifactFileActionPresentation? = nil + #endif + var isRunning = false + var showsError = false +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageActions.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageActions.swift new file mode 100644 index 000000000000..734b5f1f12d7 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageActions.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Closure actions let a viewer page mutate its owner without retaining an observable model. +struct ChatArtifactViewerPageActions { + let load: @MainActor () async -> Void + let cleanup: @MainActor () async -> Void + let retry: @MainActor () -> Void + let setSearchQuery: @MainActor (String) -> Void + let setSearchSummary: @MainActor (ChatArtifactSearchSummary) -> Void + let selectPreviousSearchResult: @MainActor () -> Void + let selectNextSearchResult: @MainActor () -> Void + let dismissSearch: @MainActor () -> Void + let setGoToLineText: @MainActor (String) -> Void + let goToLine: @MainActor (Int) -> Void + let dismissGoToLine: @MainActor () -> Void + let setFontSize: @MainActor (Double) -> Void +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift new file mode 100644 index 000000000000..42a936d6c122 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift @@ -0,0 +1,220 @@ +import CmuxAgentChat +import Foundation +import Observation + +/// Owns mutable content and controls for one stable artifact path. +@Observable +@MainActor +final class ChatArtifactViewerPageModel { + let path: String + + private let viewerModel: ChatArtifactViewerModel + private let textPreferences: ChatArtifactTextPreferences + private let textLayoutKind: ChatArtifactTextLayoutKind + private(set) var retryGeneration = 0 + private(set) var topRequestID = 0 + private(set) var bottomRequestID = 0 + private(set) var isSearchPresented = false + private(set) var searchQuery = "" + private(set) var searchSummary = ChatArtifactSearchSummary.empty + private(set) var previousSearchRequestID = 0 + private(set) var nextSearchRequestID = 0 + private(set) var showsLineNumbers = true + private(set) var isGoToLinePresented = false + private(set) var goToLineText = "" + private(set) var goToLineUTF16Offset = 0 + private(set) var goToLineRequestID = 0 + private(set) var wrapsLines: Bool + private(set) var textFontSize: Double + private(set) var fileActionState = ChatArtifactViewerFileActionState() + + init( + path: String, + textPreferences: ChatArtifactTextPreferences, + viewerModel: ChatArtifactViewerModel = ChatArtifactViewerModel() + ) { + self.path = path + self.textPreferences = textPreferences + self.viewerModel = viewerModel + let layoutKind = ChatArtifactTextLayoutKind(path: path) + textLayoutKind = layoutKind + wrapsLines = textPreferences.wrapsLines(for: layoutKind) + textFontSize = textPreferences.fontSize(for: layoutKind) + } + + var snapshot: ChatArtifactViewerPageSnapshot { + ChatArtifactViewerPageSnapshot( + path: path, + state: viewerModel.state, + textChunks: viewerModel.textChunks, + fetchedBytes: viewerModel.fetchedBytes, + totalBytes: viewerModel.totalBytes, + textReachedEOF: viewerModel.textReachedEOF, + markdownPresentation: viewerModel.markdownPresentation, + textHighlightDecision: viewerModel.textHighlightDecision, + textLineIndex: viewerModel.textLineIndex, + hasFileActions: viewerModel.hasFileActions, + isTextFile: viewerModel.isTextFile, + canCopyContents: viewerModel.canCopyContents, + retryGeneration: retryGeneration, + topRequestID: topRequestID, + bottomRequestID: bottomRequestID, + isSearchPresented: isSearchPresented, + searchQuery: searchQuery, + searchSummary: searchSummary, + previousSearchRequestID: previousSearchRequestID, + nextSearchRequestID: nextSearchRequestID, + showsLineNumbers: showsLineNumbers, + isGoToLinePresented: isGoToLinePresented, + goToLineText: goToLineText, + goToLineUTF16Offset: goToLineUTF16Offset, + goToLineRequestID: goToLineRequestID, + wrapsLines: wrapsLines, + textFontSize: textFontSize, + fileActionState: fileActionState + ) + } + + func load( + loader: ChatArtifactLoader, + quickLookCanPreview: @MainActor (URL) -> Bool + ) async { + await viewerModel.load( + path: path, + loader: loader, + quickLookCanPreview: quickLookCanPreview + ) + } + + func cleanup() async { + await viewerModel.cleanup() + } + + func retry() { + retryGeneration += 1 + } + + func toggleSearch() { + if isSearchPresented { + dismissSearch() + } else { + dismissGoToLine() + isSearchPresented = true + } + } + + func dismissSearch() { + isSearchPresented = false + searchQuery = "" + searchSummary = .empty + } + + func setSearchQuery(_ query: String) { + searchQuery = query + } + + func setSearchSummary(_ summary: ChatArtifactSearchSummary) { + searchSummary = summary + } + + func selectPreviousSearchResult() { + previousSearchRequestID += 1 + } + + func selectNextSearchResult() { + nextSearchRequestID += 1 + } + + func toggleGoToLine() { + if isGoToLinePresented { + dismissGoToLine() + } else { + dismissSearch() + isGoToLinePresented = true + } + } + + func dismissGoToLine() { + isGoToLinePresented = false + goToLineText = "" + } + + func setGoToLineText(_ text: String) { + goToLineText = text + } + + func goToLine(_ requestedLine: Int) { + let line = viewerModel.textLineIndex.clampedLine(requestedLine) + goToLineText = String(line) + goToLineUTF16Offset = viewerModel.textLineIndex.offset(forLine: line) + goToLineRequestID += 1 + } + + func requestTop() { + topRequestID += 1 + } + + func requestBottom() { + bottomRequestID += 1 + } + + func toggleLineNumbers() { + showsLineNumbers.toggle() + } + + func toggleWordWrap() { + wrapsLines.toggle() + textPreferences.setWrapsLines(wrapsLines, for: textLayoutKind) + } + + func setFontSize(_ fontSize: Double) { + textFontSize = textPreferences.setFontSize(fontSize, for: textLayoutKind) + } + + func selectMarkdownMode(_ mode: ChatArtifactMarkdownMode) { + if mode == .rendered { + dismissSearch() + dismissGoToLine() + } + viewerModel.selectMarkdownMode(mode) + } + + #if os(iOS) + func prepareShare(loader: ChatArtifactLoader) async { + await prepareFileAction(loader: loader, presentation: ChatArtifactFileActionPresentation.share) + } + + func prepareSave(loader: ChatArtifactLoader) async { + await prepareFileAction(loader: loader, presentation: ChatArtifactFileActionPresentation.save) + } + + func setFileActionPresentation(_ presentation: ChatArtifactFileActionPresentation?) { + fileActionState.presentation = presentation + } + + func setShowsFileActionError(_ isPresented: Bool) { + fileActionState.showsError = isPresented + } + + private func prepareFileAction( + loader: ChatArtifactLoader, + presentation: (URL) -> ChatArtifactFileActionPresentation + ) async { + guard !fileActionState.isRunning else { return } + fileActionState.isRunning = true + defer { fileActionState.isRunning = false } + do { + let fileURL = try await ChatArtifactFileActionStore.applicationDefault.materialize( + path: path, + loader: loader + ) + try Task.checkCancellation() + fileActionState.presentation = presentation(fileURL) + } catch is CancellationError { + return + } catch { + fileActionState.showsError = true + } + } + #endif +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageSnapshot.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageSnapshot.swift new file mode 100644 index 000000000000..cd582b2aff0b --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageSnapshot.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Immutable render state for one path-keyed artifact viewer page. +struct ChatArtifactViewerPageSnapshot: Identifiable, Equatable { + let path: String + let state: ChatArtifactViewerState + let textChunks: [String] + let fetchedBytes: Int64 + let totalBytes: Int64? + let textReachedEOF: Bool + let markdownPresentation: ChatArtifactMarkdownPresentation + let textHighlightDecision: ChatArtifactHighlightDecision + let textLineIndex: ChatArtifactLineIndex + let hasFileActions: Bool + let isTextFile: Bool + let canCopyContents: Bool + let retryGeneration: Int + let topRequestID: Int + let bottomRequestID: Int + let isSearchPresented: Bool + let searchQuery: String + let searchSummary: ChatArtifactSearchSummary + let previousSearchRequestID: Int + let nextSearchRequestID: Int + let showsLineNumbers: Bool + let isGoToLinePresented: Bool + let goToLineText: String + let goToLineUTF16Offset: Int + let goToLineRequestID: Int + let wrapsLines: Bool + let textFontSize: Double + let fileActionState: ChatArtifactViewerFileActionState + + var id: String { path } + + var displayName: String { + URL(fileURLWithPath: path).lastPathComponent + } + + var renderedText: String { + textChunks.joined() + } + + var shouldShowTextJumpControls: Bool { + state == .text + || (state == .markdown && markdownPresentation.mode == .raw) + } + + var hasViewerActions: Bool { + hasFileActions + || shouldShowTextJumpControls + || (state == .markdown && markdownPresentation.isRenderedAvailable) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift index 54b03fb0f6e4..13c5fd8bf580 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift @@ -1,14 +1,20 @@ import CmuxAgentChat import SwiftUI -/// Keeps only the selected artifact and its adjacent viewer pages mounted. +#if os(iOS) +import QuickLook +import UIKit +#endif + +/// Owns path-stable viewer pages and the destination's only navigation toolbar. struct ChatArtifactViewerPager: View { let initialPath: String let scope: ChatArtifactViewerScope let swipeOrder: ChatArtifactGallerySwipeOrder let onDone: () -> Void - @State private var selectedPath: String + @Environment(\.chatArtifactLoader) private var loader + @State private var model: ChatArtifactViewerPagerModel @State private var zoomedPath: String? init( @@ -21,44 +27,335 @@ struct ChatArtifactViewerPager: View { self.scope = scope self.swipeOrder = swipeOrder self.onDone = onDone - _selectedPath = State(initialValue: initialPath) + _model = State(initialValue: ChatArtifactViewerPagerModel( + initialPath: initialPath, + swipeOrder: swipeOrder, + textPreferences: ChatArtifactTextPreferences(defaults: .standard) + )) } @ViewBuilder var body: some View { + pagerContent + .navigationTitle(model.toolbarSnapshot.displayName) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + #if os(iOS) + ToolbarItemGroup(placement: .topBarTrailing) { + if model.toolbarSnapshot.hasViewerActions { + viewerActionsMenu(snapshot: model.toolbarSnapshot) + } + doneButton + } + #else + ToolbarItem(placement: .cancellationAction) { + doneButton + } + #endif + } + #if os(iOS) + .chatArtifactFileActionPresentation(fileActionPresentationBinding) + .alert( + String( + localized: "chat.artifact.action_failed.title", + defaultValue: "Couldn't complete action", + bundle: .module + ), + isPresented: fileActionErrorBinding + ) { + Button(String(localized: "chat.artifact.ok", defaultValue: "OK", bundle: .module)) {} + } message: { + Text(String( + localized: "chat.artifact.action_failed.message", + defaultValue: "Check the connection to your Mac and try again.", + bundle: .module + )) + } + #endif + .onChange(of: initialPath) { _, newPath in + model.update(initialPath: newPath, swipeOrder: swipeOrder) + } + .onChange(of: swipeOrder) { _, newOrder in + model.update(swipeOrder: newOrder) + } + } + + @ViewBuilder + private var pagerContent: some View { #if os(iOS) - if swipeOrder.count > 1, - swipeOrder.files.contains(where: { $0.path == initialPath }) { - TabView(selection: $selectedPath) { - ForEach(swipeOrder.pageWindow(around: selectedPath)) { file in - viewer(path: file.path) - .tag(file.path) + if model.usesPaging { + TabView(selection: selectionBinding) { + ForEach(model.pageSnapshots) { snapshot in + viewer(snapshot: snapshot) + .id(snapshot.path) + .tag(snapshot.path) } } .tabViewStyle(.page(indexDisplayMode: .never)) .scrollDisabled(zoomedPath != nil) } else { - viewer(path: initialPath) + viewer(snapshot: model.toolbarSnapshot) + .id(model.toolbarSnapshot.path) } #else - viewer(path: initialPath) + viewer(snapshot: model.toolbarSnapshot) + .id(model.toolbarSnapshot.path) #endif } - private func viewer(path: String) -> some View { + private func viewer(snapshot: ChatArtifactViewerPageSnapshot) -> some View { ChatArtifactViewerRouteView( - path: path, + snapshot: snapshot, scope: scope, + actions: model.actions( + for: snapshot.path, + loader: loader, + quickLookCanPreview: { fileURL in + #if os(iOS) + QLPreviewController.canPreview(ChatArtifactQuickLookItem( + fileURL: fileURL, + title: snapshot.displayName + )) + #else + false + #endif + } + ), onImageMinimumZoomChanged: { isAtMinimum in if isAtMinimum { - if zoomedPath == path { + if zoomedPath == snapshot.path { zoomedPath = nil } } else { - zoomedPath = path + zoomedPath = snapshot.path } }, onDone: onDone ) } + + private var selectionBinding: Binding { + Binding( + get: { model.selectedPath }, + set: { model.select(path: $0) } + ) + } + + private var doneButton: some View { + Button(String(localized: "chat.artifact.done", defaultValue: "Done", bundle: .module)) { + onDone() + } + } + + #if os(iOS) + private func viewerActionsMenu(snapshot: ChatArtifactViewerPageSnapshot) -> some View { + Menu { + if snapshot.hasFileActions { + Section { + fileActionButtons(snapshot: snapshot) + } + } + if snapshot.shouldShowTextJumpControls { + Section { + textViewerActionButtons(snapshot: snapshot) + } + } + if snapshot.state == .markdown, + snapshot.markdownPresentation.isRenderedAvailable { + Section { + Picker( + String( + localized: "chat.artifact.markdown.view", + defaultValue: "Markdown view", + bundle: .module + ), + selection: markdownModeBinding + ) { + Text(String( + localized: "chat.artifact.markdown.raw", + defaultValue: "Raw", + bundle: .module + )) + .tag(ChatArtifactMarkdownMode.raw) + Text(String( + localized: "chat.artifact.markdown.rendered", + defaultValue: "Rendered", + bundle: .module + )) + .tag(ChatArtifactMarkdownMode.rendered) + } + } + } + } label: { + Label( + String( + localized: "chat.artifact.viewer.actions", + defaultValue: "Viewer actions", + bundle: .module + ), + systemImage: "ellipsis.circle" + ) + } + .disabled(snapshot.fileActionState.isRunning) + } + + @ViewBuilder + private func fileActionButtons(snapshot: ChatArtifactViewerPageSnapshot) -> some View { + Button { + Task { await model.prepareShare(loader: loader) } + } label: { + Label( + String(localized: "chat.artifact.share", defaultValue: "Share", bundle: .module), + systemImage: "square.and.arrow.up" + ) + } + Button { + Task { await model.prepareSave(loader: loader) } + } label: { + Label( + String(localized: "chat.artifact.save_to_files", defaultValue: "Save to Files", bundle: .module), + systemImage: "folder.badge.plus" + ) + } + if snapshot.isTextFile { + Button { + UIPasteboard.general.string = snapshot.renderedText + } label: { + Label( + String(localized: "chat.artifact.copy_contents", defaultValue: "Copy contents", bundle: .module), + systemImage: "doc.on.doc" + ) + } + .disabled(!snapshot.canCopyContents) + } + Button { + UIPasteboard.general.string = snapshot.path + } label: { + Label( + String(localized: "chat.artifact.copy_path", defaultValue: "Copy path", bundle: .module), + systemImage: "link" + ) + } + } + + @ViewBuilder + private func textViewerActionButtons(snapshot: ChatArtifactViewerPageSnapshot) -> some View { + Button { + withAnimation(.snappy) { + model.toggleSearch() + } + } label: { + Label( + String( + localized: "chat.artifact.search.title", + defaultValue: "Search", + bundle: .module + ), + systemImage: "magnifyingglass" + ) + } + Button { + withAnimation(.snappy) { + model.toggleGoToLine() + } + } label: { + Label( + String( + localized: "chat.artifact.line.goto", + defaultValue: "Go to line", + bundle: .module + ), + systemImage: "text.line.first.and.arrowtriangle.forward" + ) + } + Button { + model.requestTop() + } label: { + Label( + String( + localized: "chat.artifact.jump.top", + defaultValue: "Top", + bundle: .module + ), + systemImage: "arrow.up.to.line" + ) + } + Button { + model.requestBottom() + } label: { + Label(jumpToBottomTitle(snapshot: snapshot), systemImage: "arrow.down.to.line") + } + Button { + model.toggleLineNumbers() + } label: { + Label( + String( + localized: "chat.artifact.line.numbers", + defaultValue: "Line numbers", + bundle: .module + ), + systemImage: snapshot.showsLineNumbers ? "checkmark" : "number" + ) + } + Button { + model.toggleWordWrap() + } label: { + Label( + String( + localized: "chat.artifact.wrap", + defaultValue: "Word wrap", + bundle: .module + ), + systemImage: snapshot.wrapsLines ? "checkmark" : "text.justify.left" + ) + } + } + + private var markdownModeBinding: Binding { + Binding( + get: { model.toolbarSnapshot.markdownPresentation.mode }, + set: { model.selectMarkdownMode($0) } + ) + } + + private var fileActionPresentationBinding: Binding { + let path = model.toolbarSnapshot.path + return Binding( + get: { + model.toolbarSnapshot.path == path + ? model.toolbarSnapshot.fileActionState.presentation + : nil + }, + set: { model.setFileActionPresentation($0, for: path) } + ) + } + + private var fileActionErrorBinding: Binding { + let path = model.toolbarSnapshot.path + return Binding( + get: { + model.toolbarSnapshot.path == path + && model.toolbarSnapshot.fileActionState.showsError + }, + set: { model.setShowsFileActionError($0, for: path) } + ) + } + + private func jumpToBottomTitle(snapshot: ChatArtifactViewerPageSnapshot) -> String { + if snapshot.textReachedEOF { + return String( + localized: "chat.artifact.jump.end", + defaultValue: "End", + bundle: .module + ) + } + return String( + localized: "chat.artifact.jump.bottom", + defaultValue: "Bottom", + bundle: .module + ) + } + #endif } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPagerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPagerModel.swift new file mode 100644 index 000000000000..8d55b9960019 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPagerModel.swift @@ -0,0 +1,176 @@ +import CmuxAgentChat +import Foundation +import Observation + +/// Retains one page owner per visible path while projecting a single selected toolbar state. +@Observable +@MainActor +final class ChatArtifactViewerPagerModel { + private(set) var selectedPath: String + private(set) var swipeOrder: ChatArtifactGallerySwipeOrder + private let textPreferences: ChatArtifactTextPreferences + @ObservationIgnored private var selectedPageModel: ChatArtifactViewerPageModel + @ObservationIgnored + private var pagesByPath: [String: ChatArtifactViewerPageModel] = [:] + + init( + initialPath: String, + swipeOrder: ChatArtifactGallerySwipeOrder, + textPreferences: ChatArtifactTextPreferences + ) { + selectedPath = initialPath + self.swipeOrder = swipeOrder + self.textPreferences = textPreferences + let initialPage = ChatArtifactViewerPageModel( + path: initialPath, + textPreferences: textPreferences + ) + selectedPageModel = initialPage + pagesByPath[initialPath] = initialPage + reconcilePages() + } + + var pageSnapshots: [ChatArtifactViewerPageSnapshot] { + pagePaths.compactMap { pagesByPath[$0]?.snapshot } + } + + var toolbarSnapshot: ChatArtifactViewerPageSnapshot { + selectedPageModel.snapshot + } + + var usesPaging: Bool { + swipeOrder.count > 1 && swipeOrder.paths.contains(selectedPath) + } + + func select(path: String) { + guard path != selectedPath, swipeOrder.paths.contains(path) else { return } + selectedPageModel = page(for: path) + selectedPath = path + reconcilePages() + } + + func update( + initialPath: String? = nil, + swipeOrder: ChatArtifactGallerySwipeOrder + ) { + self.swipeOrder = swipeOrder + if let initialPath, initialPath != selectedPath { + selectedPageModel = page(for: initialPath) + selectedPath = initialPath + } + reconcilePages() + } + + func pageIdentity(for path: String) -> ObjectIdentifier? { + pagesByPath[path].map(ObjectIdentifier.init) + } + + func actions( + for path: String, + loader: ChatArtifactLoader, + quickLookCanPreview: @escaping @MainActor (URL) -> Bool + ) -> ChatArtifactViewerPageActions { + let page = path == selectedPath + ? selectedPageModel + : pagesByPath[path]! + return ChatArtifactViewerPageActions( + load: { + await page.load( + loader: loader, + quickLookCanPreview: quickLookCanPreview + ) + }, + cleanup: { await page.cleanup() }, + retry: { page.retry() }, + setSearchQuery: { page.setSearchQuery($0) }, + setSearchSummary: { page.setSearchSummary($0) }, + selectPreviousSearchResult: { page.selectPreviousSearchResult() }, + selectNextSearchResult: { page.selectNextSearchResult() }, + dismissSearch: { page.dismissSearch() }, + setGoToLineText: { page.setGoToLineText($0) }, + goToLine: { page.goToLine($0) }, + dismissGoToLine: { page.dismissGoToLine() }, + setFontSize: { page.setFontSize($0) } + ) + } + + func toggleSearch() { + selectedPage.toggleSearch() + } + + func toggleGoToLine() { + selectedPage.toggleGoToLine() + } + + func requestTop() { + selectedPage.requestTop() + } + + func requestBottom() { + selectedPage.requestBottom() + } + + func toggleLineNumbers() { + selectedPage.toggleLineNumbers() + } + + func toggleWordWrap() { + selectedPage.toggleWordWrap() + } + + func selectMarkdownMode(_ mode: ChatArtifactMarkdownMode) { + selectedPage.selectMarkdownMode(mode) + } + + #if os(iOS) + func prepareShare(loader: ChatArtifactLoader) async { + let page = selectedPage + await page.prepareShare(loader: loader) + } + + func prepareSave(loader: ChatArtifactLoader) async { + let page = selectedPage + await page.prepareSave(loader: loader) + } + + func setFileActionPresentation( + _ presentation: ChatArtifactFileActionPresentation?, + for path: String + ) { + pagesByPath[path]?.setFileActionPresentation(presentation) + } + + func setShowsFileActionError(_ isPresented: Bool, for path: String) { + pagesByPath[path]?.setShowsFileActionError(isPresented) + } + #endif + + private var selectedPage: ChatArtifactViewerPageModel { + selectedPageModel + } + + private var pagePaths: [String] { + guard swipeOrder.paths.contains(selectedPath) else { return [selectedPath] } + return swipeOrder.pageWindow(around: selectedPath).map(\.path) + } + + private func page(for path: String) -> ChatArtifactViewerPageModel { + if let page = pagesByPath[path] { + return page + } + let page = ChatArtifactViewerPageModel( + path: path, + textPreferences: textPreferences + ) + pagesByPath[path] = page + return page + } + + private func reconcilePages() { + var nextPages: [String: ChatArtifactViewerPageModel] = [:] + for path in pagePaths { + nextPages[path] = page(for: path) + } + pagesByPath = nextPages + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index dada838060c2..f878ced87ac1 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -3,338 +3,44 @@ import SwiftUI #if canImport(UIKit) import UIKit -#if os(iOS) -import QuickLook -#endif #elseif canImport(AppKit) import AppKit #endif -/// The shared stat-driven route for every artifact entry point and folder level. +/// Renders one immutable artifact page snapshot without contributing navigation chrome. struct ChatArtifactViewerRouteView: View { - let path: String + let snapshot: ChatArtifactViewerPageSnapshot let scope: ChatArtifactViewerScope + let actions: ChatArtifactViewerPageActions let onDone: () -> Void let onImageMinimumZoomChanged: (Bool) -> Void - private let textPreferences: ChatArtifactTextPreferences - private let textLayoutKind: ChatArtifactTextLayoutKind - @Environment(\.chatArtifactLoader) private var loader @Environment(\.colorScheme) private var colorScheme - @State private var model = ChatArtifactViewerModel() - @State private var retryGeneration = 0 - @State private var topRequestID = 0 - @State private var bottomRequestID = 0 - @State private var isSearchPresented = false - @State private var searchQuery = "" - @State private var searchSummary = ChatArtifactSearchSummary.empty - @State private var previousSearchRequestID = 0 - @State private var nextSearchRequestID = 0 - @State private var showsLineNumbers = true - @State private var isGoToLinePresented = false - @State private var goToLineText = "" - @State private var goToLineUTF16Offset = 0 - @State private var goToLineRequestID = 0 - @State private var wrapsLines: Bool - @State private var textFontSize: Double - #if os(iOS) - @State private var fileActionPresentation: ChatArtifactFileActionPresentation? - @State private var isFileActionRunning = false - @State private var showsFileActionError = false - #endif init( - path: String, + snapshot: ChatArtifactViewerPageSnapshot, scope: ChatArtifactViewerScope, - textPreferences: ChatArtifactTextPreferences = ChatArtifactTextPreferences( - defaults: .standard - ), + actions: ChatArtifactViewerPageActions, onImageMinimumZoomChanged: @escaping (Bool) -> Void = { _ in }, onDone: @escaping () -> Void ) { - self.path = path + self.snapshot = snapshot self.scope = scope + self.actions = actions self.onDone = onDone self.onImageMinimumZoomChanged = onImageMinimumZoomChanged - self.textPreferences = textPreferences - let layoutKind = ChatArtifactTextLayoutKind(path: path) - textLayoutKind = layoutKind - _wrapsLines = State(initialValue: textPreferences.wrapsLines(for: layoutKind)) - _textFontSize = State(initialValue: textPreferences.fontSize(for: layoutKind)) } var body: some View { content - .navigationTitle(displayName) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - #if os(iOS) - ToolbarItemGroup(placement: .topBarTrailing) { - if hasViewerActions { - viewerActionsMenu - } - doneButton - } - #else - ToolbarItem(placement: .cancellationAction) { - doneButton - } - #endif - } - #if os(iOS) - .chatArtifactFileActionPresentation($fileActionPresentation) - .alert( - String( - localized: "chat.artifact.action_failed.title", - defaultValue: "Couldn't complete action", - bundle: .module - ), - isPresented: $showsFileActionError - ) { - Button(String(localized: "chat.artifact.ok", defaultValue: "OK", bundle: .module)) {} - } message: { - Text(String( - localized: "chat.artifact.action_failed.message", - defaultValue: "Check the connection to your Mac and try again.", - bundle: .module - )) - } - #endif - .task(id: "\(path)\u{0}\(retryGeneration)") { - #if os(iOS) - await model.load( - path: path, - loader: loader, - quickLookCanPreview: canQuickLookPreview - ) - #else - await model.load(path: path, loader: loader) - #endif + .task(id: "\(path)\u{0}\(snapshot.retryGeneration)") { + await actions.load() await waitForViewerTaskCancellation() - await model.cleanup() - } - } - - private var doneButton: some View { - Button(String(localized: "chat.artifact.done", defaultValue: "Done", bundle: .module)) { - onDone() - } - } - - #if os(iOS) - private var hasViewerActions: Bool { - model.hasFileActions - || shouldShowTextJumpControls - || (model.state == .markdown && model.markdownPresentation.isRenderedAvailable) - } - - private var viewerActionsMenu: some View { - Menu { - if model.hasFileActions { - Section { - fileActionButtons - } - } - if shouldShowTextJumpControls { - Section { - textViewerActionButtons - } - } - if model.state == .markdown, - model.markdownPresentation.isRenderedAvailable { - Section { - Picker( - String( - localized: "chat.artifact.markdown.view", - defaultValue: "Markdown view", - bundle: .module - ), - selection: markdownModeBinding - ) { - Text(String( - localized: "chat.artifact.markdown.raw", - defaultValue: "Raw", - bundle: .module - )) - .tag(ChatArtifactMarkdownMode.raw) - Text(String( - localized: "chat.artifact.markdown.rendered", - defaultValue: "Rendered", - bundle: .module - )) - .tag(ChatArtifactMarkdownMode.rendered) - } - } - } - } label: { - Label( - String( - localized: "chat.artifact.viewer.actions", - defaultValue: "Viewer actions", - bundle: .module - ), - systemImage: "ellipsis.circle" - ) - } - .disabled(isFileActionRunning) - } - - @ViewBuilder - private var fileActionButtons: some View { - Button { - prepareFileAction(.share) - } label: { - Label( - String(localized: "chat.artifact.share", defaultValue: "Share", bundle: .module), - systemImage: "square.and.arrow.up" - ) - } - Button { - prepareFileAction(.save) - } label: { - Label( - String(localized: "chat.artifact.save_to_files", defaultValue: "Save to Files", bundle: .module), - systemImage: "folder.badge.plus" - ) - } - if model.isTextFile { - Button { - UIPasteboard.general.string = model.renderedText - } label: { - Label( - String(localized: "chat.artifact.copy_contents", defaultValue: "Copy contents", bundle: .module), - systemImage: "doc.on.doc" - ) - } - .disabled(!model.canCopyContents) - } - Button { - UIPasteboard.general.string = path - } label: { - Label( - String(localized: "chat.artifact.copy_path", defaultValue: "Copy path", bundle: .module), - systemImage: "link" - ) - } - } - - @ViewBuilder - private var textViewerActionButtons: some View { - Button { - withAnimation(.snappy) { - if isSearchPresented { - dismissSearch() - } else { - dismissGoToLine() - isSearchPresented = true - } - } - } label: { - Label( - String( - localized: "chat.artifact.search.title", - defaultValue: "Search", - bundle: .module - ), - systemImage: "magnifyingglass" - ) - } - Button { - withAnimation(.snappy) { - if isGoToLinePresented { - dismissGoToLine() - } else { - dismissSearch() - isGoToLinePresented = true - } + await actions.cleanup() } - } label: { - Label( - String( - localized: "chat.artifact.line.goto", - defaultValue: "Go to line", - bundle: .module - ), - systemImage: "text.line.first.and.arrowtriangle.forward" - ) - } - Button { - topRequestID += 1 - } label: { - Label( - String( - localized: "chat.artifact.jump.top", - defaultValue: "Top", - bundle: .module - ), - systemImage: "arrow.up.to.line" - ) - } - Button { - bottomRequestID += 1 - } label: { - Label(jumpToBottomTitle, systemImage: "arrow.down.to.line") - } - Button { - showsLineNumbers.toggle() - } label: { - Label( - String( - localized: "chat.artifact.line.numbers", - defaultValue: "Line numbers", - bundle: .module - ), - systemImage: showsLineNumbers ? "checkmark" : "number" - ) - } - Button { - wrapsLines.toggle() - textPreferences.setWrapsLines( - wrapsLines, - for: textLayoutKind - ) - } label: { - Label( - String( - localized: "chat.artifact.wrap", - defaultValue: "Word wrap", - bundle: .module - ), - systemImage: wrapsLines ? "checkmark" : "text.justify.left" - ) - } } - private enum PreparedFileAction { - case share - case save - } - - private func prepareFileAction(_ action: PreparedFileAction) { - guard !isFileActionRunning else { return } - isFileActionRunning = true - Task { - do { - let fileURL = try await ChatArtifactFileActionStore.applicationDefault.materialize( - path: path, - loader: loader - ) - try Task.checkCancellation() - fileActionPresentation = switch action { - case .share: .share(fileURL) - case .save: .save(fileURL) - } - } catch is CancellationError { - // The viewer is going away; no error needs presenting. - } catch { - showsFileActionError = true - } - isFileActionRunning = false - } - } - #endif + private var path: String { snapshot.path } /// Keeps cleanup structured under the SwiftUI page task after loading ends. private func waitForViewerTaskCancellation() async { @@ -345,13 +51,13 @@ struct ChatArtifactViewerRouteView: View { @ViewBuilder private var content: some View { - switch model.state { + switch snapshot.state { case .loading: VStack(spacing: 12) { ProgressView( value: progressValue( - fetched: model.fetchedBytes, - total: model.totalBytes + fetched: snapshot.fetchedBytes, + total: snapshot.totalBytes ) ) .progressViewStyle(.linear) @@ -359,11 +65,11 @@ struct ChatArtifactViewerRouteView: View { Text(String(localized: "chat.artifact.loading", defaultValue: "Loading preview", bundle: .module)) .font(.subheadline) .foregroundStyle(.secondary) - if model.fetchedBytes > 0 || model.totalBytes != nil { + if snapshot.fetchedBytes > 0 || snapshot.totalBytes != nil { Text( verbatim: progressText( - fetched: model.fetchedBytes, - total: model.totalBytes + fetched: snapshot.fetchedBytes, + total: snapshot.totalBytes ) ) .font(.caption.monospacedDigit()) @@ -420,7 +126,7 @@ struct ChatArtifactViewerRouteView: View { #endif case .quickLook(let fileURL): #if os(iOS) - ChatArtifactQuickLookView(fileURL: fileURL, title: displayName) + ChatArtifactQuickLookView(fileURL: fileURL, title: snapshot.displayName) .ignoresSafeArea(.container, edges: .bottom) #else unavailableView( @@ -430,7 +136,7 @@ struct ChatArtifactViewerRouteView: View { #endif case .text: VStack(spacing: 0) { - if !model.textReachedEOF { + if !snapshot.textReachedEOF { streamingProgressHeader } searchBar @@ -440,11 +146,11 @@ struct ChatArtifactViewerRouteView: View { } case .markdown: VStack(spacing: 0) { - if !model.textReachedEOF { + if !snapshot.textReachedEOF { streamingProgressHeader } - if model.markdownPresentation.mode == .rendered { - ChatArtifactMarkdownView(markdown: model.renderedText) + if snapshot.markdownPresentation.mode == .rendered { + ChatArtifactMarkdownView(markdown: snapshot.renderedText) } else { searchBar goToLineBar @@ -494,12 +200,12 @@ struct ChatArtifactViewerRouteView: View { HStack(spacing: 10) { ProgressView( value: progressValue( - fetched: model.fetchedBytes, - total: model.totalBytes + fetched: snapshot.fetchedBytes, + total: snapshot.totalBytes ) ) .progressViewStyle(.linear) - Text(verbatim: progressText(fetched: model.fetchedBytes, total: model.totalBytes)) + Text(verbatim: progressText(fetched: snapshot.fetchedBytes, total: snapshot.totalBytes)) .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) .fixedSize() @@ -514,34 +220,27 @@ struct ChatArtifactViewerRouteView: View { #if canImport(UIKit) ChatArtifactTextView( documentID: path, - chunks: model.textChunks, - reachedEOF: model.textReachedEOF, - highlightDecision: model.textHighlightDecision, + chunks: snapshot.textChunks, + reachedEOF: snapshot.textReachedEOF, + highlightDecision: snapshot.textHighlightDecision, highlightTheme: colorScheme == .dark ? .dark : .light, - searchQuery: searchQuery, - previousSearchRequestID: previousSearchRequestID, - nextSearchRequestID: nextSearchRequestID, - onSearchSummaryChanged: { summary in - searchSummary = summary - }, - lineIndex: model.textLineIndex, - showsLineNumbers: showsLineNumbers, - goToLineUTF16Offset: goToLineUTF16Offset, - goToLineRequestID: goToLineRequestID, - wrapsLines: wrapsLines, - fontPointSize: textFontSize, - onFontSizeChanged: { fontSize in - textFontSize = textPreferences.setFontSize( - fontSize, - for: textLayoutKind - ) - }, - topRequestID: topRequestID, - bottomRequestID: bottomRequestID + searchQuery: snapshot.searchQuery, + previousSearchRequestID: snapshot.previousSearchRequestID, + nextSearchRequestID: snapshot.nextSearchRequestID, + onSearchSummaryChanged: { actions.setSearchSummary($0) }, + lineIndex: snapshot.textLineIndex, + showsLineNumbers: snapshot.showsLineNumbers, + goToLineUTF16Offset: snapshot.goToLineUTF16Offset, + goToLineRequestID: snapshot.goToLineRequestID, + wrapsLines: snapshot.wrapsLines, + fontPointSize: snapshot.textFontSize, + onFontSizeChanged: { actions.setFontSize($0) }, + topRequestID: snapshot.topRequestID, + bottomRequestID: snapshot.bottomRequestID ) #else ScrollView { - Text(model.renderedText) + Text(snapshot.renderedText) .font(.system(.body, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) @@ -552,13 +251,13 @@ struct ChatArtifactViewerRouteView: View { @ViewBuilder private var goToLineBar: some View { - if isGoToLinePresented { + if snapshot.isGoToLinePresented { ChatArtifactGoToLineBar( - lineText: $goToLineText, - onGo: goToLine, + lineText: goToLineTextBinding, + onGo: { actions.goToLine($0) }, onClose: { withAnimation(.snappy) { - dismissGoToLine() + actions.dismissGoToLine() } } ) @@ -568,16 +267,16 @@ struct ChatArtifactViewerRouteView: View { @ViewBuilder private var searchBar: some View { - if isSearchPresented { + if snapshot.isSearchPresented { ChatArtifactSearchBar( - query: $searchQuery, - summary: searchSummary, - isStillLoading: !model.textReachedEOF, - onPrevious: { previousSearchRequestID += 1 }, - onNext: { nextSearchRequestID += 1 }, + query: searchQueryBinding, + summary: snapshot.searchSummary, + isStillLoading: !snapshot.textReachedEOF, + onPrevious: { actions.selectPreviousSearchResult() }, + onNext: { actions.selectNextSearchResult() }, onClose: { withAnimation(.snappy) { - dismissSearch() + actions.dismissSearch() } } ) @@ -587,8 +286,8 @@ struct ChatArtifactViewerRouteView: View { @ViewBuilder private var highlightingStatusPill: some View { - if model.textHighlightDecision.showsHighlightingOffPill, - let totalBytes = model.totalBytes { + if snapshot.textHighlightDecision.showsHighlightingOffPill, + let totalBytes = snapshot.totalBytes { HStack { Spacer(minLength: 16) ChatArtifactHighlightingStatusPill( @@ -621,7 +320,7 @@ struct ChatArtifactViewerRouteView: View { } if retry { Button { - retryGeneration += 1 + actions.retry() } label: { Label( String(localized: "chat.artifact.retry", defaultValue: "Retry", bundle: .module), @@ -657,66 +356,17 @@ struct ChatArtifactViewerRouteView: View { #endif } - private var displayName: String { - URL(fileURLWithPath: path).lastPathComponent - } - - private var markdownModeBinding: Binding { + private var searchQueryBinding: Binding { Binding( - get: { model.markdownPresentation.mode }, - set: { mode in - if mode == .rendered { - dismissSearch() - dismissGoToLine() - } - model.selectMarkdownMode(mode) - } - ) - } - - private func dismissSearch() { - isSearchPresented = false - searchQuery = "" - searchSummary = .empty - } - - private func dismissGoToLine() { - isGoToLinePresented = false - goToLineText = "" - } - - private func goToLine(_ requestedLine: Int) { - let line = model.textLineIndex.clampedLine(requestedLine) - goToLineText = String(line) - goToLineUTF16Offset = model.textLineIndex.offset(forLine: line) - goToLineRequestID += 1 - } - - private var shouldShowTextJumpControls: Bool { - model.state == .text - || (model.state == .markdown && model.markdownPresentation.mode == .raw) - } - - #if os(iOS) - private func canQuickLookPreview(_ fileURL: URL) -> Bool { - QLPreviewController.canPreview( - ChatArtifactQuickLookItem(fileURL: fileURL, title: displayName) + get: { snapshot.searchQuery }, + set: { actions.setSearchQuery($0) } ) } - #endif - private var jumpToBottomTitle: String { - if model.textReachedEOF { - return String( - localized: "chat.artifact.jump.end", - defaultValue: "End", - bundle: .module - ) - } - return String( - localized: "chat.artifact.jump.bottom", - defaultValue: "Bottom", - bundle: .module + private var goToLineTextBinding: Binding { + Binding( + get: { snapshot.goToLineText }, + set: { actions.setGoToLineText($0) } ) } diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPagerModelTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPagerModelTests.swift index d13935a6308b..e6566d8f17e6 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPagerModelTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPagerModelTests.swift @@ -9,7 +9,9 @@ struct ChatArtifactViewerPagerModelTests { @Test("keeps page identity stable across gallery snapshot updates") @MainActor func keepsStablePageIdentity() throws { - let defaults = try #require(UserDefaults(suiteName: "cmux.viewer-pager.\(UUID().uuidString)")) + let suiteName = "cmux.viewer-pager.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } let initialOrder = swipeOrder([ item(path: "/logs/build.log", size: 11_000_000), item(path: "/logs/test.log", size: 2_000), @@ -28,13 +30,19 @@ struct ChatArtifactViewerPagerModelTests { ])) #expect(model.pageIdentity(for: "/logs/build.log") == initialIdentity) - #expect(model.pageSnapshots.map(\.path) == ["/logs/new.log", "/logs/build.log"]) + #expect(model.pageSnapshots.map(\.path) == [ + "/logs/new.log", + "/logs/build.log", + "/logs/test.log", + ]) } @Test("projects toolbar state from exactly the selected page") @MainActor func projectsOneToolbarState() throws { - let defaults = try #require(UserDefaults(suiteName: "cmux.viewer-toolbar.\(UUID().uuidString)")) + let suiteName = "cmux.viewer-toolbar.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } let model = ChatArtifactViewerPagerModel( initialPath: "/first.txt", swipeOrder: swipeOrder([ @@ -51,24 +59,73 @@ struct ChatArtifactViewerPagerModelTests { #expect(model.pageSnapshots.count == 3) } - @Test("snapshot refreshes do not replay jump requests") + @Test("streamed snapshots and gallery refreshes do not replay jump requests") @MainActor - func keepsJumpRequestIdentity() throws { - let defaults = try #require(UserDefaults(suiteName: "cmux.viewer-jumps.\(UUID().uuidString)")) + func keepsJumpRequestIdentity() async throws { + let suiteName = "cmux.viewer-jumps.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let path = "/build-\(UUID().uuidString).log" + let firstData = Data("first\n".utf8) + let secondData = Data("second\n".utf8) + let totalSize = Int64(firstData.count + secondData.count) + let stream = ControlledArtifactStream(chunks: [ + ChatArtifactChunk(data: firstData, offset: 0, totalSize: totalSize, eof: false), + ChatArtifactChunk( + data: secondData, + offset: Int64(firstData.count), + totalSize: totalSize, + eof: true + ), + ]) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: totalSize, + modifiedAt: Date(timeIntervalSince1970: 1), + kind: .text, + mimeType: "text/plain" + ) + }, + stream: { _, onChunk in + try await stream.fetch(onChunk: onChunk) + } + ) let model = ChatArtifactViewerPagerModel( - initialPath: "/build.log", - swipeOrder: swipeOrder([item(path: "/build.log", size: 11_000_000)]), + initialPath: path, + swipeOrder: swipeOrder([item(path: path, size: totalSize)]), textPreferences: ChatArtifactTextPreferences(defaults: defaults) ) + let identity = try #require(model.pageIdentity(for: path)) model.requestTop() let requestID = model.toolbarSnapshot.topRequestID + let actions = model.actions( + for: path, + loader: loader, + quickLookCanPreview: { _ in false } + ) + let loadTask = Task { await actions.load() } + await stream.waitUntilFirstChunkDelivered() + + #expect(model.pageIdentity(for: path) == identity) + #expect(model.toolbarSnapshot.textChunks == ["first\n"]) + #expect(model.toolbarSnapshot.topRequestID == requestID) model.update(swipeOrder: swipeOrder([ - item(path: "/build.log", size: 11_500_000), + item(path: path, size: totalSize), item(path: "/new.log", size: 100), ])) #expect(requestID == 1) + #expect(model.pageIdentity(for: path) == identity) + #expect(model.toolbarSnapshot.topRequestID == requestID) + + await stream.resume() + await loadTask.value + #expect(model.toolbarSnapshot.textChunks == ["first\n", "second\n"]) #expect(model.toolbarSnapshot.topRequestID == requestID) } From 61889767f5889fa38d54fb545126700c73dbc06b Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 10:33:46 -0700 Subject: [PATCH 57/92] test(ios): cover bounded artifact stream updates --- .../ChatArtifactViewerModelTests.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift index 864c3c3cc409..d7bab973243c 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift @@ -6,6 +6,30 @@ import Testing @Suite struct ChatArtifactViewerModelTests { + @Test("large transport chunks are published in bounded UI batches") + @MainActor + func batchesLargeTransportChunks() async { + let line = "0123456789abcdef 漢🙂\n" + let text = String(repeating: line, count: 20_000) + let data = Data(text.utf8) + let loader = Self.loader(totalSize: Int64(data.count)) { _, onChunk in + try await onChunk(ChatArtifactChunk( + data: data, + offset: 0, + totalSize: Int64(data.count), + eof: true + )) + } + let model = ChatArtifactViewerModel() + + await model.load(path: "/tmp/large.log", loader: loader) + + #expect(model.textChunks.count > 1) + #expect(model.textChunks.allSatisfy { $0.utf8.count <= 262_160 }) + #expect(model.renderedText == text) + #expect(model.textReachedEOF) + } + @Test @MainActor func exposesFirstChunkBeforeEOFAndCompletesProgress() async throws { From 5a2b1247fa757698e3e72bba491958049bda5f85 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 10:51:13 -0700 Subject: [PATCH 58/92] test(ios): cover artifact presentation ownership regressions --- ...hatArtifactPresentationBoundaryTests.swift | 108 ++++++++++++++++++ .../ChatArtifactViewerPagerModelTests.swift | 58 ++++++++++ ...inalArtifactChildCountFormatterTests.swift | 26 +++++ .../GhosttySurfaceTapDispositionTests.swift | 12 ++ 4 files changed, 204 insertions(+) create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPresentationBoundaryTests.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChildCountFormatterTests.swift create mode 100644 Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceTapDispositionTests.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPresentationBoundaryTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPresentationBoundaryTests.swift new file mode 100644 index 000000000000..498b340915f8 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPresentationBoundaryTests.swift @@ -0,0 +1,108 @@ +import CmuxAgentChat +import Foundation +import Testing + +#if canImport(UIKit) +import UIKit +#endif + +@testable import CmuxAgentChatUI + +@Suite("Artifact presentation boundaries") +struct ChatArtifactPresentationBoundaryTests { + @Test("rapid completed transitions preserve one unique selected path") + func rapidPageTransitions() { + let allPaths = ["/notes.md", "/data.csv", "/build.log"] + var state = ChatArtifactPageControllerState( + paths: allPaths, + selectedPath: allPaths[0] + ) + + for index in 0..<60 { + let next = allPaths[index.isMultiple(of: 2) ? 1 : 0] + let didComplete = state.completeTransition(to: next) + #expect(didComplete) + state.update(paths: allPaths + [allPaths[1]], selectedPath: next) + #expect(state.paths == allPaths) + #expect(state.selectedPath == next) + } + + #expect(state.path(before: "/data.csv") == "/notes.md") + #expect(state.path(after: "/data.csv") == "/build.log") + let didCompleteUnknown = state.completeTransition(to: "/unknown") + #expect(!didCompleteUnknown) + } + + @Test("large skipped-highlight updates need no full TextKit snapshot") + func textUpdatePlanning() { + let streaming = ChatArtifactTextUpdatePlan( + reachedEOF: false, + highlightDecision: .skippedForSize, + searchQuery: "" + ) + let finished = ChatArtifactTextUpdatePlan( + reachedEOF: true, + highlightDecision: .skippedForSize, + searchQuery: "" + ) + let searched = ChatArtifactTextUpdatePlan( + reachedEOF: false, + highlightDecision: .skippedForSize, + searchQuery: "error" + ) + + #expect(!streaming.requiresFullTextSnapshot) + #expect(!finished.requiresFullTextSnapshot) + #expect(searched.requiresFullTextSnapshot) + } + + @Test("accessibility projection remains a single bounded excerpt") + func boundedAccessibilityContent() { + var content = ChatArtifactTextAccessibilityContent() + content.append(String(repeating: "x", count: 100_000)) + content.append("tail") + + #expect(content.excerpt.count == ChatArtifactTextAccessibilityContent.maximumCharacterCount) + #expect(content.isTruncated) + #expect(!content.excerpt.contains("tail")) + } + + #if canImport(UIKit) + @Test("large text remains interactive behind one bounded accessibility element") + @MainActor + func textContainerInteractionAndAccessibility() { + let container = ChatArtifactTextContainerView() + + #expect(container.isUserInteractionEnabled) + #expect(container.textView.isUserInteractionEnabled) + #expect(container.textView.isScrollEnabled) + #expect(container.isAccessibilityElement) + #expect(!container.textView.isAccessibilityElement) + #expect(container.textView.accessibilityElementsHidden) + } + #endif + + @Test("child routes retain the live loader and exact descendant path") + func folderChildLoaderRouting() async throws { + let expected = ChatArtifactDirectoryListing(entries: []) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + supportsDirectoryBrowsing: true, + scope: .terminal(workspaceID: "workspace", surfaceID: "surface"), + list: { path in + #expect(path == "/project/docs") + return expected + } + ) + let route = ChatArtifactFolderRoute( + parentPath: "/project", + childName: "docs", + scope: .terminal, + loader: loader + ) + + #expect(route.path == "/project/docs") + #expect(route.loader.scope == loader.scope) + #expect(try await route.loader.list(path: route.path) == expected) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPagerModelTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPagerModelTests.swift index e6566d8f17e6..0f01943b8dde 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPagerModelTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPagerModelTests.swift @@ -59,6 +59,64 @@ struct ChatArtifactViewerPagerModelTests { #expect(model.pageSnapshots.count == 3) } + @Test("projects the highlighting pill only from the selected page") + @MainActor + func projectsSelectedPagePill() async throws { + let suiteName = "cmux.viewer-pill.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let logPath = "/logs/build.log" + let csvPath = "/data.csv" + let logSize = ChatArtifactSyntaxHighlightPolicy.maxHighlightBytes + 1 + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { path in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: path == logPath ? logSize : 1, + modifiedAt: Date(timeIntervalSince1970: 1), + kind: .text, + mimeType: "text/plain" + ) + }, + stream: { path, onChunk in + let totalSize = path == logPath ? logSize : 1 + try await onChunk(ChatArtifactChunk( + data: Data("x".utf8), + offset: 0, + totalSize: totalSize, + eof: true + )) + } + ) + let model = ChatArtifactViewerPagerModel( + initialPath: logPath, + swipeOrder: swipeOrder([ + item(path: logPath, size: logSize), + item(path: csvPath, size: 1), + ]), + textPreferences: ChatArtifactTextPreferences(defaults: defaults) + ) + + await model.actions( + for: logPath, + loader: loader, + quickLookCanPreview: { _ in false } + ).load() + await model.actions( + for: csvPath, + loader: loader, + quickLookCanPreview: { _ in false } + ).load() + + #expect(model.toolbarSnapshot.path == logPath) + #expect(model.toolbarSnapshot.showsHighlightingStatusPill) + model.select(path: csvPath) + #expect(model.toolbarSnapshot.path == csvPath) + #expect(!model.toolbarSnapshot.showsHighlightingStatusPill) + } + @Test("streamed snapshots and gallery refreshes do not replay jump requests") @MainActor func keepsJumpRequestIdentity() async throws { diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChildCountFormatterTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChildCountFormatterTests.swift new file mode 100644 index 000000000000..5f4ffc895af1 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChildCountFormatterTests.swift @@ -0,0 +1,26 @@ +import Foundation +import Testing + +@testable import CmuxMobileShellUI + +@Suite("Terminal artifact child-count formatting") +struct TerminalArtifactChildCountFormatterTests { + @Test("resolves English inflection markup into singular and plural text") + func englishInflection() { + let formatter = TerminalArtifactChildCountFormatter(locale: Locale(identifier: "en")) + + #expect(formatter.string(count: 1, isCapped: false) == "1 item") + #expect(formatter.string(count: 11, isCapped: false) == "11 items") + #expect(!formatter.string(count: 11, isCapped: false).contains("inflect")) + #expect(formatter.string(count: 500, isCapped: true) == "500+ items") + } + + @Test("uses the Japanese localized count without English inflection markup") + func japaneseCount() { + let formatter = TerminalArtifactChildCountFormatter(locale: Locale(identifier: "ja")) + + #expect(formatter.string(count: 1, isCapped: false) == "1項目") + #expect(formatter.string(count: 11, isCapped: false) == "11項目") + #expect(formatter.string(count: 500, isCapped: true) == "500項目以上") + } +} diff --git a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceTapDispositionTests.swift b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceTapDispositionTests.swift new file mode 100644 index 000000000000..9086f5091b3a --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceTapDispositionTests.swift @@ -0,0 +1,12 @@ +import Testing + +@testable import CmuxMobileTerminal + +@Suite("Terminal surface tap disposition") +struct GhosttySurfaceTapDispositionTests { + @Test("only terminal taps claim input focus") + func inputFocus() { + #expect(GhosttySurfaceTapDisposition.focusTerminal.shouldFocusTerminal) + #expect(!GhosttySurfaceTapDisposition.openedArtifact.shouldFocusTerminal) + } +} From 3c1414a10b41ad8fde69292dd40927032539f6d8 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 10:55:00 -0700 Subject: [PATCH 59/92] test(ios): cover pager neighbor reload after selection --- ...hatArtifactPresentationBoundaryTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPresentationBoundaryTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPresentationBoundaryTests.swift index 498b340915f8..e94f51a3744b 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPresentationBoundaryTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPresentationBoundaryTests.swift @@ -33,6 +33,28 @@ struct ChatArtifactPresentationBoundaryTests { #expect(!didCompleteUnknown) } + @Test("completed selection reports an expanded neighbor topology exactly once") + func pageTopologyReload() { + let notes = "/notes.md" + let data = "/data.csv" + let log = "/build.log" + var state = ChatArtifactPageControllerState( + paths: [notes, data], + selectedPath: notes + ) + + let didComplete = state.completeTransition(to: data) + #expect(didComplete) + let didExpandPaths = state.update(paths: [notes, data, log], selectedPath: data) + #expect(didExpandPaths) + #expect(state.path(after: data) == log) + let didChangeDeduplicatedPaths = state.update( + paths: [notes, data, log, data], + selectedPath: data + ) + #expect(!didChangeDeduplicatedPaths) + } + @Test("large skipped-highlight updates need no full TextKit snapshot") func textUpdatePlanning() { let streaming = ChatArtifactTextUpdatePlan( From 04cdfc17be1a47fc977ddc2b23ce871de130211d Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 11:01:19 -0700 Subject: [PATCH 60/92] fix(ios): isolate artifact paging and large text presentation --- .../ChatArtifactPageControllerState.swift | 64 ++++++++ .../ChatArtifactPageViewController.swift | 47 ++++++ ...rtifactPageViewControllerCoordinator.swift | 147 ++++++++++++++++++ ...ChatArtifactTextAccessibilityContent.swift | 23 +++ .../ChatArtifactTextContainerView.swift | 21 +++ .../ChatArtifactTextStreamBatcher.swift | 39 +++++ .../ChatArtifactTextUpdatePlan.swift | 20 +++ .../Artifacts/ChatArtifactTextView.swift | 20 ++- .../ChatArtifactTextViewCoordinator.swift | 20 ++- .../ChatArtifactViewerHostedPage.swift | 37 +++++ .../Artifacts/ChatArtifactViewerModel.swift | 41 +++-- .../ChatArtifactViewerPageModel.swift | 25 +++ .../ChatArtifactViewerPageSnapshot.swift | 6 + .../Artifacts/ChatArtifactViewerPager.swift | 34 ++-- .../ChatArtifactViewerPagerModel.swift | 25 +-- .../ChatArtifactViewerRouteView.swift | 2 +- .../Artifacts/UTF8ChunkDecoder.swift | 8 +- .../Resources/Localizable.xcstrings | 7 + 18 files changed, 539 insertions(+), 47 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageControllerState.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageViewController.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageViewControllerCoordinator.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAccessibilityContent.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextStreamBatcher.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextUpdatePlan.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerHostedPage.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageControllerState.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageControllerState.swift new file mode 100644 index 000000000000..11c0d7944e21 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageControllerState.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Pure ordering and completed-selection state for the UIKit artifact pager. +struct ChatArtifactPageControllerState: Equatable, Sendable { + private(set) var paths: [String] + private(set) var selectedPath: String + + init(paths: [String], selectedPath: String) { + self.paths = Self.unique(paths) + self.selectedPath = selectedPath + normalizeSelection() + } + + @discardableResult + mutating func update(paths: [String], selectedPath: String) -> Bool { + let uniquePaths = Self.unique(paths) + let didChangePaths = self.paths != uniquePaths + self.paths = uniquePaths + self.selectedPath = selectedPath + normalizeSelection() + return didChangePaths + } + + mutating func completeTransition(to path: String) -> Bool { + guard paths.contains(path), path != selectedPath else { return false } + selectedPath = path + return true + } + + func path(before path: String) -> String? { + guard let index = paths.firstIndex(of: path), index > paths.startIndex else { + return nil + } + return paths[paths.index(before: index)] + } + + func path(after path: String) -> String? { + guard let index = paths.firstIndex(of: path), + paths.index(after: index) < paths.endIndex else { + return nil + } + return paths[paths.index(after: index)] + } + + func isForwardTransition(from source: String?, to destination: String) -> Bool { + guard let source, + let sourceIndex = paths.firstIndex(of: source), + let destinationIndex = paths.firstIndex(of: destination) else { + return true + } + return destinationIndex >= sourceIndex + } + + private mutating func normalizeSelection() { + if !paths.contains(selectedPath), let firstPath = paths.first { + selectedPath = firstPath + } + } + + private static func unique(_ paths: [String]) -> [String] { + var seen: Set = [] + return paths.filter { seen.insert($0).inserted } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageViewController.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageViewController.swift new file mode 100644 index 000000000000..be3faa021601 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageViewController.swift @@ -0,0 +1,47 @@ +#if os(iOS) +import SwiftUI +import UIKit + +/// Bridges path-stable artifact hosts into UIKit's transactional page controller. +struct ChatArtifactPageViewController: UIViewControllerRepresentable { + let pages: [ChatArtifactViewerHostedPage] + @Binding var selectedPath: String + let isPagingEnabled: Bool + + func makeCoordinator() -> ChatArtifactPageViewControllerCoordinator { + ChatArtifactPageViewControllerCoordinator() + } + + func makeUIViewController(context: Context) -> UIPageViewController { + let controller = UIPageViewController( + transitionStyle: .scroll, + navigationOrientation: .horizontal + ) + controller.view.backgroundColor = .systemBackground + controller.view.isOpaque = true + controller.view.clipsToBounds = true + controller.dataSource = context.coordinator + controller.delegate = context.coordinator + context.coordinator.attach(controller) + context.coordinator.update( + pages: pages, + selectedPath: selectedPath, + selection: $selectedPath, + isPagingEnabled: isPagingEnabled + ) + return controller + } + + func updateUIViewController( + _ controller: UIPageViewController, + context: Context + ) { + context.coordinator.update( + pages: pages, + selectedPath: selectedPath, + selection: $selectedPath, + isPagingEnabled: isPagingEnabled + ) + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageViewControllerCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageViewControllerCoordinator.swift new file mode 100644 index 000000000000..8819498429cf --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageViewControllerCoordinator.swift @@ -0,0 +1,147 @@ +#if os(iOS) +import SwiftUI +import UIKit + +/// Owns one clipped hosting controller per path and commits only completed page transitions. +@MainActor +final class ChatArtifactPageViewControllerCoordinator: NSObject, + UIPageViewControllerDataSource, + UIPageViewControllerDelegate +{ + private weak var pageController: UIPageViewController? + private var state = ChatArtifactPageControllerState(paths: [], selectedPath: "") + private var pagesByPath: [String: ChatArtifactViewerHostedPage] = [:] + private var hostsByPath: [String: UIHostingController] = [:] + private var selection: Binding? + private var isTransitioning = false + private var needsDataSourceReload = false + + func attach(_ controller: UIPageViewController) { + pageController = controller + } + + func update( + pages: [ChatArtifactViewerHostedPage], + selectedPath: String, + selection: Binding, + isPagingEnabled: Bool + ) { + self.selection = selection + pagesByPath = Dictionary(uniqueKeysWithValues: pages.map { ($0.path, $0) }) + for page in pages { + hostsByPath[page.path]?.rootView = page + } + needsDataSourceReload = state.update( + paths: pages.map(\.path), + selectedPath: selectedPath + ) || needsDataSourceReload + configurePaging(isEnabled: isPagingEnabled) + guard !isTransitioning else { return } + removeUnusedHosts() + reloadDataSourceIfNeeded() + synchronizeDisplayedPage() + } + + func pageViewController( + _ pageViewController: UIPageViewController, + viewControllerBefore viewController: UIViewController + ) -> UIViewController? { + guard let path = path(for: viewController), + let previousPath = state.path(before: path) else { + return nil + } + return host(for: previousPath) + } + + func pageViewController( + _ pageViewController: UIPageViewController, + viewControllerAfter viewController: UIViewController + ) -> UIViewController? { + guard let path = path(for: viewController), + let nextPath = state.path(after: path) else { + return nil + } + return host(for: nextPath) + } + + func pageViewController( + _ pageViewController: UIPageViewController, + willTransitionTo pendingViewControllers: [UIViewController] + ) { + isTransitioning = true + } + + func pageViewController( + _ pageViewController: UIPageViewController, + didFinishAnimating finished: Bool, + previousViewControllers: [UIViewController], + transitionCompleted completed: Bool + ) { + isTransitioning = false + if completed, + let displayed = pageViewController.viewControllers?.first, + let path = path(for: displayed), + state.completeTransition(to: path) { + selection?.wrappedValue = path + } + removeUnusedHosts() + reloadDataSourceIfNeeded() + synchronizeDisplayedPage() + } + + private func synchronizeDisplayedPage() { + guard let pageController, + let destination = host(for: state.selectedPath) else { + return + } + let current = pageController.viewControllers?.first + guard current !== destination else { return } + let direction: UIPageViewController.NavigationDirection = state.isForwardTransition( + from: current.flatMap(path(for:)), + to: state.selectedPath + ) ? .forward : .reverse + pageController.setViewControllers( + [destination], + direction: direction, + animated: false + ) + } + + private func host(for path: String) -> UIHostingController? { + if let host = hostsByPath[path] { + return host + } + guard let page = pagesByPath[path] else { return nil } + let host = UIHostingController(rootView: page) + host.view.backgroundColor = .systemBackground + host.view.isOpaque = true + host.view.clipsToBounds = true + hostsByPath[path] = host + return host + } + + private func path(for viewController: UIViewController) -> String? { + hostsByPath.first { $0.value === viewController }?.key + } + + private func removeUnusedHosts() { + let retainedPaths = Set(state.paths) + hostsByPath = hostsByPath.filter { retainedPaths.contains($0.key) } + } + + private func reloadDataSourceIfNeeded() { + guard needsDataSourceReload, let pageController else { return } + pageController.dataSource = nil + pageController.dataSource = self + needsDataSourceReload = false + } + + private func configurePaging(isEnabled: Bool) { + guard let pageController else { return } + for case let scrollView as UIScrollView in pageController.view.subviews { + scrollView.isScrollEnabled = isEnabled + scrollView.clipsToBounds = true + } + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAccessibilityContent.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAccessibilityContent.swift new file mode 100644 index 000000000000..d17eee20e59e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAccessibilityContent.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Retains a bounded prefix for the artifact text view's single accessibility element. +struct ChatArtifactTextAccessibilityContent: Equatable, Sendable { + static let maximumCharacterCount = 2_048 + + private(set) var excerpt = "" + private(set) var isTruncated = false + + mutating func append(_ text: String) { + guard !text.isEmpty else { return } + let remaining = Self.maximumCharacterCount - excerpt.count + guard remaining > 0 else { + isTruncated = true + return + } + let prefix = text.prefix(remaining) + excerpt.append(contentsOf: prefix) + if prefix.endIndex != text.endIndex { + isTruncated = true + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift index ce8c3dc47699..16cdd22aa87b 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift @@ -16,6 +16,8 @@ final class ChatArtifactTextContainerView: UIView { super.init(frame: frame) backgroundColor = .clear + isAccessibilityElement = true + accessibilityTraits = [.staticText, .allowsDirectInteraction] textView.layoutManager.allowsNonContiguousLayout = true textView.isEditable = false textView.isSelectable = true @@ -28,6 +30,9 @@ final class ChatArtifactTextContainerView: UIView { ) textView.textContainerInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) textView.textContainer.lineFragmentPadding = 0 + textView.isAccessibilityElement = false + textView.accessibilityElementsHidden = true + gutterView.accessibilityElementsHidden = true gutterView.textView = textView gutterView.translatesAutoresizingMaskIntoConstraints = false @@ -46,6 +51,22 @@ final class ChatArtifactTextContainerView: UIView { ]) } + /// Exposes one bounded element so accessibility never enumerates TextKit line fragments. + func updateAccessibility( + documentID: String, + content: ChatArtifactTextAccessibilityContent + ) { + accessibilityLabel = URL(fileURLWithPath: documentID).lastPathComponent + accessibilityValue = content.excerpt + accessibilityHint = content.isTruncated + ? String( + localized: "chat.artifact.accessibility.large_text_truncated", + defaultValue: "Only the beginning of this large document is exposed to assistive technologies.", + bundle: .module + ) + : nil + } + required init?(coder: NSCoder) { nil } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextStreamBatcher.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextStreamBatcher.swift new file mode 100644 index 000000000000..dce18c1e1d39 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextStreamBatcher.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Splits decoded artifact text into bounded main-actor presentation updates. +struct ChatArtifactTextStreamBatcher: Sendable { + static let maximumBatchBytes = 256 * 1_024 + + let maximumBatchBytes: Int + + init(maximumBatchBytes: Int = Self.maximumBatchBytes) { + self.maximumBatchBytes = max(1, maximumBatchBytes) + } + + func batches(for text: String) -> [String] { + guard text.utf8.count > maximumBatchBytes else { + return text.isEmpty ? [] : [text] + } + + var result: [String] = [] + var start = text.startIndex + var remainingBytes = text.utf8.count + while start < text.endIndex { + guard remainingBytes > maximumBatchBytes else { + result.append(String(text[start...])) + break + } + + let utf8Start = start.samePosition(in: text.utf8)! + var utf8End = text.utf8.index(utf8Start, offsetBy: maximumBatchBytes) + while String.Index(utf8End, within: text) == nil { + utf8End = text.utf8.index(before: utf8End) + } + let end = String.Index(utf8End, within: text)! + result.append(String(text[start.. Void)? + private(set) var accessibilityContent = ChatArtifactTextAccessibilityContent() private weak var containerView: ChatArtifactTextContainerView? private let syntaxHighlighter = ChatArtifactSyntaxHighlighter() private var highlightTask: Task? @@ -153,15 +154,24 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { publishSearchSummary(.empty) } + func resetAccessibilityContent() { + accessibilityContent = ChatArtifactTextAccessibilityContent() + } + + func appendAccessibilityContent(_ text: String) { + accessibilityContent.append(text) + } + func updateHighlighting( in textView: UITextView, documentID: String, - text: String, + text: String?, reachedEOF: Bool, decision: ChatArtifactHighlightDecision, theme: ChatArtifactHighlightTheme ) { - guard reachedEOF, + guard let text, + reachedEOF, case .highlight(let language) = decision, !text.isEmpty else { highlightTask?.cancel() @@ -221,7 +231,8 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { func updateSearch( in textView: UITextView, documentID: String, - text: String, + text: String?, + textLength: Int, query: String, reachedEOF: Bool, previousRequestID: Int, @@ -236,7 +247,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { clearSearchHighlight(in: textView) searchModel = ChatArtifactSearchModel() searchedDocumentID = documentID - searchedTextLength = text.utf16.count + searchedTextLength = textLength pendingSearchDocumentID = nil pendingSearchTextLength = 0 pendingSearchQuery = "" @@ -246,6 +257,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { return } + guard let text else { return } let textLength = text.utf16.count let hasCurrentResults = searchedDocumentID == documentID && searchedTextLength == textLength diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerHostedPage.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerHostedPage.swift new file mode 100644 index 000000000000..57071f51751c --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerHostedPage.swift @@ -0,0 +1,37 @@ +#if os(iOS) +import QuickLook +import SwiftUI + +/// Gives one path one stable SwiftUI hosting root inside the UIKit pager. +struct ChatArtifactViewerHostedPage: View { + let model: ChatArtifactViewerPageModel + let scope: ChatArtifactViewerScope + let loader: ChatArtifactLoader + let onImageMinimumZoomChanged: (String, Bool) -> Void + let onDone: () -> Void + + var path: String { model.path } + + var body: some View { + let snapshot = model.snapshot + ChatArtifactViewerRouteView( + snapshot: snapshot, + scope: scope, + actions: model.actions( + loader: loader, + quickLookCanPreview: { fileURL in + QLPreviewController.canPreview(ChatArtifactQuickLookItem( + fileURL: fileURL, + title: snapshot.displayName + )) + } + ), + onImageMinimumZoomChanged: { + onImageMinimumZoomChanged(snapshot.path, $0) + }, + onDone: onDone + ) + .clipped() + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift index 7ff363e190e4..4d1b2ab2786e 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift @@ -191,14 +191,32 @@ final class ChatArtifactViewerModel { size: stat.size ) { chunk in try Task.checkCancellation() - let decoded = try await decoder.decode(chunk.data, eof: chunk.eof) + let decodedBatches = try await decoder.decodeBatches(chunk.data, eof: chunk.eof) try Task.checkCancellation() - await self.receiveText( - decoded, - chunk: chunk, - path: path, - isMarkdown: isMarkdown - ) + if decodedBatches.isEmpty { + await self.receiveText( + "", + chunk: chunk, + path: path, + isMarkdown: isMarkdown, + isFinalBatch: true + ) + } else { + for (index, decoded) in decodedBatches.enumerated() { + try Task.checkCancellation() + let isFinalBatch = index == decodedBatches.index(before: decodedBatches.endIndex) + await self.receiveText( + decoded, + chunk: chunk, + path: path, + isMarkdown: isMarkdown, + isFinalBatch: isFinalBatch + ) + if !isFinalBatch { + await Task.yield() + } + } + } } } @@ -206,15 +224,18 @@ final class ChatArtifactViewerModel { _ text: String, chunk: ChatArtifactChunk, path: String, - isMarkdown: Bool + isMarkdown: Bool, + isFinalBatch: Bool ) { guard path == activePath else { return } if !text.isEmpty { textChunks.append(text) textLineIndex.append(text) } - updateProgress(for: chunk) - textReachedEOF = chunk.eof + if isFinalBatch { + updateProgress(for: chunk) + textReachedEOF = chunk.eof + } state = isMarkdown ? .markdown : .text } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift index 42a936d6c122..12414285efd5 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift @@ -90,6 +90,31 @@ final class ChatArtifactViewerPageModel { await viewerModel.cleanup() } + func actions( + loader: ChatArtifactLoader, + quickLookCanPreview: @escaping @MainActor (URL) -> Bool + ) -> ChatArtifactViewerPageActions { + ChatArtifactViewerPageActions( + load: { + await self.load( + loader: loader, + quickLookCanPreview: quickLookCanPreview + ) + }, + cleanup: { await self.cleanup() }, + retry: { self.retry() }, + setSearchQuery: { self.setSearchQuery($0) }, + setSearchSummary: { self.setSearchSummary($0) }, + selectPreviousSearchResult: { self.selectPreviousSearchResult() }, + selectNextSearchResult: { self.selectNextSearchResult() }, + dismissSearch: { self.dismissSearch() }, + setGoToLineText: { self.setGoToLineText($0) }, + goToLine: { self.goToLine($0) }, + dismissGoToLine: { self.dismissGoToLine() }, + setFontSize: { self.setFontSize($0) } + ) + } + func retry() { retryGeneration += 1 } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageSnapshot.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageSnapshot.swift index cd582b2aff0b..1227b4b4bad8 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageSnapshot.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageSnapshot.swift @@ -51,4 +51,10 @@ struct ChatArtifactViewerPageSnapshot: Identifiable, Equatable { || shouldShowTextJumpControls || (state == .markdown && markdownPresentation.isRenderedAvailable) } + + var showsHighlightingStatusPill: Bool { + shouldShowTextJumpControls + && textHighlightDecision.showsHighlightingOffPill + && totalBytes != nil + } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift index 13c5fd8bf580..62b8333b218d 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift @@ -86,15 +86,11 @@ struct ChatArtifactViewerPager: View { private var pagerContent: some View { #if os(iOS) if model.usesPaging { - TabView(selection: selectionBinding) { - ForEach(model.pageSnapshots) { snapshot in - viewer(snapshot: snapshot) - .id(snapshot.path) - .tag(snapshot.path) - } - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .scrollDisabled(zoomedPath != nil) + ChatArtifactPageViewController( + pages: model.pageModels.map(hostedPage), + selectedPath: selectionBinding, + isPagingEnabled: zoomedPath == nil + ) } else { viewer(snapshot: model.toolbarSnapshot) .id(model.toolbarSnapshot.path) @@ -105,6 +101,26 @@ struct ChatArtifactViewerPager: View { #endif } + #if os(iOS) + private func hostedPage(model: ChatArtifactViewerPageModel) -> ChatArtifactViewerHostedPage { + ChatArtifactViewerHostedPage( + model: model, + scope: scope, + loader: loader, + onImageMinimumZoomChanged: { path, isAtMinimum in + if isAtMinimum { + if zoomedPath == path { + zoomedPath = nil + } + } else { + zoomedPath = path + } + }, + onDone: onDone + ) + } + #endif + private func viewer(snapshot: ChatArtifactViewerPageSnapshot) -> some View { ChatArtifactViewerRouteView( snapshot: snapshot, diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPagerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPagerModel.swift index 8d55b9960019..7c51f876960d 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPagerModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPagerModel.swift @@ -34,6 +34,10 @@ final class ChatArtifactViewerPagerModel { pagePaths.compactMap { pagesByPath[$0]?.snapshot } } + var pageModels: [ChatArtifactViewerPageModel] { + pagePaths.compactMap { pagesByPath[$0] } + } + var toolbarSnapshot: ChatArtifactViewerPageSnapshot { selectedPageModel.snapshot } @@ -73,24 +77,9 @@ final class ChatArtifactViewerPagerModel { let page = path == selectedPath ? selectedPageModel : pagesByPath[path]! - return ChatArtifactViewerPageActions( - load: { - await page.load( - loader: loader, - quickLookCanPreview: quickLookCanPreview - ) - }, - cleanup: { await page.cleanup() }, - retry: { page.retry() }, - setSearchQuery: { page.setSearchQuery($0) }, - setSearchSummary: { page.setSearchSummary($0) }, - selectPreviousSearchResult: { page.selectPreviousSearchResult() }, - selectNextSearchResult: { page.selectNextSearchResult() }, - dismissSearch: { page.dismissSearch() }, - setGoToLineText: { page.setGoToLineText($0) }, - goToLine: { page.goToLine($0) }, - dismissGoToLine: { page.dismissGoToLine() }, - setFontSize: { page.setFontSize($0) } + return page.actions( + loader: loader, + quickLookCanPreview: quickLookCanPreview ) } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index f878ced87ac1..85b75e2f4676 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -286,7 +286,7 @@ struct ChatArtifactViewerRouteView: View { @ViewBuilder private var highlightingStatusPill: some View { - if snapshot.textHighlightDecision.showsHighlightingOffPill, + if snapshot.showsHighlightingStatusPill, let totalBytes = snapshot.totalBytes { HStack { Spacer(minLength: 16) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkDecoder.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkDecoder.swift index e9e6672d77da..5af299a61894 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkDecoder.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkDecoder.swift @@ -3,9 +3,11 @@ import Foundation /// Owns one UTF-8 assembler while an artifact stream crosses actor boundaries. actor UTF8ChunkDecoder { private var assembler = UTF8ChunkAssembler() + private let batcher = ChatArtifactTextStreamBatcher() - /// Decodes the next raw chunk without moving byte scanning onto the main actor. - func decode(_ data: Data, eof: Bool) throws -> String { - try assembler.append(data, eof: eof) + /// Decodes and batches the next raw chunk without moving either operation onto the main actor. + func decodeBatches(_ data: Data, eof: Bool) throws -> [String] { + let decoded = try assembler.append(data, eof: eof) + return batcher.batches(for: decoded) } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index be7b6790977d..38ecf67ee9d2 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -32,6 +32,13 @@ "ja" : { "stringUnit" : { "state" : "translated", "value" : "操作を完了できませんでした" } } } }, + "chat.artifact.accessibility.large_text_truncated" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Only the beginning of this large document is exposed to assistive technologies." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "この大きな書類では、先頭部分のみが支援技術に公開されます。" } } + } + }, "chat.artifact.actions.title" : { "extractionState" : "manual", "localizations" : { From 74c7bb6bba4e43fa79fe5956ee32e6651c7f1c13 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 11:01:29 -0700 Subject: [PATCH 61/92] fix(ios): restore artifact taps and folder routing --- .../Artifacts/ChatArtifactFolderRoute.swift | 19 +++++++++++ .../Artifacts/ChatArtifactFolderView.swift | 15 +++++++-- .../GhosttySurfaceCoordinator+Artifacts.swift | 32 ++++++++++--------- .../TerminalArtifactChildCountFormatter.swift | 30 +++++++++++++++++ .../TerminalArtifactGalleryItemView.swift | 14 ++------ .../GhosttySurfaceTapDisposition.swift | 13 ++++++++ .../GhosttySurfaceView.swift | 31 +++++++++++------- .../GhosttySurfaceViewDelegate.swift | 16 ++++++++-- 8 files changed, 128 insertions(+), 42 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderRoute.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChildCountFormatter.swift create mode 100644 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceTapDisposition.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderRoute.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderRoute.swift new file mode 100644 index 000000000000..a190282b3872 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderRoute.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Carries the original folder loader and scope into one lazily pushed child route. +struct ChatArtifactFolderRoute: Sendable { + let path: String + let scope: ChatArtifactViewerScope + let loader: ChatArtifactLoader + + init( + parentPath: String, + childName: String, + scope: ChatArtifactViewerScope, + loader: ChatArtifactLoader + ) { + path = (parentPath as NSString).appendingPathComponent(childName) + self.scope = scope + self.loader = loader + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift index 62416dd1a7a0..6bc7911f0526 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift @@ -42,11 +42,13 @@ struct ChatArtifactFolderView: View { Section { ForEach(listing.entries) { entry in NavigationLink { + let route = childRoute(for: entry) ChatArtifactViewerDestination( - path: childPath(named: entry.name), - scope: scope, + path: route.path, + scope: route.scope, onDone: onDone ) + .environment(\.chatArtifactLoader, route.loader) } label: { rowLabel(entry) } @@ -126,6 +128,15 @@ struct ChatArtifactFolderView: View { (path as NSString).appendingPathComponent(name) } + private func childRoute(for entry: ChatArtifactDirectoryEntry) -> ChatArtifactFolderRoute { + ChatArtifactFolderRoute( + parentPath: path, + childName: entry.name, + scope: scope, + loader: loader + ) + } + private var parentPath: String { guard path != "/" else { return "/" } let parent = (path as NSString).deletingLastPathComponent diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceCoordinator+Artifacts.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceCoordinator+Artifacts.swift index 707887975704..a90eb6497607 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceCoordinator+Artifacts.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceCoordinator+Artifacts.swift @@ -241,25 +241,27 @@ extension GhosttySurfaceRepresentable.Coordinator { } } - func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didTapAtCol col: Int, row: Int) { + func ghosttySurfaceView( + _ surfaceView: GhosttySurfaceView, + didTapAtCol col: Int, + row: Int + ) async -> GhosttySurfaceTapDisposition { // Forward to the Mac's real surface as a left click; libghostty // reports it to a TUI with mouse mode, or no-ops on a normal screen. - Task { @MainActor [weak self] in - guard let self else { return } - if self.artifactFilesEnabled, - let snapshot = await surfaceView.visibleTextForArtifactHitTesting() { - if let path = TerminalArtifactTapHitTester().path( - in: snapshot.text, - col: col, - row: row, - columns: snapshot.columns - ) { - self.onArtifactPathTapped(path) - return - } + if artifactFilesEnabled, + let snapshot = await surfaceView.visibleTextForArtifactHitTesting() { + if let path = TerminalArtifactTapHitTester().path( + in: snapshot.text, + col: col, + row: row, + columns: snapshot.columns + ) { + onArtifactPathTapped(path) + return .openedArtifact } - await self.store?.clickTerminal(surfaceID: self.surfaceID, col: col, row: row) } + await store?.clickTerminal(surfaceID: surfaceID, col: col, row: row) + return .focusTerminal } func ghosttySurfaceView( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChildCountFormatter.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChildCountFormatter.swift new file mode 100644 index 000000000000..340821834e07 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChildCountFormatter.swift @@ -0,0 +1,30 @@ +#if os(iOS) +import Foundation + +/// Resolves localized folder-child inflection markup before gallery rendering. +struct TerminalArtifactChildCountFormatter: Sendable { + private let locale: Locale + + init(locale: Locale = .autoupdatingCurrent) { + self.locale = locale + } + + func string(count: Int, isCapped: Bool) -> String { + if isCapped { + return String( + localized: "terminal.artifact.gallery.child_count_capped", + defaultValue: "\(count)+ items", + bundle: .module, + locale: locale + ) + } + let attributed = AttributedString( + localized: "terminal.artifact.gallery.child_count", + defaultValue: "^[\(count) item](inflect: true)", + bundle: .module, + locale: locale + ) + return String(attributed.characters) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift index 5e57980588dc..a0b98c535bf1 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift @@ -260,17 +260,9 @@ struct TerminalArtifactGalleryItemView: View { } private func childCountText(_ childCount: Int) -> String { - if artifact.childCountIsCapped { - return String( - localized: "terminal.artifact.gallery.child_count_capped", - defaultValue: "\(childCount)+ items", - bundle: .module - ) - } - return String( - localized: "terminal.artifact.gallery.child_count", - defaultValue: "^[\(childCount) item](inflect: true)", - bundle: .module + TerminalArtifactChildCountFormatter().string( + count: childCount, + isCapped: artifact.childCountIsCapped ) } diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceTapDisposition.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceTapDisposition.swift new file mode 100644 index 000000000000..9db795eafeda --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceTapDisposition.swift @@ -0,0 +1,13 @@ +#if canImport(UIKit) +/// Tells the terminal surface whether a completed tap should claim keyboard focus. +public enum GhosttySurfaceTapDisposition: Sendable { + /// The host opened an artifact for the tapped path; terminal focus must remain unchanged. + case openedArtifact + /// The tap belongs to the terminal and should raise or restore its input focus. + case focusTerminal + + var shouldFocusTerminal: Bool { + self == .focusTerminal + } +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index ced97c2793c5..fe18b9289a9e 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -1569,17 +1569,26 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { setChromeHidden(false) } let cell = scrollCell(at: gesture.location(in: self)) - delegate?.ghosttySurfaceView(self, didTapAtCol: cell.col, row: cell.row) - // A tap inside the composer band is excluded by the gesture recognizer - // (`gestureRecognizer(_:shouldReceive:)`), so any tap reaching here is a - // deliberate terminal tap. Only a reveal-from-hide with the composer still - // presented re-focuses the composer; every other terminal tap focuses the - // terminal proxy as before. - if wasHidden, composerActive { - delegate?.ghosttySurfaceViewDidRequestComposerFocus(self) - focusMountedComposerField() - } else { - focusInput() + Task { @MainActor [weak self] in + guard let self else { return } + let disposition = await self.delegate?.ghosttySurfaceView( + self, + didTapAtCol: cell.col, + row: cell.row + ) ?? .focusTerminal + guard disposition.shouldFocusTerminal else { return } + + // A tap inside the composer band is excluded by the gesture recognizer + // (`gestureRecognizer(_:shouldReceive:)`), so any tap reaching here is a + // deliberate terminal tap. Only a reveal-from-hide with the composer still + // presented re-focuses the composer; every other terminal tap focuses the + // terminal proxy as before. Artifact taps never enter this focus path. + if wasHidden, self.composerActive { + self.delegate?.ghosttySurfaceViewDidRequestComposerFocus(self) + self.focusMountedComposerField() + } else { + self.focusInput() + } } } diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceViewDelegate.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceViewDelegate.swift index 85db9ca87998..1698a62ef94d 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceViewDelegate.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceViewDelegate.swift @@ -28,7 +28,11 @@ public protocol GhosttySurfaceViewDelegate: AnyObject { /// cell, so TUIs with mouse reporting (lazygit/htop/fzf) receive the click. /// The Mac's libghostty self-gates: a normal screen treats it as a harmless /// empty selection. Optional. - func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didTapAtCol col: Int, row: Int) + func ghosttySurfaceView( + _ surfaceView: GhosttySurfaceView, + didTapAtCol col: Int, + row: Int + ) async -> GhosttySurfaceTapDisposition /// The user tapped the "customize" button at the end of the input-accessory /// bar; the host should present the toolbar shortcuts editor. Optional. func ghosttySurfaceViewDidRequestToolbarSettings(_ surfaceView: GhosttySurfaceView) @@ -77,8 +81,14 @@ public protocol GhosttySurfaceViewDelegate: AnyObject { public extension GhosttySurfaceViewDelegate { /// Default no-op so hosts without remote scroll forwarding can ignore it. func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didScrollLines lines: Double, atCol col: Int, row: Int) {} - /// Default no-op so hosts without remote click forwarding can ignore it. - func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didTapAtCol col: Int, row: Int) {} + /// Default terminal disposition so hosts without remote click forwarding retain input focus. + func ghosttySurfaceView( + _ surfaceView: GhosttySurfaceView, + didTapAtCol col: Int, + row: Int + ) async -> GhosttySurfaceTapDisposition { + .focusTerminal + } /// Default no-op so hosts without a toolbar editor can ignore the request. func ghosttySurfaceViewDidRequestToolbarSettings(_ surfaceView: GhosttySurfaceView) {} /// Default no-op so hosts without terminal artifacts can ignore the request. From 88d125c60d53120e6f8a0f1bfa2d84d008c65541 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 17:14:48 -0700 Subject: [PATCH 62/92] test(ios): cover remaining artifact viewer defects --- .../TerminalArtifactTapHitTesterTests.swift | 24 +++++++++++ .../ChatArtifactMarkdownDocumentTests.swift | 42 +++++++++++++++++++ .../ChatArtifactTextAppendPolicyTests.swift | 27 ++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactMarkdownDocumentTests.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextAppendPolicyTests.swift diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactTapHitTesterTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactTapHitTesterTests.swift index 30a21dc314a5..6345a6420077 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactTapHitTesterTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactTapHitTesterTests.swift @@ -85,6 +85,30 @@ struct TerminalArtifactTapHitTesterTests { #expect(resolved == path) } + @Test("resolves a bare filename referenced by terminal transcript prose") + func resolvesBareFilename() { + let resolved = TerminalArtifactTapHitTester().path( + in: "Inspect data.csv, then continue.", + col: 10, + row: 0, + columns: 80 + ) + + #expect(resolved == "data.csv") + } + + @Test("trims sentence punctuation from a bare filename") + func trimsBareFilenamePunctuation() { + let resolved = TerminalArtifactTapHitTester().path( + in: "The log is build.log.", + col: 15, + row: 0, + columns: 80 + ) + + #expect(resolved == "build.log") + } + @Test("uses terminal cell width for CJK text before a path") func cjkPrefixUsesTerminalColumns() { let path = "/tmp/note.txt" diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactMarkdownDocumentTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactMarkdownDocumentTests.swift new file mode 100644 index 000000000000..d186339aad17 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactMarkdownDocumentTests.swift @@ -0,0 +1,42 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact Markdown document") +struct ChatArtifactMarkdownDocumentTests { + @Test("preserves headings paragraphs lists code and table rows as distinct blocks") + func documentStructure() { + let document = ChatArtifactMarkdownDocument(markdown: """ + # Guide + + Intro with **bold** text. + + - one + 2. two + + ```swift + print("hi") + ``` + + | kind | file | + | --- | --- | + | log | build.log | + | data | data.csv | + """) + + #expect(document.blocks.map(\.kind) == [ + .heading(level: 1), + .paragraph, + .bullet(indent: 0), + .ordered(marker: "2.", indent: 0), + .code(language: "swift"), + .tableRow(isHeader: true), + .tableRow(isHeader: false), + .tableRow(isHeader: false), + ]) + #expect(document.blocks[0].text == "Guide") + #expect(document.blocks[4].text == "print(\"hi\")") + #expect(document.blocks[5].text == "kind │ file") + #expect(document.blocks[7].text == "data │ data.csv") + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextAppendPolicyTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextAppendPolicyTests.swift new file mode 100644 index 000000000000..dd8d220dc4b5 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextAppendPolicyTests.swift @@ -0,0 +1,27 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact text append policy") +struct ChatArtifactTextAppendPolicyTests { + @Test("coalesces tracked and decelerating appends until scrolling becomes idle") + func defersUserScrollAppends() { + var policy = ChatArtifactTextAppendPolicy() + + #expect(policy.enqueue(chunkCount: 1) == 1) + policy.beginTracking() + #expect(policy.enqueue(chunkCount: 2) == 0) + #expect(policy.endTracking(willDecelerate: true) == 0) + #expect(policy.enqueue(chunkCount: 3) == 0) + #expect(policy.endDecelerating() == 5) + } + + @Test("protects an animated top jump from streamed appends") + func defersProgrammaticScrollAppends() { + var policy = ChatArtifactTextAppendPolicy() + + policy.beginProgrammaticAnimation() + #expect(policy.enqueue(chunkCount: 2) == 0) + #expect(policy.endProgrammaticAnimation() == 2) + } +} From baa852c3de11c43b605f42841932dfabdfd4ed21 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 17:18:53 -0700 Subject: [PATCH 63/92] fix(ios): preserve rendered markdown structure --- .../Artifacts/ChatArtifactMarkdownBlock.swift | 19 +++ .../ChatArtifactMarkdownDocument.swift | 117 ++++++++++++++++++ .../Artifacts/ChatArtifactMarkdownView.swift | 88 ++++++++++++- 3 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownBlock.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownDocument.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownBlock.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownBlock.swift new file mode 100644 index 000000000000..14a8f924947c --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownBlock.swift @@ -0,0 +1,19 @@ +/// One document-level Markdown block with structure preserved for layout. +struct ChatArtifactMarkdownBlock: Identifiable, Equatable, Sendable { + enum Kind: Equatable, Sendable { + case heading(level: Int) + case paragraph + case bullet(indent: Int) + case ordered(marker: String, indent: Int) + case quote + case rule + case code(language: String?) + case tableRow(isHeader: Bool) + } + + let index: Int + let kind: Kind + let text: String + + var id: Int { index } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownDocument.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownDocument.swift new file mode 100644 index 000000000000..92214a144a47 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownDocument.swift @@ -0,0 +1,117 @@ +import Foundation + +/// Parses Markdown into stable block-level values before SwiftUI lays it out. +struct ChatArtifactMarkdownDocument: Equatable, Sendable { + let blocks: [ChatArtifactMarkdownBlock] + + init(markdown: String) { + var parsed: [ChatArtifactMarkdownBlock] = [] + for segment in ChatProseSegmenter().segments(from: markdown) { + switch segment.kind { + case .text: + for textBlock in ChatTextBlockParser().blocks(from: segment.content) { + parsed.append(contentsOf: Self.blocks(from: textBlock)) + } + case .code(let language): + parsed.append(ChatArtifactMarkdownBlock( + index: parsed.count, + kind: .code(language: language), + text: segment.content + )) + } + } + blocks = parsed.enumerated().map { index, block in + ChatArtifactMarkdownBlock(index: index, kind: block.kind, text: block.text) + } + } + + private static func blocks(from block: ChatTextBlock) -> [ChatArtifactMarkdownBlock] { + switch block.kind { + case .heading(let level): + return [markdownBlock(kind: .heading(level: level), text: block.text)] + case .paragraph: + return paragraphBlocks(from: block.text) + case .bullet(let indent): + return [markdownBlock(kind: .bullet(indent: indent), text: block.text)] + case .ordered(let marker, let indent): + return [markdownBlock(kind: .ordered(marker: marker, indent: indent), text: block.text)] + case .quote: + return [markdownBlock(kind: .quote, text: block.text)] + case .rule: + return [markdownBlock(kind: .rule, text: "")] + } + } + + /// Splits GitHub-style tables out of paragraph runs while preserving surrounding prose. + private static func paragraphBlocks(from text: String) -> [ChatArtifactMarkdownBlock] { + let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var result: [ChatArtifactMarkdownBlock] = [] + var paragraphLines: [String] = [] + var index = 0 + + while index < lines.count { + let startsTable = index + 1 < lines.count + && tableCells(in: lines[index]) != nil + && isTableSeparator(lines[index + 1]) + if !startsTable { + paragraphLines.append(lines[index]) + index += 1 + continue + } + + if !paragraphLines.isEmpty { + result.append(markdownBlock( + kind: .paragraph, + text: paragraphLines.joined(separator: "\n") + )) + paragraphLines.removeAll(keepingCapacity: true) + } + + if let headerCells = tableCells(in: lines[index]) { + result.append(markdownBlock( + kind: .tableRow(isHeader: true), + text: headerCells.joined(separator: " │ ") + )) + } + index += 2 + while index < lines.count, let cells = tableCells(in: lines[index]) { + result.append(markdownBlock( + kind: .tableRow(isHeader: false), + text: cells.joined(separator: " │ ") + )) + index += 1 + } + } + + if !paragraphLines.isEmpty { + result.append(markdownBlock( + kind: .paragraph, + text: paragraphLines.joined(separator: "\n") + )) + } + return result + } + + private static func tableCells(in line: String) -> [String]? { + let cells = line + .split(separator: "|", omittingEmptySubsequences: true) + .map { $0.trimmingCharacters(in: .whitespaces) } + guard cells.count >= 2, cells.allSatisfy({ !$0.isEmpty }) else { return nil } + return cells + } + + private static func isTableSeparator(_ line: String) -> Bool { + guard let cells = tableCells(in: line) else { return false } + return cells.allSatisfy { cell in + let withoutColons = cell.trimmingCharacters(in: CharacterSet(charactersIn: ":")) + return withoutColons.count >= 3 && withoutColons.allSatisfy { $0 == "-" } + } + } + + private static func markdownBlock( + kind: ChatArtifactMarkdownBlock.Kind, + text: String + ) -> ChatArtifactMarkdownBlock { + ChatArtifactMarkdownBlock(index: 0, kind: kind, text: text) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownView.swift index 671dbcf0bc38..1769a288efc1 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownView.swift @@ -7,15 +7,95 @@ struct ChatArtifactMarkdownView: View { var body: some View { ScrollView { - Text(renderedMarkdown) + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(ChatArtifactMarkdownDocument(markdown: markdown).blocks) { block in + blockView(block) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + } + + @ViewBuilder + private func blockView(_ block: ChatArtifactMarkdownBlock) -> some View { + switch block.kind { + case .heading(let level): + Text(renderedInline(block.text)) + .font(headingFont(level: level)) .textSelection(.enabled) + .padding(.top, block.index == 0 ? 0 : 4) + case .paragraph: + Text(renderedInline(block.text)) + .font(.body) + .textSelection(.enabled) + case .bullet(let indent): + listRow(marker: "•", text: block.text, indent: indent) + case .ordered(let marker, let indent): + listRow(marker: marker, text: block.text, indent: indent) + case .quote: + HStack(spacing: 10) { + Rectangle() + .fill(.tertiary) + .frame(width: 3) + Text(renderedInline(block.text)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + case .rule: + Divider() + .padding(.vertical, 4) + case .code(let language): + VStack(alignment: .leading, spacing: 6) { + if let language, !language.isEmpty { + Text(verbatim: language.uppercased()) + .font(.caption2.weight(.semibold).monospaced()) + .foregroundStyle(.secondary) + } + ScrollView(.horizontal) { + Text(verbatim: block.text.isEmpty ? " " : block.text) + .font(.body.monospaced()) + .textSelection(.enabled) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.secondary.opacity(0.1), in: .rect(cornerRadius: 8)) + case .tableRow(let isHeader): + ScrollView(.horizontal) { + Text(verbatim: block.text) + .font(.system(.body, design: .monospaced).weight(isHeader ? .semibold : .regular)) + .textSelection(.enabled) + } + .padding(.vertical, isHeader ? 4 : 0) + } + } + + private func listRow(marker: String, text: String, indent: Int) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(verbatim: marker) + .foregroundStyle(.secondary) + .frame(minWidth: 20, alignment: .trailing) + Text(renderedInline(text)) .frame(maxWidth: .infinity, alignment: .leading) - .padding() + .textSelection(.enabled) + } + .padding(.leading, CGFloat(min(indent, 4)) * 16) + } + + private func headingFont(level: Int) -> Font { + switch level { + case 1: .title.weight(.bold) + case 2: .title2.weight(.bold) + case 3: .title3.weight(.semibold) + default: .headline } } - private var renderedMarkdown: AttributedString { - let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .full) + private func renderedInline(_ markdown: String) -> AttributedString { + var options = AttributedString.MarkdownParsingOptions() + options.interpretedSyntax = .inlineOnlyPreservingWhitespace + options.failurePolicy = .returnPartiallyParsedIfPossible return (try? AttributedString(markdown: markdown, options: options)) ?? AttributedString(markdown) } From 5be574039cbfb5abe980d3c4573c0c420fcfea2b Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 17:27:29 -0700 Subject: [PATCH 64/92] fix(ios): authorize transcript artifact taps by session --- .../TerminalArtifactTapHitTester.swift | 37 ++++++++++++++++++- .../TerminalArtifactFilesSheet+Content.swift | 1 + .../TerminalArtifactFilesSheet.swift | 37 +++++++++++++++++-- ...orkspaceDetailView+TerminalArtifacts.swift | 3 +- .../WorkspaceDetailView.swift | 25 ++++++++++--- 5 files changed, 91 insertions(+), 12 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactTapHitTester.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactTapHitTester.swift index 50861fed1460..76393a15d7b5 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactTapHitTester.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactTapHitTester.swift @@ -58,7 +58,7 @@ public struct TerminalArtifactTapHitTester: Sendable { )) } - let normalizedPath = TerminalArtifactPathDetector().tokens(in: rawPath).first?.path ?? token.path + let normalizedPath = Self.normalizedPath(in: rawPath) ?? token.path return StitchedPath(path: normalizedPath, segments: segments) } @@ -76,7 +76,7 @@ public struct TerminalArtifactTapHitTester: Sendable { index = line.index(after: index) } let raw = String(line[tokenStart.. String? { + if let path = TerminalArtifactPathDetector().tokens(in: raw).first?.path { + return path + } + + var candidate = raw.trimmingCharacters(in: bareFilenameLeadingCharacters) + while let scalar = candidate.unicodeScalars.last, + bareFilenameTrailingCharacters.contains(scalar) { + candidate.removeLast() + } + guard !candidate.isEmpty, + !candidate.contains("/"), + !candidate.contains("@"), + !candidate.contains("://"), + !candidate.unicodeScalars.contains(where: forbiddenBareFilenameCharacters.contains) + else { return nil } + + let path = candidate as NSString + let pathExtension = path.pathExtension + let basename = path.deletingPathExtension + guard !basename.isEmpty, + !pathExtension.isEmpty, + candidate.contains(where: { $0.isLetter }) + else { return nil } + return candidate + } + private func leadingContinuation(in line: String) -> Continuation? { guard let first = line.first, !first.isWhitespace, @@ -185,6 +215,9 @@ public struct TerminalArtifactTapHitTester: Sendable { } private static let leadingTrimCharacters: Set = ["\"", "'", "`", "(", "[", "{", "<"] + private static let bareFilenameLeadingCharacters = CharacterSet(charactersIn: "\"'`([{<") + private static let bareFilenameTrailingCharacters = CharacterSet(charactersIn: "\"'`)]}>,;:!?.") + private static let forbiddenBareFilenameCharacters = CharacterSet(charactersIn: "<>\"'\\`") private static let pathContinuationCharacters: Set = [ "/", ".", "_", "-", "+", "=", "~", "@", "%", ":", "\\", ] diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift index 01db21669bfe..552150ce3981 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift @@ -673,6 +673,7 @@ extension TerminalArtifactFilesSheet { selection = TerminalArtifactPathSelection( path: path, scope: scope, + usesSessionAuthorization: scope == .session || sessionID != nil, swipeOrder: swipeOrder ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift index c9eb5ba17913..c840f11afdd1 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift @@ -2,6 +2,7 @@ import CmuxAgentChat import CmuxAgentChatUI import CmuxMobileShell +import Foundation import SwiftUI struct TerminalArtifactContext: Identifiable { @@ -16,8 +17,35 @@ struct TerminalArtifactSelection: Identifiable, Equatable { let workspaceID: String let surfaceID: String let path: String + let sessionID: String? - var id: String { "\(workspaceID)#\(surfaceID)#\(path)" } + init( + workspaceID: String, + surfaceID: String, + path: String, + session: ChatSessionDescriptor? + ) { + self.workspaceID = workspaceID + self.surfaceID = surfaceID + + if (path as NSString).isAbsolutePath { + self.path = (path as NSString).standardizingPath + sessionID = session?.id + } else if let session, + let workingDirectory = session.workingDirectory, + (workingDirectory as NSString).isAbsolutePath { + self.path = ((workingDirectory as NSString).appendingPathComponent(path) as NSString) + .standardizingPath + sessionID = session.id + } else { + self.path = path + sessionID = nil + } + } + + var usesSessionAuthorization: Bool { sessionID != nil } + + var id: String { "\(workspaceID)#\(surfaceID)#\(sessionID ?? "terminal")#\(path)" } } struct TerminalArtifactFilesSheet: View { @@ -99,14 +127,14 @@ struct TerminalArtifactFilesSheet: View { if let selection { ChatArtifactViewerDestination( path: selection.path, - scope: selection.scope == .session ? .chat : .terminal, + scope: selection.usesSessionAuthorization ? .chat : .terminal, swipeOrder: selection.swipeOrder ) { dismiss() } .environment( \.chatArtifactLoader, - selection.scope == .session ? sessionLoader : loader + selection.usesSessionAuthorization ? sessionLoader : loader ) } } @@ -536,8 +564,9 @@ struct TerminalArtifactFilesSheet: View { struct TerminalArtifactPathSelection: Identifiable { let path: String let scope: Scope + let usesSessionAuthorization: Bool let swipeOrder: ChatArtifactGallerySwipeOrder - var id: String { "\(scope)#\(path)" } + var id: String { "\(scope)#\(usesSessionAuthorization)#\(path)" } } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift index 83763ab353ce..363196175301 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift @@ -40,7 +40,8 @@ extension WorkspaceDetailView { selectedTerminalArtifact = TerminalArtifactSelection( workspaceID: workspace.id.rawValue, surfaceID: terminalID, - path: path + path: path, + session: chosenChatSession ) }, onVisibleArtifactCountChanged: { count in diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 50a8eff21f98..8aa0b43c7cbf 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -265,16 +265,13 @@ struct WorkspaceDetailView: View { if let selectedTerminalArtifact { ChatArtifactViewerDestination( path: selectedTerminalArtifact.path, - scope: .terminal + scope: selectedTerminalArtifact.usesSessionAuthorization ? .chat : .terminal ) { self.selectedTerminalArtifact = nil } .environment( \.chatArtifactLoader, - terminalArtifactLoader( - workspaceID: selectedTerminalArtifact.workspaceID, - surfaceID: selectedTerminalArtifact.surfaceID - ) + artifactLoader(for: selectedTerminalArtifact) ) } } @@ -352,6 +349,24 @@ struct WorkspaceDetailView: View { } ) } + + private func artifactLoader(for selection: TerminalArtifactSelection) -> ChatArtifactLoader { + guard let sessionID = selection.sessionID else { + return terminalArtifactLoader( + workspaceID: selection.workspaceID, + surfaceID: selection.surfaceID + ) + } + guard store.supportsChatArtifacts, + let source = store.makeChatEventSource() else { + return .unsupported(cache: terminalArtifactThumbnailCache) + } + return ChatArtifactLoader( + source: source, + sessionID: sessionID, + cache: terminalArtifactThumbnailCache + ) + } #endif @ViewBuilder From 5544b19b6bdbcb1d259cdb39d77c5b07a50c7d01 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 17:30:12 -0700 Subject: [PATCH 65/92] fix(ios): defer streamed text edits while scrolling --- .../Artifacts/ChatArtifactLineIndex.swift | 18 ++- .../ChatArtifactLineIndexBatch.swift | 18 +++ .../ChatArtifactTextAppendPolicy.swift | 56 +++++++ .../Artifacts/ChatArtifactTextView.swift | 107 ++++++------- .../ChatArtifactTextViewCoordinator.swift | 150 ++++++++++++++++++ .../Artifacts/ChatArtifactViewerModel.swift | 12 +- 6 files changed, 294 insertions(+), 67 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndexBatch.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAppendPolicy.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndex.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndex.swift index 9641347352af..5970f856e5d4 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndex.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndex.swift @@ -9,15 +9,19 @@ struct ChatArtifactLineIndex: Equatable, Sendable { /// Adds line starts from one newly decoded streaming chunk. mutating func append(_ text: String) { + append(ChatArtifactLineIndexBatch(text: text)) + } + + /// Applies a batch whose UTF-16 scan may have been computed off-main. + mutating func append(_ batch: ChatArtifactLineIndexBatch) { let baseOffset = loadedUTF16Length - var chunkOffset = 0 - for codeUnit in text.utf16 { - chunkOffset += 1 - if codeUnit == 0x0A { - lineStartOffsets.append(baseOffset + chunkOffset) - } + lineStartOffsets.reserveCapacity( + lineStartOffsets.count + batch.relativeLineStartOffsets.count + ) + for relativeOffset in batch.relativeLineStartOffsets { + lineStartOffsets.append(baseOffset + relativeOffset) } - loadedUTF16Length += chunkOffset + loadedUTF16Length += batch.utf16Length } /// Clamps a one-based requested line to the range currently loaded. diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndexBatch.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndexBatch.swift new file mode 100644 index 000000000000..6713664fd925 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndexBatch.swift @@ -0,0 +1,18 @@ +/// Precomputed line-index contribution for one streamed text chunk. +struct ChatArtifactLineIndexBatch: Equatable, Sendable { + let utf16Length: Int + let relativeLineStartOffsets: [Int] + + init(text: String) { + var utf16Length = 0 + var lineStartOffsets: [Int] = [] + for codeUnit in text.utf16 { + utf16Length += 1 + if codeUnit == 0x0A { + lineStartOffsets.append(utf16Length) + } + } + self.utf16Length = utf16Length + relativeLineStartOffsets = lineStartOffsets + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAppendPolicy.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAppendPolicy.swift new file mode 100644 index 000000000000..6ab3f608f0dd --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAppendPolicy.swift @@ -0,0 +1,56 @@ +/// Coalesces streamed text chunks while scrolling must remain uninterrupted. +struct ChatArtifactTextAppendPolicy: Equatable, Sendable { + private enum DeferralState: Equatable, Sendable { + case idle + case tracking + case decelerating + case programmaticAnimation + } + + private var state = DeferralState.idle + private var pendingChunkCount = 0 + + var isDeferring: Bool { + state != .idle + } + + mutating func enqueue(chunkCount: Int) -> Int { + guard chunkCount > 0 else { return 0 } + pendingChunkCount += chunkCount + return state == .idle ? drain() : 0 + } + + mutating func beginTracking() { + state = .tracking + } + + mutating func endTracking(willDecelerate: Bool) -> Int { + state = willDecelerate ? .decelerating : .idle + return state == .idle ? drain() : 0 + } + + mutating func endDecelerating() -> Int { + state = .idle + return drain() + } + + mutating func beginProgrammaticAnimation() { + state = .programmaticAnimation + } + + mutating func endProgrammaticAnimation() -> Int { + guard state == .programmaticAnimation else { return 0 } + state = .idle + return drain() + } + + mutating func reset() { + state = .idle + pendingChunkCount = 0 + } + + private mutating func drain() -> Int { + defer { pendingChunkCount = 0 } + return pendingChunkCount + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift index e09475bfe947..bd4056c365f1 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift @@ -42,20 +42,20 @@ struct ChatArtifactTextView: UIViewRepresentable { context.coordinator.onFontSizeChanged = onFontSizeChanged let isNewDocument = context.coordinator.documentID != documentID if isNewDocument { + context.coordinator.resetStreamingText() context.coordinator.resetHighlighting() context.coordinator.resetSearch() context.coordinator.resetAccessibilityContent() textView.textStorage.setAttributedString(NSAttributedString()) textView.selectedRange = NSRange(location: 0, length: 0) context.coordinator.documentID = documentID - context.coordinator.appliedChunkCount = 0 context.coordinator.handledTopRequestID = topRequestID context.coordinator.handledBottomRequestID = bottomRequestID context.coordinator.handledGoToLineRequestID = goToLineRequestID } if context.coordinator.appliedChunkCount > chunks.count { - context.coordinator.appliedChunkCount = 0 + context.coordinator.resetStreamingText() context.coordinator.resetHighlighting() context.coordinator.resetSearch() context.coordinator.resetAccessibilityContent() @@ -74,67 +74,66 @@ struct ChatArtifactTextView: UIViewRepresentable { .font: font, .foregroundColor: textColor, ] - while context.coordinator.appliedChunkCount < chunks.count { - let chunk = chunks[context.coordinator.appliedChunkCount] - let contentOffset = textView.contentOffset - let selection = textView.selectedRange - textView.textStorage.beginEditing() - textView.textStorage.append(NSAttributedString(string: chunk, attributes: attributes)) - textView.textStorage.endEditing() - textView.selectedRange = selection - textView.setContentOffset(contentOffset, animated: false) - context.coordinator.appliedChunkCount += 1 - context.coordinator.appendAccessibilityContent(chunk) + if context.coordinator.appliedChunkCount < chunks.count { + context.coordinator.enqueueTextChunks( + chunks[context.coordinator.appliedChunkCount...], + attributes: attributes, + in: textView + ) } - let updatePlan = ChatArtifactTextUpdatePlan( - reachedEOF: reachedEOF, - highlightDecision: highlightDecision, - searchQuery: searchQuery - ) - let fullText = updatePlan.requiresFullTextSnapshot - ? textView.textStorage.string - : nil - context.coordinator.updateHighlighting( - in: textView, - documentID: documentID, - text: fullText, - reachedEOF: reachedEOF, - decision: highlightDecision, - theme: highlightTheme - ) - context.coordinator.updateSearch( - in: textView, - documentID: documentID, - text: fullText, - textLength: textView.textStorage.length, - query: searchQuery, - reachedEOF: reachedEOF, - previousRequestID: previousSearchRequestID, - nextRequestID: nextSearchRequestID, - onSummaryChanged: onSearchSummaryChanged - ) - containerView.updateLineNumbers(index: lineIndex, isVisible: showsLineNumbers) + let coordinator = context.coordinator + coordinator.schedulePostAppendWork { [weak coordinator, weak textView] in + guard let coordinator, let textView else { return } + let updatePlan = ChatArtifactTextUpdatePlan( + reachedEOF: reachedEOF, + highlightDecision: highlightDecision, + searchQuery: searchQuery + ) + let fullText = updatePlan.requiresFullTextSnapshot + ? textView.textStorage.string + : nil + coordinator.updateHighlighting( + in: textView, + documentID: documentID, + text: fullText, + reachedEOF: reachedEOF, + decision: highlightDecision, + theme: highlightTheme + ) + coordinator.updateSearch( + in: textView, + documentID: documentID, + text: fullText, + textLength: textView.textStorage.length, + query: searchQuery, + reachedEOF: reachedEOF, + previousRequestID: previousSearchRequestID, + nextRequestID: nextSearchRequestID, + onSummaryChanged: onSearchSummaryChanged + ) + } + coordinator.updateLineNumbers(index: lineIndex, isVisible: showsLineNumbers) containerView.updateAccessibility( documentID: documentID, content: context.coordinator.accessibilityContent ) if isNewDocument { - Self.scrollToTop(textView, animated: false) + coordinator.scrollToTop(in: textView, animated: false) } else { - if context.coordinator.handledTopRequestID != topRequestID { - context.coordinator.handledTopRequestID = topRequestID - Self.scrollToTop(textView, animated: true) + if coordinator.handledTopRequestID != topRequestID { + coordinator.handledTopRequestID = topRequestID + coordinator.scrollToTop(in: textView, animated: true) } - if context.coordinator.handledBottomRequestID != bottomRequestID { - context.coordinator.handledBottomRequestID = bottomRequestID + if coordinator.handledBottomRequestID != bottomRequestID { + coordinator.handledBottomRequestID = bottomRequestID textView.scrollRangeToVisible( NSRange(location: textView.textStorage.length, length: 0) ) } - if context.coordinator.handledGoToLineRequestID != goToLineRequestID { - context.coordinator.handledGoToLineRequestID = goToLineRequestID + if coordinator.handledGoToLineRequestID != goToLineRequestID { + coordinator.handledGoToLineRequestID = goToLineRequestID textView.scrollRangeToVisible(NSRange( location: min(max(goToLineUTF16Offset, 0), textView.textStorage.length), length: 0 @@ -142,15 +141,5 @@ struct ChatArtifactTextView: UIViewRepresentable { } } } - - private static func scrollToTop(_ textView: UITextView, animated: Bool) { - textView.setContentOffset( - CGPoint( - x: -textView.adjustedContentInset.left, - y: -textView.adjustedContentInset.top - ), - animated: animated - ) - } } #endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift index d4e676f99814..d89762a77c87 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift @@ -12,6 +12,11 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { var handledGoToLineRequestID = 0 var onFontSizeChanged: ((Double) -> Void)? private(set) var accessibilityContent = ChatArtifactTextAccessibilityContent() + private var appendPolicy = ChatArtifactTextAppendPolicy() + private var pendingTextChunks: [String] = [] + private var pendingTextAttributes: [NSAttributedString.Key: Any] = [:] + private var pendingLineNumberUpdate: (index: ChatArtifactLineIndex, isVisible: Bool)? + private var latestPostAppendWork: (() -> Void)? private weak var containerView: ChatArtifactTextContainerView? private let syntaxHighlighter = ChatArtifactSyntaxHighlighter() private var highlightTask: Task? @@ -61,6 +66,151 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { containerView?.gutterView.setNeedsDisplay() } + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + appendPolicy.beginTracking() + suspendTextStorageWork() + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + resumeDeferredUpdates( + releasedChunkCount: appendPolicy.endTracking(willDecelerate: decelerate), + in: scrollView as? UITextView + ) + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + resumeDeferredUpdates( + releasedChunkCount: appendPolicy.endDecelerating(), + in: scrollView as? UITextView + ) + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + resumeDeferredUpdates( + releasedChunkCount: appendPolicy.endProgrammaticAnimation(), + in: scrollView as? UITextView + ) + } + + func resetStreamingText() { + appendPolicy.reset() + pendingTextChunks.removeAll(keepingCapacity: false) + pendingTextAttributes.removeAll(keepingCapacity: false) + pendingLineNumberUpdate = nil + latestPostAppendWork = nil + appliedChunkCount = 0 + } + + func enqueueTextChunks( + _ chunks: ArraySlice, + attributes: [NSAttributedString.Key: Any], + in textView: UITextView + ) { + guard !chunks.isEmpty else { return } + pendingTextChunks.append(contentsOf: chunks) + pendingTextAttributes = attributes + appliedChunkCount += chunks.count + for chunk in chunks { + appendAccessibilityContent(chunk) + } + let releasedChunkCount = appendPolicy.enqueue(chunkCount: chunks.count) + if releasedChunkCount > 0 { + flushPendingText(releasedChunkCount: releasedChunkCount, in: textView) + } + } + + func updateLineNumbers(index: ChatArtifactLineIndex, isVisible: Bool) { + pendingLineNumberUpdate = (index, isVisible) + applyPendingLineNumbersIfReady() + } + + func schedulePostAppendWork(_ work: @escaping () -> Void) { + latestPostAppendWork = work + runPostAppendWorkIfReady() + } + + func scrollToTop(in textView: UITextView, animated: Bool) { + let target = CGPoint( + x: -textView.adjustedContentInset.left, + y: -textView.adjustedContentInset.top + ) + let requiresAnimation = animated + && (abs(textView.contentOffset.x - target.x) > 0.5 + || abs(textView.contentOffset.y - target.y) > 0.5) + if requiresAnimation { + appendPolicy.beginProgrammaticAnimation() + suspendTextStorageWork() + } + textView.setContentOffset(target, animated: requiresAnimation) + } + + private func resumeDeferredUpdates( + releasedChunkCount: Int, + in textView: UITextView? + ) { + if releasedChunkCount > 0, let textView { + flushPendingText(releasedChunkCount: releasedChunkCount, in: textView) + return + } + applyPendingLineNumbersIfReady() + runPostAppendWorkIfReady() + } + + private func flushPendingText(releasedChunkCount: Int, in textView: UITextView) { + guard !appendPolicy.isDeferring, !pendingTextChunks.isEmpty else { return } + assert(releasedChunkCount == pendingTextChunks.count) + + let text = pendingTextChunks.joined() + pendingTextChunks.removeAll(keepingCapacity: true) + let attributes = pendingTextAttributes + pendingTextAttributes.removeAll(keepingCapacity: true) + let contentOffset = textView.contentOffset + let selection = textView.selectedRange + + // TextKit can reset this optimization when its stack is rebuilt. Keep + // it explicit at the edit boundary so appends only lay out the viewport. + textView.layoutManager.allowsNonContiguousLayout = true + textView.textStorage.beginEditing() + textView.textStorage.append(NSAttributedString(string: text, attributes: attributes)) + textView.textStorage.endEditing() + textView.selectedRange = selection + textView.setContentOffset(contentOffset, animated: false) + + applyPendingLineNumbersIfReady() + runPostAppendWorkIfReady() + } + + private func applyPendingLineNumbersIfReady() { + guard !appendPolicy.isDeferring, + pendingTextChunks.isEmpty, + let update = pendingLineNumberUpdate else { return } + pendingLineNumberUpdate = nil + containerView?.updateLineNumbers(index: update.index, isVisible: update.isVisible) + } + + private func runPostAppendWorkIfReady() { + guard !appendPolicy.isDeferring, + pendingTextChunks.isEmpty else { return } + latestPostAppendWork?() + } + + private func suspendTextStorageWork() { + highlightTask?.cancel() + highlightTask = nil + highlightGeneration += 1 + pendingHighlightDocumentID = nil + pendingHighlightTextLength = 0 + pendingHighlightLanguage = nil + pendingHighlightTheme = nil + + searchTask?.cancel() + searchTask = nil + searchGeneration += 1 + pendingSearchDocumentID = nil + pendingSearchTextLength = 0 + pendingSearchQuery = "" + } + func updateFontSize(in textView: UITextView, pointSize: Double) { let clamped = min( max(pointSize, ChatArtifactTextPreferences.minimumFontSize), diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift index 4d1b2ab2786e..5eb652cc1af6 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift @@ -205,8 +205,13 @@ final class ChatArtifactViewerModel { for (index, decoded) in decodedBatches.enumerated() { try Task.checkCancellation() let isFinalBatch = index == decodedBatches.index(before: decodedBatches.endIndex) + let lineIndexBatch = await Task.detached(priority: .userInitiated) { + ChatArtifactLineIndexBatch(text: decoded) + }.value + try Task.checkCancellation() await self.receiveText( decoded, + lineIndexBatch: lineIndexBatch, chunk: chunk, path: path, isMarkdown: isMarkdown, @@ -222,6 +227,7 @@ final class ChatArtifactViewerModel { private func receiveText( _ text: String, + lineIndexBatch: ChatArtifactLineIndexBatch? = nil, chunk: ChatArtifactChunk, path: String, isMarkdown: Bool, @@ -230,7 +236,11 @@ final class ChatArtifactViewerModel { guard path == activePath else { return } if !text.isEmpty { textChunks.append(text) - textLineIndex.append(text) + if let lineIndexBatch { + textLineIndex.append(lineIndexBatch) + } else { + textLineIndex.append(text) + } } if isFinalBatch { updateProgress(for: chunk) From ed83c4f94de9a3f3d7b1e66ab66f65bd87c5e013 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 21:08:32 -0700 Subject: [PATCH 66/92] test: cover restored session surface lookup --- .../ChatSessionSurfaceIndexTests.swift | 19 +++++++++++ ...entChatSessionRegistryLifecycleTests.swift | 33 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionSurfaceIndexTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionSurfaceIndexTests.swift index 4b4d307b8774..dbdc60352864 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionSurfaceIndexTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionSurfaceIndexTests.swift @@ -47,4 +47,23 @@ struct ChatSessionSurfaceIndexTests { expectParity(surfaceID: "surface-a") expectParity(surfaceID: "surface-b") } + + @Test("an index miss scans authoritative records once and self-heals") + func selfHealsFromAuthoritativeRecords() { + let records = [ + "one": Record(sessionID: "one", surfaceID: "surface-a"), + "two": Record(sessionID: "two", surfaceID: "surface-a"), + "other": Record(sessionID: "other", surfaceID: "surface-b"), + ] + var index = ChatSessionSurfaceIndex() + + let healed = index.sessionIDs( + surfaceID: "surface-a", + healingFrom: records, + recordSurfaceID: \.surfaceID + ) + + #expect(healed == ["one", "two"]) + #expect(index.sessionIDs(surfaceID: "surface-a") == healed) + } } diff --git a/cmuxTests/AgentChatSessionRegistryLifecycleTests.swift b/cmuxTests/AgentChatSessionRegistryLifecycleTests.swift index 923be3524a83..697c25b2e4e5 100644 --- a/cmuxTests/AgentChatSessionRegistryLifecycleTests.swift +++ b/cmuxTests/AgentChatSessionRegistryLifecycleTests.swift @@ -464,6 +464,39 @@ struct AgentChatSessionRegistryLifecycleTests { #expect(service.hasBoundedReadableTranscript(record)) } + @MainActor + @Test func restoreStyleInitializationRebuildsSurfaceLookup() throws { + let surfaceID = UUID().uuidString + let older = AgentChatSessionRecord( + sessionID: "older", + agentKind: .codex, + workspaceID: UUID().uuidString, + surfaceID: surfaceID, + workingDirectory: "/Users/example/project", + transcriptPath: "/tmp/older.jsonl", + state: .ended, + lastActivityAt: Date(timeIntervalSince1970: 100), + title: nil, + pid: nil + ) + let newer = AgentChatSessionRecord( + sessionID: "newer", + agentKind: .claude, + workspaceID: UUID().uuidString, + surfaceID: surfaceID, + workingDirectory: "/Users/example/project", + transcriptPath: "/tmp/newer.jsonl", + state: .ended, + lastActivityAt: Date(timeIntervalSince1970: 200), + title: nil, + pid: nil + ) + let registry = AgentChatSessionRegistry(restoredRecords: [older, newer]) + + let resolved = try #require(registry.currentOrMostRecentSession(surfaceID: surfaceID)) + #expect(resolved.sessionID == newer.sessionID) + } + private func temporaryHomeDirectory() throws -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-agent-chat-\(UUID().uuidString)", isDirectory: true) From 191c06f5cb8d1c12f2c2005cbc082820329bd558 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 21:09:28 -0700 Subject: [PATCH 67/92] fix: keep chat session surface index coherent --- .../Session/ChatSessionSurfaceIndex.swift | 45 +++++++ .../AgentChat/AgentChatSessionRegistry.swift | 113 ++++++++++++------ 2 files changed, 119 insertions(+), 39 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Session/ChatSessionSurfaceIndex.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Session/ChatSessionSurfaceIndex.swift index afd77f6f18b3..f48d5365e3ed 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Session/ChatSessionSurfaceIndex.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Session/ChatSessionSurfaceIndex.swift @@ -34,4 +34,49 @@ public struct ChatSessionSurfaceIndex: Sendable public func sessionIDs(surfaceID: String) -> Set { sessionIDsBySurfaceID[surfaceID] ?? [] } + + /// Returns valid indexed sessions, rebuilding a missing surface entry from authoritative records. + /// + /// The full record dictionary is scanned only when the index has no valid + /// entry for `surfaceID`. Any recovered bindings are retained so later + /// lookups return directly from the index. + /// + /// - Parameters: + /// - surfaceID: Terminal surface UUID string. + /// - records: Authoritative records keyed by session identity. + /// - recordSurfaceID: Key path to each record's current surface binding. + /// - Returns: Session IDs currently associated with the surface. + public mutating func sessionIDs( + surfaceID: String, + healingFrom records: [SessionID: Record], + recordSurfaceID: KeyPath + ) -> Set { + let indexed = sessionIDsBySurfaceID[surfaceID] ?? [] + var validIndexed: Set = [] + for sessionID in indexed { + guard let record = records[sessionID], + record[keyPath: recordSurfaceID] == surfaceID else { + update( + sessionID: sessionID, + previousSurfaceID: surfaceID, + currentSurfaceID: nil + ) + continue + } + validIndexed.insert(sessionID) + } + guard validIndexed.isEmpty else { return validIndexed } + + var recovered: Set = [] + for (sessionID, record) in records + where record[keyPath: recordSurfaceID] == surfaceID { + recovered.insert(sessionID) + update( + sessionID: sessionID, + previousSurfaceID: nil, + currentSurfaceID: surfaceID + ) + } + return recovered + } } diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index 573e897e44c5..6c21d8abeea7 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -47,9 +47,22 @@ final class AgentChatSessionRegistry { /// Creates a registry. /// - /// - Parameter hookStore: Reader for the per-agent hook session stores. - init(hookStore: AgentChatHookSessionStore = AgentChatHookSessionStore()) { + /// - Parameters: + /// - hookStore: Reader for the per-agent hook session stores. + /// - restoredRecords: Records restored before live observation begins. + init( + hookStore: AgentChatHookSessionStore = AgentChatHookSessionStore(), + restoredRecords: [AgentChatSessionRecord] = [] + ) { self.hookStore = hookStore + for record in restoredRecords { + records[record.sessionID] = record + versionBySessionID[record.sessionID] = record.version + } + rebuildSessionIndexes() + for record in records.values { + syncProcessExitWatch(for: record) + } } /// All known sessions, optionally restricted to one workspace, most @@ -139,10 +152,7 @@ final class AgentChatSessionRegistry { } stampLifecycleTransition(previous: nil, current: &record, at: session.sampledAt) stampVersion(&record) - records[targetSessionID] = record - syncProcessExitWatch(for: record) - updateSessionIndexes(previous: nil, current: record) - onRecordChanged?(record, nil) + storeRecord(record, replacing: nil) } else { guard let current = records[targetSessionID] else { continue } if reviveEndedObservedSessionIfNeeded(current: current, observed: session, now: now) { @@ -260,9 +270,7 @@ final class AgentChatSessionRegistry { if let live = liveSession(surfaceID: surfaceID) { return live } - return sessionSurfaceIndex.sessionIDs(surfaceID: surfaceID) - .compactMap { records[$0] } - .filter { $0.surfaceID == surfaceID } + return indexedRecords(surfaceID: surfaceID) .max { $0.lastActivityAt < $1.lastActivityAt } } @@ -302,7 +310,6 @@ final class AgentChatSessionRegistry { mutate(&record) stampLifecycleTransition(previous: previous, current: &record, at: Date()) stampVersion(&record) - records[sessionID] = record #if DEBUG if previous.state != record.state { cmuxDebugLog( @@ -311,9 +318,7 @@ final class AgentChatSessionRegistry { ) } #endif - syncProcessExitWatch(for: record) - updateSessionIndexes(previous: previous, current: record) - onRecordChanged?(record, previous) + storeRecord(record, replacing: previous) } #if DEBUG @@ -388,10 +393,7 @@ final class AgentChatSessionRegistry { record.rememberHookStoreSessionID(entry.sessionID) stampLifecycleTransition(previous: nil, current: &record, at: entry.updatedAt ?? Date()) stampVersion(&record) - records[sessionID] = record - syncProcessExitWatch(for: record) - updateSessionIndexes(previous: nil, current: record) - onRecordChanged?(record, nil) + storeRecord(record, replacing: nil) } } } @@ -476,10 +478,7 @@ final class AgentChatSessionRegistry { record.state = Self.nextState(previous: record.state, event: event) stampLifecycleTransition(previous: previous, current: &record, at: event.receivedAt) stampVersion(&record) - records[sessionID] = record - syncProcessExitWatch(for: record) - updateSessionIndexes(previous: previous, current: record) - onRecordChanged?(record, previous) + storeRecord(record, replacing: previous) if shouldConsultStore { backfillBindingsFromStore( sessionID: sessionID, @@ -556,18 +555,7 @@ final class AgentChatSessionRegistry { .map(\.sessionID) guard !aliases.isEmpty else { return } for alias in aliases { - guard var record = records.removeValue(forKey: alias) else { continue } - stampVersion(&record) - exitWatchers[alias]?.source.cancel() - exitWatchers[alias] = nil - hookStoreConsultedAt.removeValue(forKey: alias) - updateSessionIndexes(previous: record, current: nil) - onRecordRemoved?(record) - } - if let indexed = liveSessionIDBySurfaceID[surfaceID], - aliases.contains(indexed) { - liveSessionIDBySurfaceID.removeValue(forKey: surfaceID) - rebuildLiveSessionIndex(surfaceID: surfaceID) + removeRecord(sessionID: alias) } } @@ -650,10 +638,7 @@ final class AgentChatSessionRegistry { ) stampLifecycleTransition(previous: nil, current: &record, at: now) stampVersion(&record) - records[sessionID] = record - syncProcessExitWatch(for: record) - updateSessionIndexes(previous: nil, current: record) - onRecordChanged?(record, nil) + storeRecord(record, replacing: nil) } /// Reads one session's hook-store entry OFF the main actor and applies any @@ -694,6 +679,57 @@ final class AgentChatSessionRegistry { } } + /// Stores one record and reconciles every derived registry structure. + private func storeRecord( + _ record: AgentChatSessionRecord, + replacing previous: AgentChatSessionRecord? + ) { + records[record.sessionID] = record + syncProcessExitWatch(for: record) + updateSessionIndexes(previous: previous, current: record) + onRecordChanged?(record, previous) + } + + /// Removes one record and reconciles every derived registry structure. + private func removeRecord(sessionID: String) { + guard var record = records.removeValue(forKey: sessionID) else { return } + stampVersion(&record) + exitWatchers[sessionID]?.source.cancel() + exitWatchers[sessionID] = nil + hookStoreConsultedAt.removeValue(forKey: sessionID) + updateSessionIndexes(previous: record, current: nil) + onRecordRemoved?(record) + } + + /// Rebuilds all derived indexes after records are restored at initialization. + private func rebuildSessionIndexes() { + sessionSurfaceIndex = ChatSessionSurfaceIndex() + liveSessionIDBySurfaceID.removeAll(keepingCapacity: true) + liveClaudeSessionIDsBySurfaceID.removeAll(keepingCapacity: true) + for record in records.values { + updateSessionIndexes(previous: nil, current: record) + } + } + + /// Resolves indexed records and repairs a missing or stale surface entry. + private func indexedRecords(surfaceID: String) -> [AgentChatSessionRecord] { + let before = sessionSurfaceIndex.sessionIDs(surfaceID: surfaceID) + let sessionIDs = sessionSurfaceIndex.sessionIDs( + surfaceID: surfaceID, + healingFrom: records, + recordSurfaceID: \.surfaceID + ) + #if DEBUG + if before != sessionIDs, !sessionIDs.isEmpty { + cmuxDebugLog( + "agentChat.surfaceIndex divergence surface=\(surfaceID.prefix(8)) " + + "indexed=\(before.count) recovered=\(sessionIDs.count)" + ) + } + #endif + return sessionIDs.compactMap { records[$0] } + } + private func updateSessionIndexes( previous: AgentChatSessionRecord?, current: AgentChatSessionRecord? @@ -745,8 +781,7 @@ final class AgentChatSessionRegistry { private func rebuildLiveSessionIndex(surfaceID: String?) { guard let surfaceID else { return } - if let newest = sessionSurfaceIndex.sessionIDs(surfaceID: surfaceID) - .compactMap({ records[$0] }) + if let newest = indexedRecords(surfaceID: surfaceID) .filter({ $0.surfaceID == surfaceID && $0.state != .ended }) .max(by: { $0.lastActivityAt < $1.lastActivityAt }) { liveSessionIDBySurfaceID[surfaceID] = newest.sessionID From 37cb16e7a56dc9c338bd09823bee49b8639f9663 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 21:10:46 -0700 Subject: [PATCH 68/92] test: reproduce stale In View authorization --- ...minalArtifactAuthorizationStoreTests.swift | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactAuthorizationStoreTests.swift diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactAuthorizationStoreTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactAuthorizationStoreTests.swift new file mode 100644 index 000000000000..0bd33c8c93c6 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactAuthorizationStoreTests.swift @@ -0,0 +1,98 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@Suite("Terminal artifact authorization snapshots") +struct TerminalArtifactAuthorizationStoreTests { + @Test("a listed path remains authorized after it scrolls off screen") + func listedPathSurvivesLiveScreenChange() async { + let listedPath = "/safe/report.txt" + let scanScope = TerminalArtifactScope( + terminalText: "open \(listedPath)", + workingDirectory: "/safe", + resolver: FakeResolver(files: [listedPath]) + ) + let listedPaths = Set(scanScope.artifactPaths()) + let liveScopeAfterScroll = TerminalArtifactScope( + terminalText: "prompt with no artifact path", + workingDirectory: "/safe", + resolver: FakeResolver(files: [listedPath]) + ) + let store = TerminalArtifactAuthorizationStore( + timeToLive: 60, + maximumGenerationsPerSurface: 2 + ) + let scannedAt = Date(timeIntervalSince1970: 100) + + await store.record( + workspaceID: "workspace", + surfaceID: "surface", + canonicalPaths: listedPaths, + at: scannedAt + ) + let snapshotPaths = await store.authorizedPaths( + workspaceID: "workspace", + surfaceID: "surface", + at: scannedAt.addingTimeInterval(30) + ) + let snapshotScope = ChatArtifactScope( + referencedPaths: snapshotPaths, + resolver: FakeResolver(files: [listedPath]) + ) + + #expect(liveScopeAfterScroll.canonicalPath(for: listedPath) == nil) + #expect(snapshotScope.canonicalFilePath(for: listedPath) == listedPath) + } + + @Test("authorization is bounded by TTL and retained generations") + func boundedLifetimeAndGenerations() async { + let store = TerminalArtifactAuthorizationStore( + timeToLive: 60, + maximumGenerationsPerSurface: 2 + ) + let start = Date(timeIntervalSince1970: 100) + + await store.record( + workspaceID: "workspace", + surfaceID: "surface", + canonicalPaths: ["/safe/one.txt"], + at: start + ) + await store.record( + workspaceID: "workspace", + surfaceID: "surface", + canonicalPaths: ["/safe/two.txt"], + at: start.addingTimeInterval(1) + ) + await store.record( + workspaceID: "workspace", + surfaceID: "surface", + canonicalPaths: ["/safe/three.txt"], + at: start.addingTimeInterval(2) + ) + + #expect(await store.authorizedPaths( + workspaceID: "workspace", + surfaceID: "surface", + at: start.addingTimeInterval(30) + ) == ["/safe/two.txt", "/safe/three.txt"]) + #expect(await store.authorizedPaths( + workspaceID: "workspace", + surfaceID: "surface", + at: start.addingTimeInterval(63) + ).isEmpty) + } + + private struct FakeResolver: ChatArtifactScope.FileSystemResolving { + let files: Set + + func resolveSymlinks(of path: String) -> String? { + (path as NSString).standardizingPath + } + + func isDirectory(_ path: String) -> Bool? { + files.contains(path) ? false : nil + } + } +} From 9c12bc261c453d230ff95f880f1b5ba12f4db08c Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 21:11:59 -0700 Subject: [PATCH 69/92] fix: preserve In View scan authorization --- .../TerminalArtifactAuthorizationStore.swift | 112 ++++++++++++++++++ ...alController+MobileTerminalArtifacts.swift | 57 +++++++-- Sources/TerminalController.swift | 4 + 3 files changed, 165 insertions(+), 8 deletions(-) create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactAuthorizationStore.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactAuthorizationStore.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactAuthorizationStore.swift new file mode 100644 index 000000000000..f07d764260ab --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactAuthorizationStore.swift @@ -0,0 +1,112 @@ +import Foundation + +/// Retains bounded terminal-scan generations for subsequent artifact reads. +public actor TerminalArtifactAuthorizationStore { + private typealias Generation = ( + ordinal: UInt64, + canonicalPaths: Set, + expiresAt: Date + ) + + private let timeToLive: TimeInterval + private let maximumGenerationsPerSurface: Int + private let maximumSurfaceCount: Int + private var generationsBySurfaceKey: [String: [Generation]] = [:] + private var nextOrdinalBySurfaceKey: [String: UInt64] = [:] + private var lastAccessBySurfaceKey: [String: Date] = [:] + + /// Creates a bounded authorization store. + /// + /// - Parameters: + /// - timeToLive: Lifetime of each scan generation in seconds. + /// - maximumGenerationsPerSurface: Maximum concurrent listings retained for one surface. + /// - maximumSurfaceCount: Maximum terminal surfaces retained at once. + public init( + timeToLive: TimeInterval = 10 * 60, + maximumGenerationsPerSurface: Int = 4, + maximumSurfaceCount: Int = 64 + ) { + self.timeToLive = max(1, timeToLive) + self.maximumGenerationsPerSurface = max(1, maximumGenerationsPerSurface) + self.maximumSurfaceCount = max(1, maximumSurfaceCount) + } + + /// Records one canonical terminal-scan generation. + /// + /// - Parameters: + /// - workspaceID: Workspace containing the scanned terminal. + /// - surfaceID: Scanned terminal surface. + /// - canonicalPaths: Canonical paths returned to the listing client. + /// - date: Scan completion time. + public func record( + workspaceID: String, + surfaceID: String, + canonicalPaths: Set, + at date: Date = Date() + ) { + purgeExpired(at: date) + let key = surfaceKey(workspaceID: workspaceID, surfaceID: surfaceID) + let ordinal = (nextOrdinalBySurfaceKey[key] ?? 0) &+ 1 + nextOrdinalBySurfaceKey[key] = ordinal + var generations = generationsBySurfaceKey[key] ?? [] + generations.append(( + ordinal: ordinal, + canonicalPaths: canonicalPaths, + expiresAt: date.addingTimeInterval(timeToLive) + )) + if generations.count > maximumGenerationsPerSurface { + generations.removeFirst(generations.count - maximumGenerationsPerSurface) + } + generationsBySurfaceKey[key] = generations + lastAccessBySurfaceKey[key] = date + evictLeastRecentSurfacesIfNeeded() + } + + /// Returns the union of unexpired scan generations for one terminal. + /// + /// - Parameters: + /// - workspaceID: Workspace containing the terminal. + /// - surfaceID: Terminal surface being read. + /// - date: Read time used for deterministic expiry. + /// - Returns: Canonical paths listed by retained scan generations. + public func authorizedPaths( + workspaceID: String, + surfaceID: String, + at date: Date = Date() + ) -> Set { + purgeExpired(at: date) + let key = surfaceKey(workspaceID: workspaceID, surfaceID: surfaceID) + guard let generations = generationsBySurfaceKey[key] else { return [] } + lastAccessBySurfaceKey[key] = date + return generations.reduce(into: Set()) { result, generation in + result.formUnion(generation.canonicalPaths) + } + } + + private func surfaceKey(workspaceID: String, surfaceID: String) -> String { + "\(workspaceID)\u{0}\(surfaceID)" + } + + private func purgeExpired(at date: Date) { + for key in Array(generationsBySurfaceKey.keys) { + let retained = generationsBySurfaceKey[key, default: []] + .filter { $0.expiresAt > date } + if retained.isEmpty { + generationsBySurfaceKey.removeValue(forKey: key) + nextOrdinalBySurfaceKey.removeValue(forKey: key) + lastAccessBySurfaceKey.removeValue(forKey: key) + } else { + generationsBySurfaceKey[key] = retained + } + } + } + + private func evictLeastRecentSurfacesIfNeeded() { + while generationsBySurfaceKey.count > maximumSurfaceCount, + let oldestKey = lastAccessBySurfaceKey.min(by: { $0.value < $1.value })?.key { + generationsBySurfaceKey.removeValue(forKey: oldestKey) + nextOrdinalBySurfaceKey.removeValue(forKey: oldestKey) + lastAccessBySurfaceKey.removeValue(forKey: oldestKey) + } + } +} diff --git a/Sources/TerminalController+MobileTerminalArtifacts.swift b/Sources/TerminalController+MobileTerminalArtifacts.swift index e2d446d51f99..ac0889128968 100644 --- a/Sources/TerminalController+MobileTerminalArtifacts.swift +++ b/Sources/TerminalController+MobileTerminalArtifacts.swift @@ -44,7 +44,7 @@ extension TerminalController { let visibleOnly = v2Bool(params, "visible_only") ?? false let countOnly = v2Bool(params, "count_only") ?? false let includeDirectories = v2Bool(params, "include_directories") ?? false - let resolution = mobileTerminalArtifactContext( + let resolution = await mobileTerminalArtifactContext( params: params, requiresPath: false, includeScrollback: !visibleOnly, @@ -79,11 +79,16 @@ extension TerminalController { let response = await Task.detached(priority: .utility) { context.scan(includeDirectories: includeDirectories) }.value + await terminalArtifactAuthorizationStore.record( + workspaceID: context.workspaceID, + surfaceID: context.surfaceID, + canonicalPaths: Set(response.artifacts.map(\.path)) + ) return TerminalArtifactWire.result(response) } func v2MobileTerminalArtifactStat(params: [String: Any]) async -> V2CallResult { - let resolution = mobileTerminalArtifactContext(params: params, requiresPath: true) + let resolution = await mobileTerminalArtifactContext(params: params, requiresPath: true) guard case .success(let context) = resolution else { return resolution.failureResult } @@ -107,7 +112,7 @@ extension TerminalController { } func v2MobileTerminalArtifactFetch(params: [String: Any]) async -> V2CallResult { - let resolution = mobileTerminalArtifactContext(params: params, requiresPath: true) + let resolution = await mobileTerminalArtifactContext(params: params, requiresPath: true) guard case .success(let context) = resolution else { return resolution.failureResult } @@ -132,7 +137,7 @@ extension TerminalController { } func v2MobileTerminalArtifactThumbnail(params: [String: Any]) async -> V2CallResult { - let resolution = mobileTerminalArtifactContext(params: params, requiresPath: true) + let resolution = await mobileTerminalArtifactContext(params: params, requiresPath: true) guard case .success(let context) = resolution else { return resolution.failureResult } @@ -155,7 +160,7 @@ extension TerminalController { } func v2MobileTerminalArtifactList(params: [String: Any]) async -> V2CallResult { - let resolution = mobileTerminalArtifactContext(params: params, requiresPath: true) + let resolution = await mobileTerminalArtifactContext(params: params, requiresPath: true) guard case .success(let context) = resolution else { return resolution.failureResult } @@ -181,7 +186,7 @@ extension TerminalController { requiresPath: Bool, includeScrollback: Bool = true, includeTerminalText: Bool = true - ) -> TerminalArtifactContextResolution { + ) async -> TerminalArtifactContextResolution { guard let workspaceID = v2RawString(params, "workspace_id")?.trimmingCharacters(in: .whitespacesAndNewlines), let surfaceID = v2RawString(params, "surface_id")?.trimmingCharacters(in: .whitespacesAndNewlines), !workspaceID.isEmpty, @@ -196,6 +201,12 @@ extension TerminalController { data: nil )) } + let scanAuthorizedPaths = requiresPath + ? await terminalArtifactAuthorizationStore.authorizedPaths( + workspaceID: workspaceID, + surfaceID: surfaceID + ) + : [] let directoryAccessMode = mobileArtifactDirectoryAccessMode() return v2MainSync { () -> TerminalArtifactContextResolution in guard let resolved = mobileResolveWorkspaceAndSurface(params: params, requireTerminal: true), @@ -219,10 +230,13 @@ extension TerminalController { $0.currentOrMostRecentSessionRecord(surfaceID: resolvedSurfaceID.uuidString)?.sessionID } return .success(TerminalArtifactReadContext( + workspaceID: workspaceID, + surfaceID: surfaceID, terminalText: terminalText, workingDirectory: workingDirectory, requestedPath: v2RawString(params, "path"), sessionID: sessionID, + scanAuthorizedPaths: scanAuthorizedPaths, directoryAccessMode: directoryAccessMode )) } @@ -305,23 +319,32 @@ private struct TerminalArtifactReadContext: Sendable { case forbidden } + let workspaceID: String + let surfaceID: String private let terminalText: String private let workingDirectory: String? let requestedPath: String? let sessionID: String? + private let scanAuthorizedPaths: Set private let directoryAccessMode: ChatArtifactScope.DirectoryAccessMode init( + workspaceID: String, + surfaceID: String, terminalText: String, workingDirectory: String?, requestedPath: String?, sessionID: String?, + scanAuthorizedPaths: Set, directoryAccessMode: ChatArtifactScope.DirectoryAccessMode ) { + self.workspaceID = workspaceID + self.surfaceID = surfaceID self.terminalText = terminalText self.workingDirectory = workingDirectory self.requestedPath = requestedPath self.sessionID = sessionID + self.scanAuthorizedPaths = scanAuthorizedPaths self.directoryAccessMode = directoryAccessMode } @@ -351,10 +374,19 @@ private struct TerminalArtifactReadContext: Sendable { _ operation: (ArtifactByteReader, String) throws -> T ) throws -> T { guard let requestedPath else { throw Error.forbidden } + let resolver = ChatArtifactScope.FoundationResolver() + let snapshotScope = ChatArtifactScope( + referencedPaths: scanAuthorizedPaths, + directoryAccessMode: directoryAccessMode, + resolver: resolver + ) + if let canonicalPath = snapshotScope.canonicalFilePath(for: requestedPath) { + return try operation(ArtifactByteReader(), canonicalPath) + } let scope = TerminalArtifactScope( terminalText: terminalText, workingDirectory: workingDirectory, - resolver: ChatArtifactScope.FoundationResolver(), + resolver: resolver, directoryAccessMode: directoryAccessMode ) guard let canonicalPath = scope.canonicalPath(for: requestedPath) else { @@ -367,10 +399,19 @@ private struct TerminalArtifactReadContext: Sendable { _ operation: (ArtifactByteReader, String) throws -> T ) throws -> T { guard let requestedPath else { throw Error.forbidden } + let resolver = ChatArtifactScope.FoundationResolver() + let snapshotScope = ChatArtifactScope( + referencedPaths: scanAuthorizedPaths, + directoryAccessMode: directoryAccessMode, + resolver: resolver + ) + if let canonicalPath = snapshotScope.canonicalDirectoryListPath(for: requestedPath) { + return try operation(ArtifactByteReader(), canonicalPath) + } let scope = TerminalArtifactScope( terminalText: terminalText, workingDirectory: workingDirectory, - resolver: ChatArtifactScope.FoundationResolver(), + resolver: resolver, directoryAccessMode: directoryAccessMode ) guard let canonicalPath = scope.canonicalDirectoryListPath(for: requestedPath) else { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 13efbd01ab91..329974eb9ff1 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -16,6 +16,7 @@ import CmuxSwiftRenderUI import Carbon.HIToolbox import CMUXMobileCore import CMUXAgentLaunch +import CmuxAgentChat import Foundation import os import Bonsplit @@ -125,6 +126,7 @@ class TerminalController { @MainActor private(set) var authCoordinator: AuthCoordinator? @MainActor private(set) var browserSignInFlow: HostBrowserSignInFlow? @MainActor var agentChatTranscriptService: AgentChatTranscriptService? + nonisolated let terminalArtifactAuthorizationStore: TerminalArtifactAuthorizationStore // Sendable value type; injected at construction so socket auth never reaches a global. private nonisolated let passwordStore: SocketControlPasswordStore nonisolated let socketClientCapabilityAuthority: SocketClientCapabilityAuthority @@ -354,6 +356,7 @@ class TerminalController { socketClientPreauthorizationLimiter: SocketClientPreauthorizationLimiter = .init( maximumConcurrentClaims: 32 ), + terminalArtifactAuthorizationStore: TerminalArtifactAuthorizationStore = .init(), remoteProxyBroker: any RemoteProxyBrokering = RemoteProxyBroker( tunnelProvider: RemoteDaemonProxyTunnelProvider(strings: .appLocalized, ptyBridgeStrings: AppRemotePTYBridgeStrings()) ) @@ -361,6 +364,7 @@ class TerminalController { self.passwordStore = passwordStore self.socketClientCapabilityAuthority = Self.makeSocketClientCapabilityAuthority() self.socketClientPreauthorizationLimiter = socketClientPreauthorizationLimiter + self.terminalArtifactAuthorizationStore = terminalArtifactAuthorizationStore self.transport = transport self.remoteProxyBroker = remoteProxyBroker let serverEventTarget = ServerEventTarget() From b3044e18ba14ae5b1a78a9ed21a5be1becd90bda Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 21:12:44 -0700 Subject: [PATCH 70/92] fix(ios): unify gallery document glyphs --- .../ChatArtifactGalleryClassifier.swift | 40 ++++++++++++++++++- .../ChatArtifactGalleryClassifierTests.swift | 28 +++++++------ .../TerminalArtifactGalleryItemView.swift | 14 ++----- 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryClassifier.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryClassifier.swift index 38acdee125c7..5890249aa0bc 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryClassifier.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryClassifier.swift @@ -3,10 +3,10 @@ import UniformTypeIdentifiers /// Classifies gallery artifacts into the dedicated sheet filter buckets. public struct ChatArtifactGalleryClassifier: Sendable { - private let logExtensions: Set = ["log", "out", "txt"] + private let logExtensions: Set = ["log", "out"] private let documentExtensions: Set = [ "doc", "docx", "key", "md", "markdown", "mdown", "mkd", "numbers", - "odp", "ods", "odt", "pages", "pdf", "ppt", "pptx", "rtf", "xls", "xlsx", + "odp", "ods", "odt", "pages", "pdf", "ppt", "pptx", "rtf", "txt", "xls", "xlsx", ] /// Creates an artifact classifier. @@ -56,4 +56,40 @@ public struct ChatArtifactGalleryClassifier: Sendable { public func filter(for item: ChatArtifactGalleryItem) -> ChatArtifactGalleryFilter? { filter(for: item.kind, path: item.path) } + + /// Returns the shared SF Symbol for an artifact gallery row. + /// + /// PDF, Office, Markdown, plain-text, and otherwise generic files share + /// one document glyph. Only images, source code, logs, and folders use + /// distinct presentation. + /// + /// - Parameters: + /// - kind: Preview kind assigned by the artifact host. + /// - path: Artifact path whose extension supplements the preview kind. + /// - Returns: SF Symbol name for list and grid placeholders. + public func systemImageName( + for kind: ChatArtifactKind, + path: String + ) -> String { + switch kind { + case .image: + return "photo" + case .directory: + return "folder" + case .text, .binary: + break + } + switch filter(for: kind, path: path) { + case .code: + return "chevron.left.forwardslash.chevron.right" + case .logs: + return "text.alignleft" + case .images: + return "photo" + case .folders: + return "folder" + case .docs, .all, nil: + return "doc.text" + } + } } diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryClassifierTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryClassifierTests.swift index e11925e5395b..052f1dcdae2d 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryClassifierTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryClassifierTests.swift @@ -8,28 +8,30 @@ struct ChatArtifactGalleryClassifierTests { let path: String let kind: ChatArtifactKind let expected: ChatArtifactGalleryFilter? + let expectedSystemImage: String var testDescription: String { "\(kind.rawValue):\(path)" } } @Test(arguments: [ - Fixture(path: "/tmp/photo.swift", kind: .image, expected: .images), - Fixture(path: "/tmp/folder.png", kind: .directory, expected: .folders), - Fixture(path: "/tmp/App.swift", kind: .text, expected: .code), - Fixture(path: "/tmp/main.CPP", kind: .binary, expected: .code), - Fixture(path: "/tmp/run.log", kind: .text, expected: .logs), - Fixture(path: "/tmp/process.OUT", kind: .binary, expected: .logs), - Fixture(path: "/tmp/notes.txt", kind: .text, expected: .logs), - Fixture(path: "/tmp/README.md", kind: .text, expected: .docs), - Fixture(path: "/tmp/report.PDF", kind: .binary, expected: .docs), - Fixture(path: "/tmp/deck.pptx", kind: .binary, expected: .docs), - Fixture(path: "/tmp/table.numbers", kind: .binary, expected: .docs), - Fixture(path: "/tmp/archive.zip", kind: .binary, expected: nil), - Fixture(path: "/tmp/LICENSE", kind: .text, expected: nil), + Fixture(path: "/tmp/photo.swift", kind: .image, expected: .images, expectedSystemImage: "photo"), + Fixture(path: "/tmp/folder.png", kind: .directory, expected: .folders, expectedSystemImage: "folder"), + Fixture(path: "/tmp/App.swift", kind: .text, expected: .code, expectedSystemImage: "chevron.left.forwardslash.chevron.right"), + Fixture(path: "/tmp/main.CPP", kind: .binary, expected: .code, expectedSystemImage: "chevron.left.forwardslash.chevron.right"), + Fixture(path: "/tmp/run.log", kind: .text, expected: .logs, expectedSystemImage: "text.alignleft"), + Fixture(path: "/tmp/process.OUT", kind: .binary, expected: .logs, expectedSystemImage: "text.alignleft"), + Fixture(path: "/tmp/notes.txt", kind: .text, expected: .docs, expectedSystemImage: "doc.text"), + Fixture(path: "/tmp/README.md", kind: .text, expected: .docs, expectedSystemImage: "doc.text"), + Fixture(path: "/tmp/report.PDF", kind: .binary, expected: .docs, expectedSystemImage: "doc.text"), + Fixture(path: "/tmp/deck.pptx", kind: .binary, expected: .docs, expectedSystemImage: "doc.text"), + Fixture(path: "/tmp/table.numbers", kind: .binary, expected: .docs, expectedSystemImage: "doc.text"), + Fixture(path: "/tmp/archive.zip", kind: .binary, expected: nil, expectedSystemImage: "doc.text"), + Fixture(path: "/tmp/LICENSE", kind: .text, expected: nil, expectedSystemImage: "doc.text"), ]) func classifiesKindAndExtensionMatrix(_ fixture: Fixture) { let classifier = ChatArtifactGalleryClassifier() #expect(classifier.filter(for: fixture.kind, path: fixture.path) == fixture.expected) + #expect(classifier.systemImageName(for: fixture.kind, path: fixture.path) == fixture.expectedSystemImage) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift index a0b98c535bf1..671bcf236906 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift @@ -312,16 +312,10 @@ struct TerminalArtifactGalleryItemView: View { } private var symbolName: String { - switch artifact.kind { - case .image: - return "photo" - case .text: - return "doc.text" - case .binary: - return "doc.fill" - case .directory: - return "folder" - } + ChatArtifactGalleryClassifier().systemImageName( + for: artifact.kind, + path: artifact.path + ) } private static let thumbnailDimension = 256 From 7a8c57eeec2d9015a4fe87f8af53c2052a7bf1a3 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 21:16:30 -0700 Subject: [PATCH 71/92] fix(ios): keep text end jumps at final content --- .../ChatArtifactTextEndJumpTarget.swift | 11 +++ .../Artifacts/ChatArtifactTextView.swift | 35 ++++---- .../ChatArtifactTextViewCoordinator.swift | 87 +++++++++++++++++++ .../Artifacts/ChatArtifactViewerPager.swift | 18 ++-- .../Resources/Localizable.xcstrings | 17 ++++ .../ChatArtifactTextEndJumpTargetTests.swift | 12 +++ 6 files changed, 153 insertions(+), 27 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextEndJumpTarget.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextEndJumpTargetTests.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextEndJumpTarget.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextEndJumpTarget.swift new file mode 100644 index 000000000000..cf63fb548812 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextEndJumpTarget.swift @@ -0,0 +1,11 @@ +/// Semantic destination for the artifact viewer's trailing-edge jump. +enum ChatArtifactTextEndJumpTarget: Equatable, Sendable { + /// Most recently loaded text while content is still streaming. + case latest + /// Final document end after EOF is known. + case end + + init(reachedEOF: Bool) { + self = reachedEOF ? .end : .latest + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift index bd4056c365f1..9e619875c0ee 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift @@ -50,7 +50,7 @@ struct ChatArtifactTextView: UIViewRepresentable { textView.selectedRange = NSRange(location: 0, length: 0) context.coordinator.documentID = documentID context.coordinator.handledTopRequestID = topRequestID - context.coordinator.handledBottomRequestID = bottomRequestID + context.coordinator.handledBottomRequestID = 0 context.coordinator.handledGoToLineRequestID = goToLineRequestID } @@ -121,25 +121,22 @@ struct ChatArtifactTextView: UIViewRepresentable { if isNewDocument { coordinator.scrollToTop(in: textView, animated: false) - } else { - if coordinator.handledTopRequestID != topRequestID { - coordinator.handledTopRequestID = topRequestID - coordinator.scrollToTop(in: textView, animated: true) - } - if coordinator.handledBottomRequestID != bottomRequestID { - coordinator.handledBottomRequestID = bottomRequestID - textView.scrollRangeToVisible( - NSRange(location: textView.textStorage.length, length: 0) - ) - } - if coordinator.handledGoToLineRequestID != goToLineRequestID { - coordinator.handledGoToLineRequestID = goToLineRequestID - textView.scrollRangeToVisible(NSRange( - location: min(max(goToLineUTF16Offset, 0), textView.textStorage.length), - length: 0 - )) - } + } else if coordinator.handledTopRequestID != topRequestID { + coordinator.handledTopRequestID = topRequestID + coordinator.scrollToTop(in: textView, animated: true) } + if coordinator.handledBottomRequestID != bottomRequestID { + coordinator.handledBottomRequestID = bottomRequestID + coordinator.requestEndJump( + ChatArtifactTextEndJumpTarget(reachedEOF: reachedEOF), + in: textView + ) + } + if coordinator.handledGoToLineRequestID != goToLineRequestID { + coordinator.handledGoToLineRequestID = goToLineRequestID + coordinator.scrollToUTF16Offset(goToLineUTF16Offset, in: textView) + } + coordinator.reconcileEndJump(reachedEOF: reachedEOF, in: textView) } } #endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift index d89762a77c87..5dbaf1fd10ac 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift @@ -17,6 +17,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { private var pendingTextAttributes: [NSAttributedString.Key: Any] = [:] private var pendingLineNumberUpdate: (index: ChatArtifactLineIndex, isVisible: Bool)? private var latestPostAppendWork: (() -> Void)? + private var endJumpTarget: ChatArtifactTextEndJumpTarget? private weak var containerView: ChatArtifactTextContainerView? private let syntaxHighlighter = ChatArtifactSyntaxHighlighter() private var highlightTask: Task? @@ -67,6 +68,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + endJumpTarget = nil appendPolicy.beginTracking() suspendTextStorageWork() } @@ -98,6 +100,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { pendingTextAttributes.removeAll(keepingCapacity: false) pendingLineNumberUpdate = nil latestPostAppendWork = nil + endJumpTarget = nil appliedChunkCount = 0 } @@ -130,6 +133,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { } func scrollToTop(in textView: UITextView, animated: Bool) { + endJumpTarget = nil let target = CGPoint( x: -textView.adjustedContentInset.left, y: -textView.adjustedContentInset.top @@ -144,6 +148,30 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { textView.setContentOffset(target, animated: requiresAnimation) } + func requestEndJump( + _ target: ChatArtifactTextEndJumpTarget, + in textView: UITextView + ) { + endJumpTarget = target + scrollToDocumentEnd(in: textView, animated: true) + settleEndJumpIfReady(in: textView) + } + + func reconcileEndJump(reachedEOF: Bool, in textView: UITextView) { + if reachedEOF, endJumpTarget == .latest { + endJumpTarget = .end + } + settleEndJumpIfReady(in: textView) + } + + func scrollToUTF16Offset(_ offset: Int, in textView: UITextView) { + endJumpTarget = nil + textView.scrollRangeToVisible(NSRange( + location: min(max(offset, 0), textView.textStorage.length), + length: 0 + )) + } + private func resumeDeferredUpdates( releasedChunkCount: Int, in textView: UITextView? @@ -154,6 +182,9 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { } applyPendingLineNumbersIfReady() runPostAppendWorkIfReady() + if let textView { + settleEndJumpIfReady(in: textView) + } } private func flushPendingText(releasedChunkCount: Int, in textView: UITextView) { @@ -176,10 +207,65 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { textView.selectedRange = selection textView.setContentOffset(contentOffset, animated: false) + settleEndJumpIfReady(in: textView) applyPendingLineNumbersIfReady() runPostAppendWorkIfReady() } + /// Replays a durable latest/end intent after streamed edits become visible. + private func settleEndJumpIfReady(in textView: UITextView) { + guard let target = endJumpTarget, + !appendPolicy.isDeferring, + pendingTextChunks.isEmpty else { return } + scrollToDocumentEnd(in: textView, animated: false) + if target == .end { + endJumpTarget = nil + } + } + + /// Lays out only the final TextKit 1 character range and scrolls to its true bottom. + private func scrollToDocumentEnd(in textView: UITextView, animated: Bool) { + let minimumY = -textView.adjustedContentInset.top + var documentBottom = textView.textContainerInset.top + + textView.textContainerInset.bottom + if textView.textStorage.length > 0 { + let finalCharacterRange = NSRange( + location: textView.textStorage.length - 1, + length: 1 + ) + textView.layoutManager.allowsNonContiguousLayout = true + textView.layoutManager.ensureLayout(forCharacterRange: finalCharacterRange) + let finalGlyphRange = textView.layoutManager.glyphRange( + forCharacterRange: finalCharacterRange, + actualCharacterRange: nil + ) + let finalGlyphRect = textView.layoutManager.boundingRect( + forGlyphRange: finalGlyphRange, + in: textView.textContainer + ) + documentBottom += max( + finalGlyphRect.maxY, + textView.layoutManager.extraLineFragmentRect.maxY + ) + } + let target = CGPoint( + x: textView.contentOffset.x, + y: max( + minimumY, + documentBottom + - textView.bounds.height + + textView.adjustedContentInset.bottom + ) + ) + let requiresAnimation = animated + && abs(textView.contentOffset.y - target.y) > 0.5 + if requiresAnimation { + appendPolicy.beginProgrammaticAnimation() + suspendTextStorageWork() + } + textView.setContentOffset(target, animated: requiresAnimation) + } + private func applyPendingLineNumbersIfReady() { guard !appendPolicy.isDeferring, pendingTextChunks.isEmpty, @@ -553,6 +639,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { ) appliedSearchRange = currentRange if scrollToMatch { + endJumpTarget = nil textView.scrollRangeToVisible(currentRange) } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift index 62b8333b218d..9b1aae10a66c 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift @@ -301,7 +301,7 @@ struct ChatArtifactViewerPager: View { Button { model.requestBottom() } label: { - Label(jumpToBottomTitle(snapshot: snapshot), systemImage: "arrow.down.to.line") + Label(jumpToEndTitle(snapshot: snapshot), systemImage: "arrow.down.to.line") } Button { model.toggleLineNumbers() @@ -359,19 +359,21 @@ struct ChatArtifactViewerPager: View { ) } - private func jumpToBottomTitle(snapshot: ChatArtifactViewerPageSnapshot) -> String { - if snapshot.textReachedEOF { + private func jumpToEndTitle(snapshot: ChatArtifactViewerPageSnapshot) -> String { + switch ChatArtifactTextEndJumpTarget(reachedEOF: snapshot.textReachedEOF) { + case .end: return String( localized: "chat.artifact.jump.end", defaultValue: "End", bundle: .module ) + case .latest: + return String( + localized: "chat.artifact.jump.latest", + defaultValue: "Latest", + bundle: .module + ) } - return String( - localized: "chat.artifact.jump.bottom", - defaultValue: "Bottom", - bundle: .module - ) } #endif } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index 38ecf67ee9d2..d2191bae9d49 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -315,6 +315,23 @@ } } }, + "chat.artifact.jump.latest" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Latest" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "最新" + } + } + } + }, "chat.artifact.jump.top" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextEndJumpTargetTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextEndJumpTargetTests.swift new file mode 100644 index 000000000000..bb31e0143977 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextEndJumpTargetTests.swift @@ -0,0 +1,12 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact text end jump target") +struct ChatArtifactTextEndJumpTargetTests { + @Test("streaming targets latest and EOF targets the final end") + func followsStreamingState() { + #expect(ChatArtifactTextEndJumpTarget(reachedEOF: false) == .latest) + #expect(ChatArtifactTextEndJumpTarget(reachedEOF: true) == .end) + } +} From b46bad563f893940ea78c64dd90e74114a9141b1 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 18:08:20 -0700 Subject: [PATCH 72/92] fix: restore main's ghostty and bonsplit submodule pointers The consolidation merge staged stale worktree submodule checkouts, reverting main's pointers this branch never intended to touch. Co-Authored-By: Claude Fable 5 --- ghostty | 2 +- vendor/bonsplit | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ghostty b/ghostty index eb500e9f45c8..bb30526cdab8 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit eb500e9f45c8b6ffa6043350ec1488a42d195406 +Subproject commit bb30526cdab8f5fb08ae43e404e3aacc40d3ffc3 diff --git a/vendor/bonsplit b/vendor/bonsplit index a0400b3533db..10563e2fda6f 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit a0400b3533db21afda4a5a59e695cbff588a39b7 +Subproject commit 10563e2fda6fc18c47adf1864d55e0e25087a864 From 6839a26eaab3a25990d4f15b5c3e6ec50095d571 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 18:23:17 -0700 Subject: [PATCH 73/92] test(ios): cover jump convergence and artifact glyph rules --- .../ChatArtifactGalleryClassifierTests.swift | 110 +++++++++++++++--- ...ChatArtifactTextJumpConvergenceTests.swift | 28 +++++ 2 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextJumpConvergenceTests.swift diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryClassifierTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryClassifierTests.swift index 052f1dcdae2d..551a10db9763 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryClassifierTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryClassifierTests.swift @@ -8,30 +8,110 @@ struct ChatArtifactGalleryClassifierTests { let path: String let kind: ChatArtifactKind let expected: ChatArtifactGalleryFilter? - let expectedSystemImage: String + let expectedGlyph: ChatArtifactGalleryGlyphPresentation var testDescription: String { "\(kind.rawValue):\(path)" } } @Test(arguments: [ - Fixture(path: "/tmp/photo.swift", kind: .image, expected: .images, expectedSystemImage: "photo"), - Fixture(path: "/tmp/folder.png", kind: .directory, expected: .folders, expectedSystemImage: "folder"), - Fixture(path: "/tmp/App.swift", kind: .text, expected: .code, expectedSystemImage: "chevron.left.forwardslash.chevron.right"), - Fixture(path: "/tmp/main.CPP", kind: .binary, expected: .code, expectedSystemImage: "chevron.left.forwardslash.chevron.right"), - Fixture(path: "/tmp/run.log", kind: .text, expected: .logs, expectedSystemImage: "text.alignleft"), - Fixture(path: "/tmp/process.OUT", kind: .binary, expected: .logs, expectedSystemImage: "text.alignleft"), - Fixture(path: "/tmp/notes.txt", kind: .text, expected: .docs, expectedSystemImage: "doc.text"), - Fixture(path: "/tmp/README.md", kind: .text, expected: .docs, expectedSystemImage: "doc.text"), - Fixture(path: "/tmp/report.PDF", kind: .binary, expected: .docs, expectedSystemImage: "doc.text"), - Fixture(path: "/tmp/deck.pptx", kind: .binary, expected: .docs, expectedSystemImage: "doc.text"), - Fixture(path: "/tmp/table.numbers", kind: .binary, expected: .docs, expectedSystemImage: "doc.text"), - Fixture(path: "/tmp/archive.zip", kind: .binary, expected: nil, expectedSystemImage: "doc.text"), - Fixture(path: "/tmp/LICENSE", kind: .text, expected: nil, expectedSystemImage: "doc.text"), + Fixture( + path: "/tmp/photo.swift", + kind: .image, + expected: .images, + expectedGlyph: .init(systemImageName: "photo", tint: .secondary) + ), + Fixture( + path: "/tmp/folder.png", + kind: .directory, + expected: .folders, + expectedGlyph: .init(systemImageName: "folder", tint: .accent) + ), + Fixture( + path: "/tmp/App.swift", + kind: .text, + expected: .code, + expectedGlyph: .init( + systemImageName: "chevron.left.forwardslash.chevron.right", + tint: .accent + ) + ), + Fixture( + path: "/tmp/seed.py", + kind: .text, + expected: .code, + expectedGlyph: .init( + systemImageName: "chevron.left.forwardslash.chevron.right", + tint: .accent + ) + ), + Fixture( + path: "/tmp/main.CPP", + kind: .binary, + expected: .code, + expectedGlyph: .init( + systemImageName: "chevron.left.forwardslash.chevron.right", + tint: .accent + ) + ), + Fixture( + path: "/tmp/run.log", + kind: .text, + expected: .logs, + expectedGlyph: .init(systemImageName: "text.alignleft", tint: .accent) + ), + Fixture( + path: "/tmp/process.OUT", + kind: .binary, + expected: .logs, + expectedGlyph: .init(systemImageName: "text.alignleft", tint: .accent) + ), + Fixture( + path: "/tmp/report.PDF", + kind: .binary, + expected: .docs, + expectedGlyph: .init(systemImageName: "doc.text", tint: .accent) + ), + Fixture( + path: "/tmp/report.docx", + kind: .binary, + expected: .docs, + expectedGlyph: .init(systemImageName: "doc.text", tint: .accent) + ), + Fixture( + path: "/tmp/README.md", + kind: .text, + expected: .docs, + expectedGlyph: .init(systemImageName: "doc.text", tint: .accent) + ), + Fixture( + path: "/tmp/notes.txt", + kind: .text, + expected: .docs, + expectedGlyph: .init(systemImageName: "doc.text", tint: .accent) + ), + Fixture( + path: "/tmp/table.numbers", + kind: .binary, + expected: .docs, + expectedGlyph: .init(systemImageName: "doc.text", tint: .accent) + ), + Fixture( + path: "/tmp/archive.zip", + kind: .binary, + expected: nil, + expectedGlyph: .init(systemImageName: "doc.text", tint: .secondary) + ), + Fixture( + path: "/tmp/LICENSE", + kind: .text, + expected: nil, + expectedGlyph: .init(systemImageName: "doc.text", tint: .accent) + ), ]) func classifiesKindAndExtensionMatrix(_ fixture: Fixture) { let classifier = ChatArtifactGalleryClassifier() #expect(classifier.filter(for: fixture.kind, path: fixture.path) == fixture.expected) - #expect(classifier.systemImageName(for: fixture.kind, path: fixture.path) == fixture.expectedSystemImage) + #expect(classifier.glyphPresentation(for: fixture.kind, path: fixture.path) == fixture.expectedGlyph) } } diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextJumpConvergenceTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextJumpConvergenceTests.swift new file mode 100644 index 000000000000..0a6292b8a3f4 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextJumpConvergenceTests.swift @@ -0,0 +1,28 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact text jump convergence") +struct ChatArtifactTextJumpConvergenceTests { + @Test("retargets a stale laid-out window until the requested boundary is stable") + func retargetsUntilStable() { + var top = ChatArtifactTextJumpConvergence(initialTargetOffset: 0) + #expect(top.decision(observedOffset: 149_188, targetOffset: 0) == .retarget(offset: 0)) + #expect(top.decision(observedOffset: 0, targetOffset: 0) == .finish) + + var end = ChatArtifactTextJumpConvergence(initialTargetOffset: 19_200) + #expect(end.decision(observedOffset: 19_200, targetOffset: 168_000) == .retarget(offset: 168_000)) + #expect(end.decision(observedOffset: 168_000, targetOffset: 168_000) == .finish) + } + + @Test("forces the latest boundary after the bounded retarget budget") + func boundsRetargeting() { + var convergence = ChatArtifactTextJumpConvergence( + initialTargetOffset: 20_000, + maximumRetargetCount: 1 + ) + + #expect(convergence.decision(observedOffset: 20_000, targetOffset: 40_000) == .retarget(offset: 40_000)) + #expect(convergence.decision(observedOffset: 40_000, targetOffset: 80_000) == .force(offset: 80_000)) + } +} From 35d18edfdc38e02909b2e500fc008feb1e9cbac7 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 18:31:46 -0700 Subject: [PATCH 74/92] fix(ios): converge viewer jumps and unify glyphs --- .../ChatArtifactGalleryClassifier.swift | 50 +++-- ...ChatArtifactGalleryGlyphPresentation.swift | 18 ++ .../ChatArtifactGalleryGlyphTint.swift | 8 + .../Artifacts/ChatArtifactFolderView.swift | 31 ++- ...ChatArtifactGalleryGlyphTint+SwiftUI.swift | 14 ++ .../ChatArtifactTextJumpConvergence.swift | 37 ++++ .../ChatArtifactTextViewCoordinator.swift | 198 +++++++++++++++--- .../TerminalArtifactGalleryItemView.swift | 24 +-- 8 files changed, 302 insertions(+), 78 deletions(-) create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGlyphPresentation.swift create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGlyphTint.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactGalleryGlyphTint+SwiftUI.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextJumpConvergence.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryClassifier.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryClassifier.swift index 5890249aa0bc..749197c2c948 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryClassifier.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryClassifier.swift @@ -57,7 +57,7 @@ public struct ChatArtifactGalleryClassifier: Sendable { filter(for: item.kind, path: item.path) } - /// Returns the shared SF Symbol for an artifact gallery row. + /// Returns the shared glyph and tint for an artifact gallery row. /// /// PDF, Office, Markdown, plain-text, and otherwise generic files share /// one document glyph. Only images, source code, logs, and folders use @@ -66,30 +66,56 @@ public struct ChatArtifactGalleryClassifier: Sendable { /// - Parameters: /// - kind: Preview kind assigned by the artifact host. /// - path: Artifact path whose extension supplements the preview kind. - /// - Returns: SF Symbol name for list and grid placeholders. - public func systemImageName( + /// - Returns: Glyph presentation for list, grid, and folder-browser placeholders. + public func glyphPresentation( for kind: ChatArtifactKind, path: String - ) -> String { + ) -> ChatArtifactGalleryGlyphPresentation { switch kind { case .image: - return "photo" + return ChatArtifactGalleryGlyphPresentation( + systemImageName: "photo", + tint: .secondary + ) case .directory: - return "folder" + return ChatArtifactGalleryGlyphPresentation( + systemImageName: "folder", + tint: .accent + ) case .text, .binary: break } switch filter(for: kind, path: path) { case .code: - return "chevron.left.forwardslash.chevron.right" + return ChatArtifactGalleryGlyphPresentation( + systemImageName: "chevron.left.forwardslash.chevron.right", + tint: .accent + ) case .logs: - return "text.alignleft" + return ChatArtifactGalleryGlyphPresentation( + systemImageName: "text.alignleft", + tint: .accent + ) case .images: - return "photo" + return ChatArtifactGalleryGlyphPresentation( + systemImageName: "photo", + tint: .secondary + ) case .folders: - return "folder" - case .docs, .all, nil: - return "doc.text" + return ChatArtifactGalleryGlyphPresentation( + systemImageName: "folder", + tint: .accent + ) + case .docs, .all: + return ChatArtifactGalleryGlyphPresentation( + systemImageName: "doc.text", + tint: .accent + ) + case nil: + return ChatArtifactGalleryGlyphPresentation( + systemImageName: "doc.text", + tint: kind == .text ? .accent : .secondary + ) } } } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGlyphPresentation.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGlyphPresentation.swift new file mode 100644 index 000000000000..721ded3e58fb --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGlyphPresentation.swift @@ -0,0 +1,18 @@ +/// Shared glyph and tint presentation for one artifact row or tile. +public struct ChatArtifactGalleryGlyphPresentation: Sendable, Equatable { + /// SF Symbol rendered when an image thumbnail is unavailable or inapplicable. + public let systemImageName: String + + /// Semantic tint applied to the symbol. + public let tint: ChatArtifactGalleryGlyphTint + + /// Creates a shared artifact glyph presentation. + /// + /// - Parameters: + /// - systemImageName: SF Symbol rendered by list and grid consumers. + /// - tint: Semantic symbol tint. + public init(systemImageName: String, tint: ChatArtifactGalleryGlyphTint) { + self.systemImageName = systemImageName + self.tint = tint + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGlyphTint.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGlyphTint.swift new file mode 100644 index 000000000000..f1ba1858773e --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGlyphTint.swift @@ -0,0 +1,8 @@ +/// Semantic tint shared by every artifact gallery glyph consumer. +public enum ChatArtifactGalleryGlyphTint: Sendable, Equatable { + /// The gallery's blue interactive accent. + case accent + + /// A subdued placeholder for images and otherwise generic binary files. + case secondary +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift index 6bc7911f0526..3045fc84301c 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift @@ -163,9 +163,7 @@ private struct ChatArtifactFolderThumbnail: View { artifactImage(data: thumbnailData) .scaledToFill() } else { - Image(systemName: entry.isDirectory ? "folder" : iconName) - .font(.body) - .foregroundStyle(.secondary) + placeholder } } .frame(width: 34, height: 34) @@ -183,29 +181,28 @@ private struct ChatArtifactFolderThumbnail: View { if let image = UIImage(data: data) { Image(uiImage: image).resizable() } else { - Image(systemName: iconName) + placeholder } #elseif canImport(AppKit) if let image = NSImage(data: data) { Image(nsImage: image).resizable() } else { - Image(systemName: iconName) + placeholder } #else - Image(systemName: iconName) + placeholder #endif } - private var iconName: String { - switch entry.kind { - case .image: - return "photo" - case .text: - return "doc.text" - case .binary: - return "doc" - case .directory: - return "folder" - } + private var placeholder: some View { + let kind: ChatArtifactKind = entry.isDirectory ? .directory : entry.kind + let glyph = ChatArtifactGalleryClassifier().glyphPresentation( + for: kind, + path: path + ) + return Image(systemName: glyph.systemImageName) + .font(.body) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(glyph.tint.swiftUIColor) } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactGalleryGlyphTint+SwiftUI.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactGalleryGlyphTint+SwiftUI.swift new file mode 100644 index 000000000000..0356d7300d4f --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactGalleryGlyphTint+SwiftUI.swift @@ -0,0 +1,14 @@ +import CmuxAgentChat +import SwiftUI + +public extension ChatArtifactGalleryGlyphTint { + /// SwiftUI color corresponding to the shared gallery tint. + var swiftUIColor: Color { + switch self { + case .accent: + return .blue + case .secondary: + return .secondary + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextJumpConvergence.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextJumpConvergence.swift new file mode 100644 index 000000000000..e98c5237cb95 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextJumpConvergence.swift @@ -0,0 +1,37 @@ +/// Bounded policy for retargeting a document-boundary scroll after layout changes. +struct ChatArtifactTextJumpConvergence: Equatable, Sendable { + enum Decision: Equatable, Sendable { + case finish + case retarget(offset: Double) + case force(offset: Double) + } + + private let tolerance: Double + private var previousTargetOffset: Double + private var remainingRetargetCount: Int + + init( + initialTargetOffset: Double, + maximumRetargetCount: Int = 24, + tolerance: Double = 0.5 + ) { + previousTargetOffset = initialTargetOffset + remainingRetargetCount = max(0, maximumRetargetCount) + self.tolerance = max(0, tolerance) + } + + mutating func decision(observedOffset: Double, targetOffset: Double) -> Decision { + let reachedTarget = abs(observedOffset - targetOffset) <= tolerance + let targetIsStable = abs(previousTargetOffset - targetOffset) <= tolerance + if reachedTarget, targetIsStable { + return .finish + } + + previousTargetOffset = targetOffset + guard remainingRetargetCount > 0 else { + return .force(offset: targetOffset) + } + remainingRetargetCount -= 1 + return .retarget(offset: targetOffset) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift index 5dbaf1fd10ac..36dca6d6b9eb 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift @@ -17,7 +17,9 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { private var pendingTextAttributes: [NSAttributedString.Key: Any] = [:] private var pendingLineNumberUpdate: (index: ChatArtifactLineIndex, isVisible: Bool)? private var latestPostAppendWork: (() -> Void)? + private var topJumpConvergence: ChatArtifactTextJumpConvergence? private var endJumpTarget: ChatArtifactTextEndJumpTarget? + private var endJumpConvergence: ChatArtifactTextJumpConvergence? private weak var containerView: ChatArtifactTextContainerView? private let syntaxHighlighter = ChatArtifactSyntaxHighlighter() private var highlightTask: Task? @@ -68,7 +70,9 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + topJumpConvergence = nil endJumpTarget = nil + endJumpConvergence = nil appendPolicy.beginTracking() suspendTextStorageWork() } @@ -100,7 +104,9 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { pendingTextAttributes.removeAll(keepingCapacity: false) pendingLineNumberUpdate = nil latestPostAppendWork = nil + topJumpConvergence = nil endJumpTarget = nil + endJumpConvergence = nil appliedChunkCount = 0 } @@ -134,38 +140,53 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { func scrollToTop(in textView: UITextView, animated: Bool) { endJumpTarget = nil - let target = CGPoint( - x: -textView.adjustedContentInset.left, - y: -textView.adjustedContentInset.top + endJumpConvergence = nil + let target = documentTopContentOffset(in: textView) + guard animated else { + topJumpConvergence = nil + textView.setContentOffset(target, animated: false) + return + } + + topJumpConvergence = ChatArtifactTextJumpConvergence( + initialTargetOffset: Double(target.y) ) - let requiresAnimation = animated - && (abs(textView.contentOffset.x - target.x) > 0.5 - || abs(textView.contentOffset.y - target.y) > 0.5) - if requiresAnimation { - appendPolicy.beginProgrammaticAnimation() - suspendTextStorageWork() + if !setContentOffset(target, animated: true, in: textView) { + topJumpConvergence = nil } - textView.setContentOffset(target, animated: requiresAnimation) } func requestEndJump( _ target: ChatArtifactTextEndJumpTarget, in textView: UITextView ) { + topJumpConvergence = nil endJumpTarget = target - scrollToDocumentEnd(in: textView, animated: true) - settleEndJumpIfReady(in: textView) + let contentOffset = documentEndContentOffset(in: textView) + endJumpConvergence = ChatArtifactTextJumpConvergence( + initialTargetOffset: Double(contentOffset.y) + ) + if !setContentOffset(contentOffset, animated: true, in: textView) { + settleBoundaryJumpIfReady(in: textView) + } } func reconcileEndJump(reachedEOF: Bool, in textView: UITextView) { if reachedEOF, endJumpTarget == .latest { endJumpTarget = .end + if endJumpConvergence == nil { + endJumpConvergence = ChatArtifactTextJumpConvergence( + initialTargetOffset: Double(textView.contentOffset.y) + ) + } } - settleEndJumpIfReady(in: textView) + settleBoundaryJumpIfReady(in: textView) } func scrollToUTF16Offset(_ offset: Int, in textView: UITextView) { + topJumpConvergence = nil endJumpTarget = nil + endJumpConvergence = nil textView.scrollRangeToVisible(NSRange( location: min(max(offset, 0), textView.textStorage.length), length: 0 @@ -183,7 +204,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { applyPendingLineNumbersIfReady() runPostAppendWorkIfReady() if let textView { - settleEndJumpIfReady(in: textView) + settleBoundaryJumpIfReady(in: textView) } } @@ -207,24 +228,122 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { textView.selectedRange = selection textView.setContentOffset(contentOffset, animated: false) - settleEndJumpIfReady(in: textView) + settleBoundaryJumpIfReady(in: textView) applyPendingLineNumbersIfReady() runPostAppendWorkIfReady() } - /// Replays a durable latest/end intent after streamed edits become visible. - private func settleEndJumpIfReady(in textView: UITextView) { - guard let target = endJumpTarget, - !appendPolicy.isDeferring, + /// Retargets a boundary intent after each animation or streamed edit changes layout. + private func settleBoundaryJumpIfReady(in textView: UITextView) { + guard !appendPolicy.isDeferring, pendingTextChunks.isEmpty else { return } - scrollToDocumentEnd(in: textView, animated: false) - if target == .end { - endJumpTarget = nil + + if var convergence = topJumpConvergence { + let target = documentTopContentOffset(in: textView) + let decision = convergence.decision( + observedOffset: Double(textView.contentOffset.y), + targetOffset: Double(target.y) + ) + topJumpConvergence = convergence + applyTopJumpDecision(decision, target: target, in: textView) + return + } + + guard let endJumpTarget else { return } + let target = documentEndContentOffset(in: textView) + guard var convergence = endJumpConvergence else { + textView.setContentOffset(target, animated: false) + return + } + let decision = convergence.decision( + observedOffset: Double(textView.contentOffset.y), + targetOffset: Double(target.y) + ) + endJumpConvergence = convergence + applyEndJumpDecision( + decision, + target: target, + semanticTarget: endJumpTarget, + in: textView + ) + } + + private func applyTopJumpDecision( + _ decision: ChatArtifactTextJumpConvergence.Decision, + target: CGPoint, + in textView: UITextView + ) { + switch decision { + case .finish: + topJumpConvergence = nil + case .retarget: + if !setContentOffset(target, animated: true, in: textView) { + textView.layoutIfNeeded() + settleBoundaryJumpIfReady(in: textView) + } + case .force: + settleAtDocumentTop(in: textView) + topJumpConvergence = nil } } - /// Lays out only the final TextKit 1 character range and scrolls to its true bottom. - private func scrollToDocumentEnd(in textView: UITextView, animated: Bool) { + private func applyEndJumpDecision( + _ decision: ChatArtifactTextJumpConvergence.Decision, + target: CGPoint, + semanticTarget: ChatArtifactTextEndJumpTarget, + in textView: UITextView + ) { + switch decision { + case .finish: + endJumpConvergence = nil + if semanticTarget == .end { + endJumpTarget = nil + } + case .retarget: + if !setContentOffset(target, animated: true, in: textView) { + textView.layoutIfNeeded() + settleBoundaryJumpIfReady(in: textView) + } + case .force: + settleAtDocumentEnd(in: textView) + endJumpConvergence = nil + if semanticTarget == .end { + endJumpTarget = nil + } + } + } + + private func setContentOffset( + _ target: CGPoint, + animated: Bool, + in textView: UITextView + ) -> Bool { + let requiresAnimation = animated + && (abs(textView.contentOffset.x - target.x) > 0.5 + || abs(textView.contentOffset.y - target.y) > 0.5) + if requiresAnimation { + appendPolicy.beginProgrammaticAnimation() + suspendTextStorageWork() + } + textView.setContentOffset(target, animated: requiresAnimation) + return requiresAnimation + } + + private func documentTopContentOffset(in textView: UITextView) -> CGPoint { + if textView.textStorage.length > 0 { + textView.layoutManager.allowsNonContiguousLayout = true + textView.layoutManager.ensureLayout( + forCharacterRange: NSRange(location: 0, length: 1) + ) + } + return CGPoint( + x: -textView.adjustedContentInset.left, + y: -textView.adjustedContentInset.top + ) + } + + /// Lays out only the final TextKit 1 character range and returns its true bottom offset. + private func documentEndContentOffset(in textView: UITextView) -> CGPoint { let minimumY = -textView.adjustedContentInset.top var documentBottom = textView.textContainerInset.top + textView.textContainerInset.bottom @@ -248,7 +367,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { textView.layoutManager.extraLineFragmentRect.maxY ) } - let target = CGPoint( + return CGPoint( x: textView.contentOffset.x, y: max( minimumY, @@ -257,13 +376,30 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { + textView.adjustedContentInset.bottom ) ) - let requiresAnimation = animated - && abs(textView.contentOffset.y - target.y) > 0.5 - if requiresAnimation { - appendPolicy.beginProgrammaticAnimation() - suspendTextStorageWork() + } + + /// Settles at the first character without materializing the intervening document. + private func settleAtDocumentTop(in textView: UITextView) { + if textView.textStorage.length > 0 { + let range = NSRange(location: 0, length: 1) + textView.layoutManager.allowsNonContiguousLayout = true + textView.layoutManager.ensureLayout(forCharacterRange: range) + textView.scrollRangeToVisible(range) } - textView.setContentOffset(target, animated: requiresAnimation) + textView.layoutIfNeeded() + textView.setContentOffset(documentTopContentOffset(in: textView), animated: false) + } + + /// Settles at the final character without materializing the intervening document. + private func settleAtDocumentEnd(in textView: UITextView) { + if textView.textStorage.length > 0 { + let range = NSRange(location: textView.textStorage.length - 1, length: 1) + textView.layoutManager.allowsNonContiguousLayout = true + textView.layoutManager.ensureLayout(forCharacterRange: range) + textView.scrollRangeToVisible(range) + } + textView.layoutIfNeeded() + textView.setContentOffset(documentEndContentOffset(in: textView), animated: false) } private func applyPendingLineNumbersIfReady() { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift index 671bcf236906..581d6445a463 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift @@ -236,10 +236,14 @@ struct TerminalArtifactGalleryItemView: View { } private var placeholderSymbol: some View { - Image(systemName: symbolName) + let glyph = ChatArtifactGalleryClassifier().glyphPresentation( + for: artifact.kind, + path: artifact.path + ) + return Image(systemName: glyph.systemImageName) .font(.system(size: layout == .grid ? gridSymbolSize : listSymbolSize, weight: .regular)) .symbolRenderingMode(.hierarchical) - .foregroundStyle(symbolTint) + .foregroundStyle(glyph.tint.swiftUIColor) } private var metadataText: String? { @@ -302,22 +306,6 @@ struct TerminalArtifactGalleryItemView: View { } } - private var symbolTint: Color { - switch artifact.kind { - case .image, .binary: - .secondary - case .text, .directory: - .blue - } - } - - private var symbolName: String { - ChatArtifactGalleryClassifier().systemImageName( - for: artifact.kind, - path: artifact.path - ) - } - private static let thumbnailDimension = 256 } #endif From 08771223d328b689a66ac958818efcbd3dece89a Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 18:40:04 -0700 Subject: [PATCH 75/92] chore(ios): resolve workspace Highlightr pin --- .../xcshareddata/swiftpm/Package.resolved | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved index 505700b66295..0e840e0469d4 100644 --- a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { "originHash" : "d9134cf84862c709f8ed0ba2bf6030fe66ea77a67fec9d41b789ed6c8eaf10a0", "pins" : [ + { + "identity" : "highlightr", + "kind" : "remoteSourceControl", + "location" : "https://github.com/raspu/Highlightr.git", + "state" : { + "revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef", + "version" : "2.3.0" + } + }, { "identity" : "iroh-ffi", "kind" : "remoteSourceControl", From fa3775c28a2e22c39a76101e89dbd0c641b28669 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 21:47:53 -0700 Subject: [PATCH 76/92] test(ios): reproduce cold-open end-jump recursion --- ...ChatArtifactTextJumpConvergenceTests.swift | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextJumpConvergenceTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextJumpConvergenceTests.swift index 0a6292b8a3f4..a10f6efd8acb 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextJumpConvergenceTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextJumpConvergenceTests.swift @@ -25,4 +25,43 @@ struct ChatArtifactTextJumpConvergenceTests { #expect(convergence.decision(observedOffset: 20_000, targetOffset: 40_000) == .retarget(offset: 40_000)) #expect(convergence.decision(observedOffset: 40_000, targetOffset: 80_000) == .force(offset: 80_000)) } + + @Test("a cold-open synchronous settle callback cannot recurse beyond the budget") + func coldOpenSynchronousSettleCallbackCannotExceedBudget() { + let maximumRetargetCount = 2 + let recursionSafetyLimit = 64 + var convergence = ChatArtifactTextJumpConvergence( + initialTargetOffset: 0, + maximumRetargetCount: maximumRetargetCount + ) + var callbackCount = 0 + var forcedBoundaryMaterializationCount = 0 + + func settle(targetOffset: Double) { + guard callbackCount < recursionSafetyLimit else { return } + callbackCount += 1 + + switch convergence.decision( + observedOffset: 0, + targetOffset: targetOffset + ) { + case .finish: + return + case .retarget(let offset): + // An empty cold-open layout window can synchronously complete + // the retarget while discovering a later document boundary. + settle(targetOffset: offset + 20_000) + case .force(let offset): + forcedBoundaryMaterializationCount += 1 + // UITextView can synchronously report animation completion + // while the bounded final-character range is materialized. + settle(targetOffset: offset + 20_000) + } + } + + settle(targetOffset: 20_000) + + #expect(callbackCount <= maximumRetargetCount + 2) + #expect(forcedBoundaryMaterializationCount == 1) + } } From 30f7a9fa55082985b534e2b9c33621cf0b24b17c Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 21:49:12 -0700 Subject: [PATCH 77/92] fix(ios): bound artifact end-jump settling --- .../ChatArtifactTextJumpConvergence.swift | 5 ++ .../ChatArtifactTextViewCoordinator.swift | 46 +++++++++++++------ 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextJumpConvergence.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextJumpConvergence.swift index e98c5237cb95..707d71331844 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextJumpConvergence.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextJumpConvergence.swift @@ -9,6 +9,8 @@ struct ChatArtifactTextJumpConvergence: Equatable, Sendable { private let tolerance: Double private var previousTargetOffset: Double private var remainingRetargetCount: Int + // A synchronous settle callback must not claim the forced fallback twice. + private var exhaustedRetargetBudget = false init( initialTargetOffset: Double, @@ -21,6 +23,8 @@ struct ChatArtifactTextJumpConvergence: Equatable, Sendable { } mutating func decision(observedOffset: Double, targetOffset: Double) -> Decision { + guard !exhaustedRetargetBudget else { return .finish } + let reachedTarget = abs(observedOffset - targetOffset) <= tolerance let targetIsStable = abs(previousTargetOffset - targetOffset) <= tolerance if reachedTarget, targetIsStable { @@ -29,6 +33,7 @@ struct ChatArtifactTextJumpConvergence: Equatable, Sendable { previousTargetOffset = targetOffset guard remainingRetargetCount > 0 else { + exhaustedRetargetBudget = true return .force(offset: targetOffset) } remainingRetargetCount -= 1 diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift index 36dca6d6b9eb..7aa65762b568 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift @@ -20,6 +20,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { private var topJumpConvergence: ChatArtifactTextJumpConvergence? private var endJumpTarget: ChatArtifactTextEndJumpTarget? private var endJumpConvergence: ChatArtifactTextJumpConvergence? + private var isSettlingBoundaryJump = false private weak var containerView: ChatArtifactTextContainerView? private let syntaxHighlighter = ChatArtifactSyntaxHighlighter() private var highlightTask: Task? @@ -235,8 +236,19 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { /// Retargets a boundary intent after each animation or streamed edit changes layout. private func settleBoundaryJumpIfReady(in textView: UITextView) { + // UIKit may synchronously report animation completion while a fallback + // range is being materialized. The active settle already owns that edge. + guard !isSettlingBoundaryJump else { return } + isSettlingBoundaryJump = true + defer { isSettlingBoundaryJump = false } + + while settleBoundaryJumpOnceIfReady(in: textView) {} + } + + /// Performs one budgeted settle step and reports whether layout must be sampled again now. + private func settleBoundaryJumpOnceIfReady(in textView: UITextView) -> Bool { guard !appendPolicy.isDeferring, - pendingTextChunks.isEmpty else { return } + pendingTextChunks.isEmpty else { return false } if var convergence = topJumpConvergence { let target = documentTopContentOffset(in: textView) @@ -245,22 +257,20 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { targetOffset: Double(target.y) ) topJumpConvergence = convergence - applyTopJumpDecision(decision, target: target, in: textView) - return + return applyTopJumpDecision(decision, target: target, in: textView) } - guard let endJumpTarget else { return } + guard let endJumpTarget else { return false } let target = documentEndContentOffset(in: textView) - guard var convergence = endJumpConvergence else { - textView.setContentOffset(target, animated: false) - return - } + var convergence = endJumpConvergence ?? ChatArtifactTextJumpConvergence( + initialTargetOffset: Double(textView.contentOffset.y) + ) let decision = convergence.decision( observedOffset: Double(textView.contentOffset.y), targetOffset: Double(target.y) ) endJumpConvergence = convergence - applyEndJumpDecision( + return applyEndJumpDecision( decision, target: target, semanticTarget: endJumpTarget, @@ -272,18 +282,21 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { _ decision: ChatArtifactTextJumpConvergence.Decision, target: CGPoint, in textView: UITextView - ) { + ) -> Bool { switch decision { case .finish: topJumpConvergence = nil + return false case .retarget: if !setContentOffset(target, animated: true, in: textView) { textView.layoutIfNeeded() - settleBoundaryJumpIfReady(in: textView) + return true } + return false case .force: - settleAtDocumentTop(in: textView) topJumpConvergence = nil + settleAtDocumentTop(in: textView) + return false } } @@ -292,24 +305,27 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { target: CGPoint, semanticTarget: ChatArtifactTextEndJumpTarget, in textView: UITextView - ) { + ) -> Bool { switch decision { case .finish: endJumpConvergence = nil if semanticTarget == .end { endJumpTarget = nil } + return false case .retarget: if !setContentOffset(target, animated: true, in: textView) { textView.layoutIfNeeded() - settleBoundaryJumpIfReady(in: textView) + return true } + return false case .force: - settleAtDocumentEnd(in: textView) endJumpConvergence = nil if semanticTarget == .end { endJumpTarget = nil } + settleAtDocumentEnd(in: textView) + return false } } From 8a106879aa8bc8ddc4de3280b3491c02dde2b23f Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 22:33:34 -0700 Subject: [PATCH 78/92] test(ios): specify durable artifact bottom pin --- ...tifactTextBottomPinStateMachineTests.swift | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift new file mode 100644 index 000000000000..f4a5f210515e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift @@ -0,0 +1,100 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact text bottom pin") +struct ChatArtifactTextBottomPinStateMachineTests { + @Test("End engages a durable bottom pin with one initial animation") + func endEngagesBottomPin() { + var pin = ChatArtifactTextBottomPinStateMachine() + let boundary = ChatArtifactTextBottomBoundary( + storageEnd: 174_000, + contentOffsetY: 149_188 + ) + + #expect( + pin.engage(target: .end, boundary: boundary) + == .scrollToBottom(boundary: boundary, animated: true) + ) + #expect(pin.isPinned) + #expect(pin.target == .end) + #expect(pin.phase == .initialAnimation) + } + + @Test("a pinned view re-pins non-animated across late layout growth") + func repinsAcrossLayoutGrowth() { + var pin = ChatArtifactTextBottomPinStateMachine() + let earlyBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 174_000, + contentOffsetY: 149_188 + ) + let refinedBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 174_000, + contentOffsetY: 168_000 + ) + + _ = pin.engage(target: .end, boundary: earlyBoundary) + #expect( + pin.initialAnimationSettled(at: earlyBoundary) + == .scrollToBottom(boundary: earlyBoundary, animated: false) + ) + pin.didApplyPin(at: earlyBoundary) + + #expect( + pin.layoutChanged(to: refinedBoundary) + == .scrollToBottom(boundary: refinedBoundary, animated: false) + ) + pin.didApplyPin(at: refinedBoundary) + #expect(pin.visibleBoundary == refinedBoundary) + } + + @Test("user interaction exits the pin and later layout cannot re-engage it") + func userInteractionExitsPin() { + var pin = ChatArtifactTextBottomPinStateMachine() + let boundary = ChatArtifactTextBottomBoundary( + storageEnd: 174_000, + contentOffsetY: 168_000 + ) + + _ = pin.engage(target: .end, boundary: boundary) + _ = pin.initialAnimationSettled(at: boundary) + pin.didApplyPin(at: boundary) + pin.userInteracted() + + #expect(!pin.isPinned) + #expect(pin.layoutChanged(to: boundary) == .none) + #expect(pin.visibleBoundary == nil) + } + + @Test("streaming follow-tail finishes with the visible boundary at storage end") + func terminalStateShowsStorageEnd() { + var pin = ChatArtifactTextBottomPinStateMachine() + let partialBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 120_000, + contentOffsetY: 112_000 + ) + let finalBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 174_000, + contentOffsetY: 168_000 + ) + + _ = pin.engage(target: .latest, boundary: partialBoundary) + _ = pin.initialAnimationSettled(at: partialBoundary) + pin.didApplyPin(at: partialBoundary) + + #expect( + pin.appendsFlushed(at: finalBoundary) + == .scrollToBottom(boundary: finalBoundary, animated: false) + ) + pin.didApplyPin(at: finalBoundary) + #expect( + pin.reachedEOF(at: finalBoundary) + == .scrollToBottom(boundary: finalBoundary, animated: false) + ) + pin.didApplyPin(at: finalBoundary) + + #expect(pin.target == .end) + #expect(pin.phase == .following) + #expect(pin.visibleBoundary?.storageEnd == finalBoundary.storageEnd) + } +} From be1bf858ad0692e0e16ee89ed1a6f85a75e3de69 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 22:42:27 -0700 Subject: [PATCH 79/92] fix(ios): keep artifact End bottom-pinned --- .../ChatArtifactTextBottomBoundary.swift | 5 + ...hatArtifactTextBottomPinStateMachine.swift | 94 +++++++++ .../ChatArtifactTextContainerView.swift | 2 +- .../Artifacts/ChatArtifactTextView.swift | 4 +- .../ChatArtifactTextViewCoordinator.swift | 188 +++++++++++++----- .../Artifacts/ChatArtifactUIKitTextView.swift | 34 ++++ ...tifactTextBottomPinStateMachineTests.swift | 25 +++ 7 files changed, 303 insertions(+), 49 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomBoundary.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomPinStateMachine.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactUIKitTextView.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomBoundary.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomBoundary.swift new file mode 100644 index 000000000000..d84eeb6aa193 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomBoundary.swift @@ -0,0 +1,5 @@ +/// One measured TextKit document end used by bottom-pin reconciliation. +struct ChatArtifactTextBottomBoundary: Equatable, Sendable { + let storageEnd: Int + let contentOffsetY: Double +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomPinStateMachine.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomPinStateMachine.swift new file mode 100644 index 000000000000..3a9aa816b2ff --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomPinStateMachine.swift @@ -0,0 +1,94 @@ +/// Owns the durable follow-tail state entered by an artifact End jump. +struct ChatArtifactTextBottomPinStateMachine: Equatable, Sendable { + enum Phase: Equatable, Sendable { + case initialAnimation + case following + } + + enum Action: Equatable, Sendable { + case none + case scrollToBottom( + boundary: ChatArtifactTextBottomBoundary, + animated: Bool + ) + } + + private(set) var target: ChatArtifactTextEndJumpTarget? + private(set) var phase: Phase? + private(set) var visibleBoundary: ChatArtifactTextBottomBoundary? + private var requestedBoundary: ChatArtifactTextBottomBoundary? + + var isPinned: Bool { + target != nil + } + + mutating func engage( + target: ChatArtifactTextEndJumpTarget, + boundary: ChatArtifactTextBottomBoundary + ) -> Action { + self.target = target + phase = .initialAnimation + visibleBoundary = nil + requestedBoundary = boundary + return .scrollToBottom(boundary: boundary, animated: true) + } + + mutating func initialAnimationSettled( + at boundary: ChatArtifactTextBottomBoundary + ) -> Action { + guard isPinned else { return .none } + phase = .following + visibleBoundary = nil + requestedBoundary = boundary + return .scrollToBottom(boundary: boundary, animated: false) + } + + mutating func layoutChanged( + to boundary: ChatArtifactTextBottomBoundary + ) -> Action { + guard phase == .following, + requestedBoundary != boundary else { return .none } + visibleBoundary = nil + requestedBoundary = boundary + return .scrollToBottom(boundary: boundary, animated: false) + } + + mutating func appendsFlushed( + at boundary: ChatArtifactTextBottomBoundary + ) -> Action { + guard phase == .following else { return .none } + visibleBoundary = nil + requestedBoundary = boundary + return .scrollToBottom(boundary: boundary, animated: false) + } + + mutating func reachedEOF( + at boundary: ChatArtifactTextBottomBoundary + ) -> Action { + guard markReachedEOF() else { return .none } + guard phase == .following else { return .none } + visibleBoundary = nil + requestedBoundary = boundary + return .scrollToBottom(boundary: boundary, animated: false) + } + + @discardableResult + mutating func markReachedEOF() -> Bool { + guard target == .latest else { return false } + target = .end + return true + } + + mutating func didApplyPin(at boundary: ChatArtifactTextBottomBoundary) { + guard phase == .following else { return } + visibleBoundary = boundary + requestedBoundary = boundary + } + + mutating func userInteracted() { + target = nil + phase = nil + visibleBoundary = nil + requestedBoundary = nil + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift index 16cdd22aa87b..4122bd31d3be 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift @@ -11,7 +11,7 @@ final class ChatArtifactTextContainerView: UIView { private var wrapsLines = true override init(frame: CGRect) { - textView = UITextView(usingTextLayoutManager: false) + textView = ChatArtifactUIKitTextView() gutterWidthConstraint = gutterView.widthAnchor.constraint(equalToConstant: 0) super.init(frame: frame) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift index 9e619875c0ee..28a596402b00 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextView.swift @@ -28,8 +28,8 @@ struct ChatArtifactTextView: UIViewRepresentable { } func makeUIView(context: Context) -> ChatArtifactTextContainerView { - // The container constructs `UITextView(usingTextLayoutManager: false)` - // so non-contiguous TextKit 1 layout remains genuinely viewport-lazy. + // The container constructs an explicit TextKit 1 storage/layout stack + // so non-contiguous layout remains genuinely viewport-lazy. let containerView = ChatArtifactTextContainerView() containerView.textView.delegate = context.coordinator context.coordinator.attach(containerView) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift index 7aa65762b568..d7795adc500f 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift @@ -4,7 +4,17 @@ import UIKit /// Tracks which streamed chunks have been applied to one text-storage instance. @MainActor -final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { +final class ChatArtifactTextViewCoordinator: + NSObject, + UITextViewDelegate, + UIGestureRecognizerDelegate +{ + private enum BottomPinTrigger { + case layoutChanged + case appendsFlushed + case reachedEOF + } + var documentID: String? var appliedChunkCount = 0 var handledTopRequestID = 0 @@ -18,7 +28,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { private var pendingLineNumberUpdate: (index: ChatArtifactLineIndex, isVisible: Bool)? private var latestPostAppendWork: (() -> Void)? private var topJumpConvergence: ChatArtifactTextJumpConvergence? - private var endJumpTarget: ChatArtifactTextEndJumpTarget? + private var bottomPin = ChatArtifactTextBottomPinStateMachine() private var endJumpConvergence: ChatArtifactTextJumpConvergence? private var isSettlingBoundaryJump = false private weak var containerView: ChatArtifactTextContainerView? @@ -55,6 +65,12 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { recognizer.cancelsTouchesInView = false return recognizer }() + private lazy var bottomPinExitTapGestureRecognizer: UITapGestureRecognizer = { + let recognizer = UITapGestureRecognizer(target: self, action: #selector(handleUserTap(_:))) + recognizer.cancelsTouchesInView = false + recognizer.delegate = self + return recognizer + }() init(searchDebounce: Duration = .milliseconds(160)) { self.searchDebounce = searchDebounce @@ -64,6 +80,13 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { func attach(_ containerView: ChatArtifactTextContainerView) { self.containerView = containerView containerView.textView.addGestureRecognizer(fontPinchGestureRecognizer) + containerView.textView.addGestureRecognizer(bottomPinExitTapGestureRecognizer) + if let textView = containerView.textView as? ChatArtifactUIKitTextView { + textView.onLayoutDidChange = { [weak self, weak textView] in + guard let textView else { return } + self?.textViewLayoutDidChange(in: textView) + } + } } func scrollViewDidScroll(_ scrollView: UIScrollView) { @@ -71,9 +94,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - topJumpConvergence = nil - endJumpTarget = nil - endJumpConvergence = nil + cancelBoundaryJumpOwnership() appendPolicy.beginTracking() suspendTextStorageWork() } @@ -99,6 +120,20 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { ) } + private func textViewLayoutDidChange(in textView: UITextView) { + settleBoundaryJumpIfReady(in: textView, bottomPinTrigger: .layoutChanged) + } + + private func cancelBottomPinOwnership() { + endJumpConvergence = nil + bottomPin.userInteracted() + } + + private func cancelBoundaryJumpOwnership() { + topJumpConvergence = nil + cancelBottomPinOwnership() + } + func resetStreamingText() { appendPolicy.reset() pendingTextChunks.removeAll(keepingCapacity: false) @@ -106,7 +141,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { pendingLineNumberUpdate = nil latestPostAppendWork = nil topJumpConvergence = nil - endJumpTarget = nil + bottomPin = ChatArtifactTextBottomPinStateMachine() endJumpConvergence = nil appliedChunkCount = 0 } @@ -140,8 +175,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { } func scrollToTop(in textView: UITextView, animated: Bool) { - endJumpTarget = nil - endJumpConvergence = nil + cancelBottomPinOwnership() let target = documentTopContentOffset(in: textView) guard animated else { topJumpConvergence = nil @@ -162,32 +196,27 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { in textView: UITextView ) { topJumpConvergence = nil - endJumpTarget = target - let contentOffset = documentEndContentOffset(in: textView) + let boundary = documentEndBoundary(in: textView) endJumpConvergence = ChatArtifactTextJumpConvergence( - initialTargetOffset: Double(contentOffset.y) + initialTargetOffset: boundary.contentOffsetY ) - if !setContentOffset(contentOffset, animated: true, in: textView) { + let action = bottomPin.engage(target: target, boundary: boundary) + if !applyBottomPinAction(action, in: textView) { settleBoundaryJumpIfReady(in: textView) } } func reconcileEndJump(reachedEOF: Bool, in textView: UITextView) { - if reachedEOF, endJumpTarget == .latest { - endJumpTarget = .end - if endJumpConvergence == nil { - endJumpConvergence = ChatArtifactTextJumpConvergence( - initialTargetOffset: Double(textView.contentOffset.y) - ) - } + guard reachedEOF else { return } + if endJumpConvergence != nil || appendPolicy.isDeferring { + bottomPin.markReachedEOF() + return } - settleBoundaryJumpIfReady(in: textView) + settleBoundaryJumpIfReady(in: textView, bottomPinTrigger: .reachedEOF) } func scrollToUTF16Offset(_ offset: Int, in textView: UITextView) { - topJumpConvergence = nil - endJumpTarget = nil - endJumpConvergence = nil + cancelBoundaryJumpOwnership() textView.scrollRangeToVisible(NSRange( location: min(max(offset, 0), textView.textStorage.length), length: 0 @@ -205,7 +234,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { applyPendingLineNumbersIfReady() runPostAppendWorkIfReady() if let textView { - settleBoundaryJumpIfReady(in: textView) + settleBoundaryJumpIfReady(in: textView, bottomPinTrigger: .layoutChanged) } } @@ -229,27 +258,44 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { textView.selectedRange = selection textView.setContentOffset(contentOffset, animated: false) - settleBoundaryJumpIfReady(in: textView) + settleBoundaryJumpIfReady(in: textView, bottomPinTrigger: .appendsFlushed) applyPendingLineNumbersIfReady() runPostAppendWorkIfReady() } - /// Retargets a boundary intent after each animation or streamed edit changes layout. - private func settleBoundaryJumpIfReady(in textView: UITextView) { + /// Settles the bounded initial animation, then lets the durable pin own later changes. + private func settleBoundaryJumpIfReady( + in textView: UITextView, + bottomPinTrigger: BottomPinTrigger? = nil + ) { // UIKit may synchronously report animation completion while a fallback // range is being materialized. The active settle already owns that edge. guard !isSettlingBoundaryJump else { return } isSettlingBoundaryJump = true defer { isSettlingBoundaryJump = false } + guard !appendPolicy.isDeferring, + pendingTextChunks.isEmpty else { return } + while settleBoundaryJumpOnceIfReady(in: textView) {} + + guard endJumpConvergence == nil, + let bottomPinTrigger else { return } + let boundary = documentEndBoundary(in: textView) + let action: ChatArtifactTextBottomPinStateMachine.Action + switch bottomPinTrigger { + case .layoutChanged: + action = bottomPin.layoutChanged(to: boundary) + case .appendsFlushed: + action = bottomPin.appendsFlushed(at: boundary) + case .reachedEOF: + action = bottomPin.reachedEOF(at: boundary) + } + _ = applyBottomPinAction(action, in: textView) } /// Performs one budgeted settle step and reports whether layout must be sampled again now. private func settleBoundaryJumpOnceIfReady(in textView: UITextView) -> Bool { - guard !appendPolicy.isDeferring, - pendingTextChunks.isEmpty else { return false } - if var convergence = topJumpConvergence { let target = documentTopContentOffset(in: textView) let decision = convergence.decision( @@ -260,20 +306,16 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { return applyTopJumpDecision(decision, target: target, in: textView) } - guard let endJumpTarget else { return false } - let target = documentEndContentOffset(in: textView) - var convergence = endJumpConvergence ?? ChatArtifactTextJumpConvergence( - initialTargetOffset: Double(textView.contentOffset.y) - ) + guard var convergence = endJumpConvergence else { return false } + let boundary = documentEndBoundary(in: textView) let decision = convergence.decision( observedOffset: Double(textView.contentOffset.y), - targetOffset: Double(target.y) + targetOffset: boundary.contentOffsetY ) endJumpConvergence = convergence return applyEndJumpDecision( decision, - target: target, - semanticTarget: endJumpTarget, + boundary: boundary, in: textView ) } @@ -302,18 +344,22 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { private func applyEndJumpDecision( _ decision: ChatArtifactTextJumpConvergence.Decision, - target: CGPoint, - semanticTarget: ChatArtifactTextEndJumpTarget, + boundary: ChatArtifactTextBottomBoundary, in textView: UITextView ) -> Bool { switch decision { case .finish: endJumpConvergence = nil - if semanticTarget == .end { - endJumpTarget = nil - } + _ = applyBottomPinAction( + bottomPin.initialAnimationSettled(at: boundary), + in: textView + ) return false case .retarget: + let target = CGPoint( + x: textView.contentOffset.x, + y: CGFloat(boundary.contentOffsetY) + ) if !setContentOffset(target, animated: true, in: textView) { textView.layoutIfNeeded() return true @@ -321,10 +367,35 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { return false case .force: endJumpConvergence = nil - if semanticTarget == .end { - endJumpTarget = nil + _ = applyBottomPinAction( + bottomPin.initialAnimationSettled(at: boundary), + in: textView + ) + return false + } + } + + /// Applies the state-machine decision; only the first End movement may animate. + private func applyBottomPinAction( + _ action: ChatArtifactTextBottomPinStateMachine.Action, + in textView: UITextView + ) -> Bool { + switch action { + case .none: + return false + case .scrollToBottom(let boundary, let animated): + if animated { + return setContentOffset( + CGPoint( + x: textView.contentOffset.x, + y: CGFloat(boundary.contentOffsetY) + ), + animated: true, + in: textView + ) } settleAtDocumentEnd(in: textView) + bottomPin.didApplyPin(at: documentEndBoundary(in: textView)) return false } } @@ -394,6 +465,16 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { ) } + private func documentEndBoundary( + in textView: UITextView + ) -> ChatArtifactTextBottomBoundary { + let contentOffset = documentEndContentOffset(in: textView) + return ChatArtifactTextBottomBoundary( + storageEnd: textView.textStorage.length, + contentOffsetY: Double(contentOffset.y) + ) + } + /// Settles at the first character without materializing the intervening document. private func settleAtDocumentTop(in textView: UITextView) { if textView.textStorage.length > 0 { @@ -463,6 +544,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { guard let textView = containerView?.textView else { return } switch recognizer.state { case .began: + cancelBoundaryJumpOwnership() pinchStartFontPointSize = appliedFontPointSize > 0 ? appliedFontPointSize : Double(textView.font?.pointSize ?? 15) @@ -486,6 +568,20 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { } } + @objc + private func handleUserTap(_ recognizer: UITapGestureRecognizer) { + guard recognizer.state == .ended else { return } + cancelBoundaryJumpOwnership() + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + gestureRecognizer === bottomPinExitTapGestureRecognizer + || otherGestureRecognizer === bottomPinExitTapGestureRecognizer + } + private func applyFontSize(_ pointSize: Double, to textView: UITextView) { let font = UIFont.monospacedSystemFont( ofSize: CGFloat(pointSize), @@ -791,7 +887,7 @@ final class ChatArtifactTextViewCoordinator: NSObject, UITextViewDelegate { ) appliedSearchRange = currentRange if scrollToMatch { - endJumpTarget = nil + cancelBoundaryJumpOwnership() textView.scrollRangeToVisible(currentRange) } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactUIKitTextView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactUIKitTextView.swift new file mode 100644 index 000000000000..2383fbd4809f --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactUIKitTextView.swift @@ -0,0 +1,34 @@ +#if canImport(UIKit) +import UIKit + +/// Reports UIKit/TextKit layout passes without owning artifact scroll state. +@MainActor +final class ChatArtifactUIKitTextView: UITextView { + var onLayoutDidChange: (() -> Void)? + + override var contentSize: CGSize { + didSet { + guard oldValue != contentSize else { return } + onLayoutDidChange?() + } + } + + init() { + let textStorage = NSTextStorage() + let layoutManager = NSLayoutManager() + let textContainer = NSTextContainer(size: .zero) + textStorage.addLayoutManager(layoutManager) + layoutManager.addTextContainer(textContainer) + super.init(frame: .zero, textContainer: textContainer) + } + + required init?(coder: NSCoder) { + nil + } + + override func layoutSubviews() { + super.layoutSubviews() + onLayoutDidChange?() + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift index f4a5f210515e..52d4d9a1d9b2 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift @@ -97,4 +97,29 @@ struct ChatArtifactTextBottomPinStateMachineTests { #expect(pin.phase == .following) #expect(pin.visibleBoundary?.storageEnd == finalBoundary.storageEnd) } + + @Test("EOF during the initial animation is retained for the terminal settle") + func eofDuringInitialAnimationIsRetained() { + var pin = ChatArtifactTextBottomPinStateMachine() + let partialBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 120_000, + contentOffsetY: 112_000 + ) + let finalBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 174_000, + contentOffsetY: 168_000 + ) + + _ = pin.engage(target: .latest, boundary: partialBoundary) + let didMarkEOF = pin.markReachedEOF() + #expect(didMarkEOF) + #expect(pin.target == .end) + #expect( + pin.initialAnimationSettled(at: finalBoundary) + == .scrollToBottom(boundary: finalBoundary, animated: false) + ) + pin.didApplyPin(at: finalBoundary) + + #expect(pin.visibleBoundary == finalBoundary) + } } From 9ebd2a07385f18cfde711d95496ffff2483f8d84 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 23:26:58 -0700 Subject: [PATCH 80/92] test(ios): reproduce final artifact pin flush gap --- ...tifactTextBottomPinStateMachineTests.swift | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift index 52d4d9a1d9b2..3e72d5c3b7b0 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift @@ -122,4 +122,37 @@ struct ChatArtifactTextBottomPinStateMachineTests { #expect(pin.visibleBoundary == finalBoundary) } + + @Test("EOF plus the final deferred append leaves the pinned document end visible") + func finalDeferredAppendLeavesDocumentEndVisible() { + var appendPolicy = ChatArtifactTextAppendPolicy() + var pin = ChatArtifactTextBottomPinStateMachine() + let preFlushBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 171_817, + contentOffsetY: 165_817 + ) + let finalBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 174_000, + contentOffsetY: 168_000 + ) + + _ = pin.engage(target: .latest, boundary: preFlushBoundary) + appendPolicy.beginProgrammaticAnimation() + #expect(appendPolicy.enqueue(chunkCount: 1) == 0) + let didReachEOF = pin.markReachedEOF() + #expect(didReachEOF) + + let finalFlushCount = appendPolicy.endProgrammaticAnimation() + #expect(finalFlushCount == 1) + #expect(pin.appendsFlushed(at: finalBoundary) == .none) + #expect( + pin.initialAnimationSettled(at: preFlushBoundary) + == .scrollToBottom(boundary: finalBoundary, animated: false) + ) + pin.didApplyPin(at: finalBoundary) + + #expect(!appendPolicy.isDeferring) + #expect(pin.target == .end) + #expect(pin.visibleBoundary == finalBoundary) + } } From 82b61d5575ece0800f016f61ea0aae92709ac930 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 23:30:13 -0700 Subject: [PATCH 81/92] fix(ios): drain final artifact bottom pin --- ...hatArtifactTextBottomPinStateMachine.swift | 12 +++- .../ChatArtifactTextViewCoordinator.swift | 58 ++++++++++++++----- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomPinStateMachine.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomPinStateMachine.swift index 3a9aa816b2ff..0fc5fc420b1f 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomPinStateMachine.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomPinStateMachine.swift @@ -17,6 +17,7 @@ struct ChatArtifactTextBottomPinStateMachine: Equatable, Sendable { private(set) var phase: Phase? private(set) var visibleBoundary: ChatArtifactTextBottomBoundary? private var requestedBoundary: ChatArtifactTextBottomBoundary? + private var pendingFlushedBoundary: ChatArtifactTextBottomBoundary? var isPinned: Bool { target != nil @@ -30,6 +31,7 @@ struct ChatArtifactTextBottomPinStateMachine: Equatable, Sendable { phase = .initialAnimation visibleBoundary = nil requestedBoundary = boundary + pendingFlushedBoundary = nil return .scrollToBottom(boundary: boundary, animated: true) } @@ -37,6 +39,8 @@ struct ChatArtifactTextBottomPinStateMachine: Equatable, Sendable { at boundary: ChatArtifactTextBottomBoundary ) -> Action { guard isPinned else { return .none } + let boundary = pendingFlushedBoundary ?? boundary + pendingFlushedBoundary = nil phase = .following visibleBoundary = nil requestedBoundary = boundary @@ -56,7 +60,12 @@ struct ChatArtifactTextBottomPinStateMachine: Equatable, Sendable { mutating func appendsFlushed( at boundary: ChatArtifactTextBottomBoundary ) -> Action { - guard phase == .following else { return .none } + guard isPinned else { return .none } + guard phase == .following else { + pendingFlushedBoundary = boundary + return .none + } + pendingFlushedBoundary = nil visibleBoundary = nil requestedBoundary = boundary return .scrollToBottom(boundary: boundary, animated: false) @@ -90,5 +99,6 @@ struct ChatArtifactTextBottomPinStateMachine: Equatable, Sendable { phase = nil visibleBoundary = nil requestedBoundary = nil + pendingFlushedBoundary = nil } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift index d7795adc500f..9cb3e66633a9 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift @@ -9,7 +9,7 @@ final class ChatArtifactTextViewCoordinator: UITextViewDelegate, UIGestureRecognizerDelegate { - private enum BottomPinTrigger { + private enum BottomPinTrigger: Int { case layoutChanged case appendsFlushed case reachedEOF @@ -31,6 +31,7 @@ final class ChatArtifactTextViewCoordinator: private var bottomPin = ChatArtifactTextBottomPinStateMachine() private var endJumpConvergence: ChatArtifactTextJumpConvergence? private var isSettlingBoundaryJump = false + private var pendingBottomPinTriggerMask = 0 private weak var containerView: ChatArtifactTextContainerView? private let syntaxHighlighter = ChatArtifactSyntaxHighlighter() private var highlightTask: Task? @@ -126,6 +127,7 @@ final class ChatArtifactTextViewCoordinator: private func cancelBottomPinOwnership() { endJumpConvergence = nil + pendingBottomPinTriggerMask = 0 bottomPin.userInteracted() } @@ -143,6 +145,7 @@ final class ChatArtifactTextViewCoordinator: topJumpConvergence = nil bottomPin = ChatArtifactTextBottomPinStateMachine() endJumpConvergence = nil + pendingBottomPinTriggerMask = 0 appliedChunkCount = 0 } @@ -268,8 +271,12 @@ final class ChatArtifactTextViewCoordinator: in textView: UITextView, bottomPinTrigger: BottomPinTrigger? = nil ) { + if let bottomPinTrigger { + pendingBottomPinTriggerMask |= 1 << bottomPinTrigger.rawValue + } // UIKit may synchronously report animation completion while a fallback - // range is being materialized. The active settle already owns that edge. + // range or a deferred append is being materialized. The active settle + // owns that edge and drains every trigger recorded by re-entrant work. guard !isSettlingBoundaryJump else { return } isSettlingBoundaryJump = true defer { isSettlingBoundaryJump = false } @@ -277,21 +284,40 @@ final class ChatArtifactTextViewCoordinator: guard !appendPolicy.isDeferring, pendingTextChunks.isEmpty else { return } - while settleBoundaryJumpOnceIfReady(in: textView) {} + while true { + while settleBoundaryJumpOnceIfReady(in: textView) {} + + guard !appendPolicy.isDeferring, + pendingTextChunks.isEmpty, + endJumpConvergence == nil, + let bottomPinTrigger = takePendingBottomPinTrigger() else { return } + let boundary = documentEndBoundary(in: textView) + let action: ChatArtifactTextBottomPinStateMachine.Action + switch bottomPinTrigger { + case .layoutChanged: + action = bottomPin.layoutChanged(to: boundary) + case .appendsFlushed: + action = bottomPin.appendsFlushed(at: boundary) + case .reachedEOF: + action = bottomPin.reachedEOF(at: boundary) + } + _ = applyBottomPinAction(action, in: textView) - guard endJumpConvergence == nil, - let bottomPinTrigger else { return } - let boundary = documentEndBoundary(in: textView) - let action: ChatArtifactTextBottomPinStateMachine.Action - switch bottomPinTrigger { - case .layoutChanged: - action = bottomPin.layoutChanged(to: boundary) - case .appendsFlushed: - action = bottomPin.appendsFlushed(at: boundary) - case .reachedEOF: - action = bottomPin.reachedEOF(at: boundary) - } - _ = applyBottomPinAction(action, in: textView) + guard !appendPolicy.isDeferring, + pendingTextChunks.isEmpty else { return } + } + } + + private func takePendingBottomPinTrigger() -> BottomPinTrigger? { + for trigger in [ + BottomPinTrigger.reachedEOF, + .appendsFlushed, + .layoutChanged, + ] where pendingBottomPinTriggerMask & (1 << trigger.rawValue) != 0 { + pendingBottomPinTriggerMask &= ~(1 << trigger.rawValue) + return trigger + } + return nil } /// Performs one budgeted settle step and reports whether layout must be sampled again now. From 62f57bab4a99fef81995fed2e69931fb0beee687 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Fri, 17 Jul 2026 00:14:13 -0700 Subject: [PATCH 82/92] fix(ios): never defer appends for pin-owned scrolls A missed end-of-animation callback left the append policy deferring forever: the pin's own scrolls kept appends queued, the storage stayed truncated below the file end, and End parked short believing it was at the bottom. Only user-driven scrolling defers now; animated jumps are protected by convergence re-targeting instead. Co-Authored-By: Claude Fable 5 --- .../ChatArtifactTextAppendPolicy.swift | 8 +++++-- .../ChatArtifactTextAppendPolicyTests.swift | 21 +++++++++++++++---- ...tifactTextBottomPinStateMachineTests.swift | 8 ++++--- ghostty | 2 +- vendor/bonsplit | 2 +- 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAppendPolicy.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAppendPolicy.swift index 6ab3f608f0dd..e234612724cf 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAppendPolicy.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAppendPolicy.swift @@ -10,14 +10,18 @@ struct ChatArtifactTextAppendPolicy: Equatable, Sendable { private var state = DeferralState.idle private var pendingChunkCount = 0 + // Only user-driven scrolling defers appends. Programmatic (pin-owned) + // scrolls must not: the pin exists to reveal new content, and a missed + // end-of-animation callback would otherwise strand every later chunk + // in the pending queue with the storage silently truncated. var isDeferring: Bool { - state != .idle + state == .tracking || state == .decelerating } mutating func enqueue(chunkCount: Int) -> Int { guard chunkCount > 0 else { return 0 } pendingChunkCount += chunkCount - return state == .idle ? drain() : 0 + return isDeferring ? 0 : drain() } mutating func beginTracking() { diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextAppendPolicyTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextAppendPolicyTests.swift index dd8d220dc4b5..478203660323 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextAppendPolicyTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextAppendPolicyTests.swift @@ -16,12 +16,25 @@ struct ChatArtifactTextAppendPolicyTests { #expect(policy.endDecelerating() == 5) } - @Test("protects an animated top jump from streamed appends") - func defersProgrammaticScrollAppends() { + @Test("programmatic scrolls never defer appends") + func programmaticScrollAppendsApplyImmediately() { + // Deferral during coordinator-owned scrolls once stranded every later + // chunk when an end-of-animation callback was missed; animated jumps + // are protected by convergence re-targeting instead. var policy = ChatArtifactTextAppendPolicy() policy.beginProgrammaticAnimation() - #expect(policy.enqueue(chunkCount: 2) == 0) - #expect(policy.endProgrammaticAnimation() == 2) + #expect(policy.enqueue(chunkCount: 2) == 2) + #expect(policy.endProgrammaticAnimation() == 0) + } + + @Test("a missed end-of-animation callback cannot strand chunks") + func missedAnimationEndDoesNotStrandChunks() { + var policy = ChatArtifactTextAppendPolicy() + + policy.beginProgrammaticAnimation() + // No endProgrammaticAnimation is ever delivered. + #expect(policy.enqueue(chunkCount: 3) == 3) + #expect(!policy.isDeferring) } } diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift index 3e72d5c3b7b0..62b3183ff6cf 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift @@ -138,12 +138,14 @@ struct ChatArtifactTextBottomPinStateMachineTests { _ = pin.engage(target: .latest, boundary: preFlushBoundary) appendPolicy.beginProgrammaticAnimation() - #expect(appendPolicy.enqueue(chunkCount: 1) == 0) + // Appends apply immediately even while the pin's own scroll animates, + // so a missed end-of-animation callback can no longer truncate the + // storage below the file end. + #expect(appendPolicy.enqueue(chunkCount: 1) == 1) let didReachEOF = pin.markReachedEOF() #expect(didReachEOF) - let finalFlushCount = appendPolicy.endProgrammaticAnimation() - #expect(finalFlushCount == 1) + #expect(appendPolicy.endProgrammaticAnimation() == 0) #expect(pin.appendsFlushed(at: finalBoundary) == .none) #expect( pin.initialAnimationSettled(at: preFlushBoundary) diff --git a/ghostty b/ghostty index bb30526cdab8..eb500e9f45c8 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit bb30526cdab8f5fb08ae43e404e3aacc40d3ffc3 +Subproject commit eb500e9f45c8b6ffa6043350ec1488a42d195406 diff --git a/vendor/bonsplit b/vendor/bonsplit index 10563e2fda6f..a0400b3533db 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 10563e2fda6fc18c47adf1864d55e0e25087a864 +Subproject commit a0400b3533db21afda4a5a59e695cbff588a39b7 From fc22f84270fca853702b6fa1156b794d8fca58cb Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:53:39 -0500 Subject: [PATCH 83/92] Fix iOS reconnect and build isolation (#8299) * test(ios): cover reconnect overlap cleanup * fix(ios): retire superseded reconnect sessions * test(ios): isolate saved dev Mac instances * fix(ios): enforce build compatibility boundaries * test(ios): cover startup status auth race * fix(ios): reuse connect token for identity check * test(auth): preserve selected team during refresh outage * fix(auth): keep selected team effective during startup * test(auth): keep cached sessions restoring until ready * fix(ios): wait for auth restore before reconnect * test(ios): cover compatibility review regressions * fix(ios): address compatibility review findings * test(ios): use deterministic compatibility timestamps --------- Co-authored-by: cmux reload-cloud --- .../CMUXAuthCore/Models/CMUXAuthState.swift | 5 +- .../CMUXAuthStateTests.swift | 8 +- .../Coordinator/AuthCoordinator.swift | 10 +- .../AuthCoordinatorTests.swift | 14 + .../AuthCoordinatorTimeoutTests.swift | 2 +- .../CmuxMobileRPC/MobileCoreRPCClient.swift | 110 ++++++- ...leCoreRPCHostStatusTokenTimeoutTests.swift | 182 +++++++++++- .../IOSBuildScopedPairedMacStore.swift | 70 ++++- .../MobileMacBuildCompatibilityPolicy.swift | 65 +++++ .../MobileMacCompatiblePairedMacStore.swift | 246 ++++++++++++++++ .../MobilePairingFailure.swift | 30 +- ...ileShellComposite+BuildCompatibility.swift | 59 ++++ ...ileShellComposite+ManualAttachTicket.swift | 4 +- .../MobileShellComposite.swift | 87 +++--- .../PresenceServiceConfiguration.swift | 6 +- .../IOSBuildScopedPairedMacStoreTests.swift | 104 +++++-- .../IrohReconnectRouteSelectionTests.swift | 2 - ...bileMacBuildCompatibilityPolicyTests.swift | 276 ++++++++++++++++++ .../MobilePairingFailureTests.swift | 22 +- ...ggedBuildPresenceRouteIsolationTests.swift | 3 +- ...ggedBuildRegistryRouteIsolationTests.swift | 2 +- .../CMUXMobileRootView.swift | 8 +- .../MobileRootAuthGate.swift | 9 +- .../MobileRootAuthGateTests.swift | 9 + Sources/Mobile/MobileHostService.swift | 19 +- .../MobileHostTransportAuthorization.swift | 44 ++- .../MobileHostConnectionLifecycleTests.swift | 154 ++++++++++ ios/Config/Shared.xcconfig | 13 +- ios/README.md | 28 +- ios/cmux/Resources/Localizable.xcstrings | 38 ++- .../cmuxFeature/CMUXMobileRootScene.swift | 18 +- .../cmuxFeature/MobileAuthComposition.swift | 4 +- .../MobileAuthEnvironmentOverrideTests.swift | 17 +- .../MobilePairingAccountPreflightTests.swift | 9 +- ios/scripts/reload.sh | 20 +- 35 files changed, 1516 insertions(+), 181 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacBuildCompatibilityPolicy.swift create mode 100644 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatiblePairedMacStore.swift create mode 100644 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+BuildCompatibility.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacBuildCompatibilityPolicyTests.swift diff --git a/Packages/Shared/CMUXAuthCore/Sources/CMUXAuthCore/Models/CMUXAuthState.swift b/Packages/Shared/CMUXAuthCore/Sources/CMUXAuthCore/Models/CMUXAuthState.swift index e73b7b2a7111..d64265aad071 100644 --- a/Packages/Shared/CMUXAuthCore/Sources/CMUXAuthCore/Models/CMUXAuthState.swift +++ b/Packages/Shared/CMUXAuthCore/Sources/CMUXAuthCore/Models/CMUXAuthState.swift @@ -22,7 +22,8 @@ public struct CMUXAuthState: Equatable, Sendable { /// priority order: cleared auth, mock data, a UI-test fixture user, /// pending auto-login, then the cached user. A cached user with known /// tokens is treated as signed in immediately while runtime validation - /// proceeds in the background. + /// remains explicitly in progress. This lets the authenticated UI render + /// without allowing token-dependent work to race the restore. /// - Parameters: /// - clearAuthRequested: Whether the launch requested a cleared auth state. /// - mockDataEnabled: Whether mock-data mode is active. @@ -57,7 +58,7 @@ public struct CMUXAuthState: Equatable, Sendable { } if hasTokens, let cachedUser { - return Self(isAuthenticated: true, currentUser: cachedUser, isRestoringSession: false) + return Self(isAuthenticated: true, currentUser: cachedUser, isRestoringSession: true) } return Self(isAuthenticated: false, currentUser: cachedUser, isRestoringSession: hasTokens) diff --git a/Packages/Shared/CMUXAuthCore/Tests/CMUXAuthCoreTests/CMUXAuthStateTests.swift b/Packages/Shared/CMUXAuthCore/Tests/CMUXAuthCoreTests/CMUXAuthStateTests.swift index 4a594a3cb4a1..d6db6d2bc666 100644 --- a/Packages/Shared/CMUXAuthCore/Tests/CMUXAuthCoreTests/CMUXAuthStateTests.swift +++ b/Packages/Shared/CMUXAuthCore/Tests/CMUXAuthCoreTests/CMUXAuthStateTests.swift @@ -120,8 +120,8 @@ struct CMUXAuthStateTests { ) } - @Test("Primed state authenticates cached user while validating tokens") - func primedStateAuthenticatesCachedUserWhileValidatingTokens() { + @Test("Primed cached user remains restoring while validating tokens") + func primedCachedUserRemainsRestoringWhileValidatingTokens() { let user = CMUXAuthUser(id: "user_123", primaryEmail: "user@example.com", displayName: "Test User") let state = CMUXAuthState.primed( clearAuthRequested: false, @@ -135,7 +135,7 @@ struct CMUXAuthStateTests { #expect(state.isAuthenticated) #expect(state.currentUser == user) - #expect(!state.isRestoringSession) + #expect(state.isRestoringSession) } @Test("Primed state restores when tokens exist without a cached user") @@ -188,7 +188,7 @@ struct CMUXAuthStateTests { #expect(state.isAuthenticated) #expect(state.currentUser == user) - #expect(!state.isRestoringSession) + #expect(state.isRestoringSession) } @Test("Primed state does not authenticate from cached user alone") diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift index d8ed650c5384..91d80725f03b 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift @@ -50,8 +50,11 @@ public final class AuthCoordinator { } } - /// The team id API calls should target: the persisted selection while it is - /// still one of ``availableTeams``, else the first available team. + /// The team id API calls should target. While the team refresh is unavailable + /// or still loading, the account-scoped persisted selection remains effective + /// so local state and reconnect routes do not temporarily fall into the + /// teamless partition. Once teams load, an invalid selection falls back to the + /// first available team. public var resolvedTeamID: String? { Self.resolveTeamID(selectedTeamID: selectedTeamID, teams: availableTeams) } @@ -625,6 +628,9 @@ public final class AuthCoordinator { selectedTeamID: String?, teams: [CMUXAuthTeam] ) -> String? { + if teams.isEmpty { + return selectedTeamID + } if let selectedTeamID, teams.contains(where: { $0.id == selectedTeamID }) { return selectedTeamID diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTests.swift index ace7a45afa0b..d197f824384a 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTests.swift @@ -382,6 +382,20 @@ import Testing #expect(coordinator.availableTeams.isEmpty) } + @Test func persistedTeamSelectionRemainsEffectiveWhileTeamRefreshIsUnavailable() async throws { + let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") + let client = FakeAuthClient(user: user) + await client.setThrowOnListTeams(AuthError.networkError) + let (coordinator, _) = makeCoordinator(client: client) + coordinator.selectedTeamID = "team_b" + + try await coordinator.signInWithPassword(email: "a@b.com", password: "pw") + + #expect(coordinator.isAuthenticated) + #expect(coordinator.availableTeams.isEmpty) + #expect(coordinator.resolvedTeamID == "team_b") + } + @Test func signOutClearsTeamsAndSelection() async throws { let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") let client = FakeAuthClient(user: user) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTimeoutTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTimeoutTests.swift index 8beb2de94721..21a9312796fb 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTimeoutTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTimeoutTests.swift @@ -380,7 +380,7 @@ import Testing hasCachedTokens: true ) - #expect(coordinator.isRestoringSession == false) + #expect(coordinator.isRestoringSession) #expect(coordinator.isAuthenticated) #expect(coordinator.currentUser == user) coordinator.start() diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift index 38f9e998a890..1f5528d12a44 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift @@ -121,6 +121,48 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { /// The optional timeout is a hard end-to-end deadline for auth augmentation, /// connection setup, and response wait, not a per-subphase timeout. public func sendRequest(_ requestData: Data, timeoutNanoseconds: UInt64? = nil) async throws -> Data { + try await sendRequestOperation( + requestData, + timeoutNanoseconds: timeoutNanoseconds + ).response + } + + /// Sends an authorized request and then proves host identity with the same + /// Stack token that authorized its successful response. + /// + /// The token stays in this call frame. Failed, cancelled, and concurrent + /// requests cannot publish or overwrite authorization state for a later + /// host-status probe. + public func sendRequestAndAuthenticatedHostStatus( + _ requestData: Data, + timeoutNanoseconds: UInt64? = nil, + hostStatusTimeoutNanoseconds: @Sendable () -> UInt64? = { nil } + ) async throws -> (response: Data, hostStatusResponse: Data) { + guard let request = try JSONSerialization.jsonObject(with: requestData) as? [String: Any], + Self.requestRequiresAuth(request) else { + throw MobileShellConnectionError.invalidResponse + } + let authorized = try await sendRequestOperation( + requestData, + timeoutNanoseconds: timeoutNanoseconds + ) + let hostStatusTimeout = hostStatusTimeoutNanoseconds() + if hostStatusTimeout == 0 { + throw MobileShellConnectionError.requestTimedOut + } + let hostStatus = try await sendRequestOperation( + Self.requestData(method: "mobile.host.status", params: [:]), + timeoutNanoseconds: hostStatusTimeout, + hostStatusStackToken: authorized.stackAccessToken + ) + return (authorized.response, hostStatus.response) + } + + private func sendRequestOperation( + _ requestData: Data, + timeoutNanoseconds: UInt64?, + hostStatusStackToken: String? = nil + ) async throws -> AuthenticatedRequestResult { let deadline = RPCRequestDeadline( timeoutNanoseconds: timeoutNanoseconds ?? runtime.rpcRequestTimeoutNanoseconds ) @@ -132,7 +174,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { return try await sendAuthenticatedRequest( preparedRequest, deadline: deadline, - allowAuthRetry: true + allowAuthRetry: true, + hostStatusStackToken: hostStatusStackToken ) } catch let error as MobileShellConnectionError { // The host rejected this request on Stack-auth grounds. Before @@ -153,7 +196,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { return try await sendAuthenticatedRequest( preparedRequest, deadline: deadline, - allowAuthRetry: false + allowAuthRetry: false, + hostStatusStackToken: hostStatusStackToken ) } } @@ -215,8 +259,9 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { private func sendAuthenticatedRequest( _ requestData: Data, deadline: RPCRequestDeadline, - allowAuthRetry: Bool - ) async throws -> Data { + allowAuthRetry: Bool, + hostStatusStackToken: String? + ) async throws -> AuthenticatedRequestResult { // Multiplexed over a persistent transport: each request gets a unique // id, the session's reader task routes the response back here. No // connect/close per RPC, no head-of-line blocking between calls. @@ -228,14 +273,19 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { ) let authenticated = try await requestDataWithAuth( augmented, - deadline: deadline + deadline: deadline, + hostStatusStackToken: hostStatusStackToken ) try Task.checkCancellation() - return try await session.send( - payload: authenticated, + let response = try await session.send( + payload: authenticated.data, requestID: id, deadlineUptimeNanoseconds: deadline.uptimeNanoseconds ) + return AuthenticatedRequestResult( + response: response, + stackAccessToken: authenticated.stackAccessToken + ) } private static func requestWithGuaranteedID( @@ -256,17 +306,25 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { return (id, data) } - private func requestDataWithAuth(_ requestData: Data, deadline: RPCRequestDeadline) async throws -> Data { + private func requestDataWithAuth( + _ requestData: Data, + deadline: RPCRequestDeadline, + hostStatusStackToken: String? + ) async throws -> AuthenticatedRequestPayload { guard var request = try JSONSerialization.jsonObject(with: requestData) as? [String: Any] else { - return requestData + return AuthenticatedRequestPayload(data: requestData, stackAccessToken: nil) } if transportRequest.authorizationMode == .transportAdmission { request.removeValue(forKey: "auth") - return try JSONSerialization.data(withJSONObject: request) + return AuthenticatedRequestPayload( + data: try JSONSerialization.data(withJSONObject: request), + stackAccessToken: nil + ) } let requestNeedsAuth = Self.requestRequiresAuth(request) let requestIsCoveredByAttachTicket = !Self.requestNeedsStackAuthFallback(request, ticket: ticket) var auth: [String: Any] = [:] + var requestStackAccessToken: String? let attachToken = ticket.authToken?.trimmingCharacters(in: .whitespacesAndNewlines) let hasAttachToken = attachToken?.isEmpty == false if let attachToken, @@ -297,7 +355,9 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { throw MobileShellConnectionError.insecureManualRoute } do { - auth["stack_access_token"] = try await stackAccessToken(deadline: deadline) + let token = try await stackAccessToken(deadline: deadline) + auth["stack_access_token"] = token + requestStackAccessToken = token } catch let error as MobileShellConnectionError { // The provider already classified the failure: a transient // token-fetch failure (offline / refresh server hiccup, session @@ -322,14 +382,24 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { if !requestNeedsAuth, isHostStatusRequest(request), allowsStackAuthFallback, - MobileShellRouteAuthPolicy.routeAllowsStackAuth(route), - let stackAccessToken = try await stackAccessTokenForStatus(deadline: deadline) { - auth["stack_access_token"] = stackAccessToken + MobileShellRouteAuthPolicy.routeAllowsStackAuth(route) { + let stackAccessToken: String? + if let hostStatusStackToken { + stackAccessToken = hostStatusStackToken + } else { + stackAccessToken = try await stackAccessTokenForStatus(deadline: deadline) + } + if let stackAccessToken { + auth["stack_access_token"] = stackAccessToken + } } if !auth.isEmpty { request["auth"] = auth } - return try JSONSerialization.data(withJSONObject: request) + return AuthenticatedRequestPayload( + data: try JSONSerialization.data(withJSONObject: request), + stackAccessToken: requestStackAccessToken + ) } private func stackAccessTokenForStatus(deadline: RPCRequestDeadline) async throws -> String? { @@ -438,6 +508,16 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { let hasConflict: Bool } + private struct AuthenticatedRequestPayload { + let data: Data + let stackAccessToken: String? + } + + private struct AuthenticatedRequestResult { + let response: Data + let stackAccessToken: String? + } + } private extension MobileCoreRPCClient { diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCHostStatusTokenTimeoutTests.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCHostStatusTokenTimeoutTests.swift index 444ab90e258b..9fe3d230605e 100644 --- a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCHostStatusTokenTimeoutTests.swift +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCHostStatusTokenTimeoutTests.swift @@ -49,15 +49,13 @@ import Testing } @Test func hostStatusProbeTimeoutCoversStatusStackTokenFallback() async throws { - let tokenStarted = AsyncFlag() + let tokenProvider = ReleasableStatusTokenProvider() let transport = QueuedCancellationProbeTransport() let route = try hostPortRoute(kind: .debugLoopback, host: "127.0.0.1", port: 59130) let runtime = TestMobileSyncRuntime( transportFactory: QueuedCancellationProbeTransportFactory(transport: transport), stackAccessTokenForStatusProvider: { - await tokenStarted.set() - try? await Task.sleep(nanoseconds: 60 * 1_000_000_000) - return Task.isCancelled ? nil : "late-status-token" + await tokenProvider.token() }, rpcRequestTimeoutNanoseconds: 10_000_000 ) @@ -90,7 +88,181 @@ import Testing Issue.record("Expected requestTimedOut, got \(error)") } - #expect(await tokenStarted.isSet()) + #expect(await tokenProvider.didStart()) #expect(try await transport.sentRequests().isEmpty) + await tokenProvider.release() + } + + @Test func separateHostStatusRequestDoesNotReuseImplicitAuthorizationState() async throws { + let statusTokenProviderStarted = AsyncFlag() + let transport = ImmediateResponseRecordingTransport() + let route = try hostPortRoute(kind: .debugLoopback, host: "127.0.0.1", port: 59131) + let runtime = TestMobileSyncRuntime( + transportFactory: ImmediateResponseRecordingTransportFactory(transport: transport), + stackAccessTokenProvider: { "workspace-token" }, + stackAccessTokenForStatusProvider: { + await statusTokenProviderStarted.set() + return "status-token" + }, + rpcRequestTimeoutNanoseconds: 60 * 1_000_000_000 + ) + let ticket = try CmxAttachTicket( + workspaceID: "workspace-main", + terminalID: "terminal-main", + macDeviceID: "test-mac", + macDisplayName: "Test Mac", + routes: [route], + expiresAt: Date().addingTimeInterval(60), + authToken: "ticket-secret" + ) + let client = MobileCoreRPCClient( + runtime: runtime, + route: route, + ticket: ticket, + allowsStackAuthFallback: true + ) + + _ = try await client.sendRequest( + MobileCoreRPCClient.requestData( + method: "workspace.list", + params: ["workspace_id": "workspace-main"], + id: "workspace" + ) + ) + _ = try await client.sendRequest( + MobileCoreRPCClient.requestData( + method: "mobile.host.status", + id: "status" + ) + ) + + let sent = try await transport.sentRequests() + #expect(sent.map(\.method) == ["workspace.list", "mobile.host.status"]) + #expect(sent.map(\.stackAccessToken) == ["workspace-token", "status-token"]) + #expect(await statusTokenProviderStarted.isSet()) + } + + @Test func explicitHostStatusUsesTheSuccessfulAuthorizedRequestToken() async throws { + let statusTokenProviderStarted = AsyncFlag() + let transport = ImmediateResponseRecordingTransport() + let route = try hostPortRoute(kind: .debugLoopback, host: "127.0.0.1", port: 59132) + let runtime = TestMobileSyncRuntime( + transportFactory: ImmediateResponseRecordingTransportFactory(transport: transport), + stackAccessTokenProvider: { "workspace-token" }, + stackAccessTokenForStatusProvider: { + await statusTokenProviderStarted.set() + return "different-status-token" + }, + rpcRequestTimeoutNanoseconds: 60 * 1_000_000_000 + ) + let ticket = try CmxAttachTicket( + workspaceID: "workspace-main", + terminalID: "terminal-main", + macDeviceID: "test-mac", + macDisplayName: "Test Mac", + routes: [route], + expiresAt: Date().addingTimeInterval(60), + authToken: "ticket-secret" + ) + let client = MobileCoreRPCClient( + runtime: runtime, + route: route, + ticket: ticket, + allowsStackAuthFallback: true + ) + + _ = try await client.sendRequestAndAuthenticatedHostStatus( + MobileCoreRPCClient.requestData( + method: "workspace.list", + params: ["workspace_id": "workspace-main"], + id: "workspace" + ), + hostStatusTimeoutNanoseconds: { 60 * 1_000_000_000 } + ) + + let sent = try await transport.sentRequests() + #expect(sent.map(\.method) == ["workspace.list", "mobile.host.status"]) + #expect(sent.map(\.stackAccessToken) == ["workspace-token", "workspace-token"]) + #expect(!(await statusTokenProviderStarted.isSet())) + } +} + +private actor ReleasableStatusTokenProvider { + private var continuation: CheckedContinuation? + private var started = false + + func token() async -> String? { + started = true + return await withCheckedContinuation { continuation = $0 } + } + + func didStart() -> Bool { + started + } + + func release() { + continuation?.resume(returning: nil) + continuation = nil + } +} + +private actor ImmediateResponseRecordingTransport: CmxByteTransport { + private var sentPayloads: [Data] = [] + private var queuedResponses: [Data] = [] + private var receiveWaiters: [CheckedContinuation] = [] + private var isClosed = false + + func connect() async throws {} + + func receive() async throws -> Data? { + guard !isClosed else { return nil } + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return await withCheckedContinuation { continuation in + receiveWaiters.append(continuation) + } + } + + func send(_ data: Data) async throws { + var buffer = data + let payloads = try MobileSyncFrameCodec.decodeFrames(from: &buffer) + sentPayloads.append(contentsOf: payloads) + for payload in payloads { + let request = try recordedRPCRequest(from: payload) + let response = try JSONSerialization.data(withJSONObject: [ + "id": request.id ?? "", + "ok": true, + "result": ["status": "ok"], + ]) + let frame = try MobileSyncFrameCodec.encodeFrame(response) + if let waiter = receiveWaiters.first { + receiveWaiters.removeFirst() + waiter.resume(returning: frame) + } else { + queuedResponses.append(frame) + } + } + } + + func close() async { + isClosed = true + let waiters = receiveWaiters + receiveWaiters = [] + for waiter in waiters { + waiter.resume(returning: nil) + } + } + + func sentRequests() throws -> [RecordedRPCRequest] { + try sentPayloads.map(recordedRPCRequest(from:)) + } +} + +private struct ImmediateResponseRecordingTransportFactory: CmxByteTransportFactory { + let transport: ImmediateResponseRecordingTransport + + func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + transport } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/IOSBuildScopedPairedMacStore.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/IOSBuildScopedPairedMacStore.swift index f81299c1f758..c1e4414c287b 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/IOSBuildScopedPairedMacStore.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/IOSBuildScopedPairedMacStore.swift @@ -4,19 +4,22 @@ public import Foundation /// Scopes the iOS saved-Mac list to one tagged iOS app build. /// -/// QR pairing still accepts any Mac build because the Mac's device id and routes -/// are unchanged. This decorator only decides where that successful pairing is -/// stored, so two iOS dev tags stop restoring or aggregating each other's saved -/// Macs. +/// The scoped store also enforces exact Mac app-instance compatibility, so a +/// tagged iOS build cannot display, restore, or reconnect another tag that was +/// saved into its partition by an older build. public struct IOSBuildScopedPairedMacStore: MobilePairedMacStoring { private static let separator = "\u{1F}" + private let rawInner: any MobilePairedMacStoring private let inner: any MobilePairedMacStoring private let scope: MobileIOSBuildScope private let mutationGate: PairedMacMutationGate public init(inner: any MobilePairedMacStoring, scope: MobileIOSBuildScope) { - self.inner = inner + self.rawInner = inner + self.inner = MobileMacBuildCompatibilityPolicy + .development(expectedInstanceTag: scope.value) + .scoping(inner) self.scope = scope self.mutationGate = PairedMacMutationGate() } @@ -225,7 +228,7 @@ public struct IOSBuildScopedPairedMacStore: MobilePairedMacStoring { } } } - return byID.values.sorted { lhs, rhs in + return removingAuthenticatedLegacyAliases(from: Array(byID.values)).sorted { lhs, rhs in if lhs.lastSeenAt != rhs.lastSeenAt { return lhs.lastSeenAt > rhs.lastSeenAt } return lhs.macDeviceID < rhs.macDeviceID } @@ -352,8 +355,13 @@ public struct IOSBuildScopedPairedMacStore: MobilePairedMacStoring { } private func removeAllUnlocked() async throws { - for mac in try await inner.loadAll(stackUserID: nil, teamID: nil) where isScoped(mac) { - try await inner.remove(macDeviceID: mac.macDeviceID, stackUserID: mac.stackUserID, teamID: mac.teamID) + for mac in try await rawInner.loadAll(stackUserID: nil, teamID: nil) where isScoped(mac) { + try await rawInner.remove( + macDeviceID: mac.macDeviceID, + instanceTag: mac.instanceTag, + stackUserID: mac.stackUserID, + teamID: mac.teamID + ) } } @@ -388,4 +396,50 @@ public struct IOSBuildScopedPairedMacStore: MobilePairedMacStoring { private var scopedSuffix: String { "\(Self.separator)\(scope.serializedScope)" } + private func matches( + _ mac: MobilePairedMac, + macDeviceID: String, + instanceTag: String? + ) -> Bool { + mac.macDeviceID == macDeviceID && mac.instanceTag == instanceTag + } + + /// A legacy nil-tag row and a tagged row are the same app instance only + /// when both the physical Mac id and authenticated Iroh peer id match. + /// Distinct tagged builds and legacy rows for other peers remain visible. + private func removingAuthenticatedLegacyAliases( + from rows: [MobilePairedMac] + ) -> [MobilePairedMac] { + var taggedPeersByMacDeviceID: [String: Set] = [:] + for mac in rows where mac.instanceTag?.isEmpty == false { + taggedPeersByMacDeviceID[mac.macDeviceID, default: []] + .formUnion(irohPeerEndpointIDs(in: mac.routes)) + } + return rows.filter { mac in + guard mac.instanceTag == nil, + let taggedPeers = taggedPeersByMacDeviceID[mac.macDeviceID] else { + return true + } + return taggedPeers.isDisjoint(with: irohPeerEndpointIDs(in: mac.routes)) + } + } + + private func irohPeerEndpointIDs(in routes: [CmxAttachRoute]) -> Set { + Set(routes.compactMap { route in + guard route.kind == .iroh, + case let .peer(identity, _) = route.endpoint else { + return nil + } + return identity.endpointID + }) + } +} + +private extension MobilePairedMacRouteWriteCondition { + var instanceTag: String? { + switch self { + case .matchingInstanceTag(let instanceTag): instanceTag + case .unclaimed: nil + } + } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacBuildCompatibilityPolicy.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacBuildCompatibilityPolicy.swift new file mode 100644 index 000000000000..c8bd61b24f39 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacBuildCompatibilityPolicy.swift @@ -0,0 +1,65 @@ +public import CMUXMobileCore +public import CmuxMobilePairedMac +internal import Foundation + +/// Defines which authenticated Mac app instances one iOS app build may use. +/// +/// Mac app identity remains exact (`default`, `nightly`, or a development tag), +/// while this policy supplies the compatibility boundary used by persistence, +/// registry projection, and live connection validation. +public enum MobileMacBuildCompatibilityPolicy: Equatable, Sendable { + /// A tagged development build may use only the matching Mac development tag. + case development(expectedInstanceTag: String) + /// A distributed iOS build may use Stable and Nightly Mac releases. + case official + + /// Resolves the policy compiled into the running iOS app. + /// + /// - Parameter buildScope: The tagged development scope, when this is a + /// tagged DEBUG build. + /// - Returns: Exact-tag development compatibility for DEBUG builds and + /// official compatibility for distributed builds. + public static func current( + buildScope: MobileIOSBuildScope? + ) -> MobileMacBuildCompatibilityPolicy { + #if DEBUG + return .development(expectedInstanceTag: buildScope?.value ?? "dev") + #else + return .official + #endif + } + + /// Returns whether an authenticated Mac instance belongs to this policy. + /// + /// Missing tags fail closed because they cannot distinguish two app + /// instances on the same physical Mac. + /// + /// - Parameter instanceTag: The tag reported by authenticated host status. + /// - Returns: `true` only when the Mac instance is compatible. + public func allows(instanceTag: String?) -> Bool { + guard let normalizedTag = Self.normalized(instanceTag) else { return false } + switch self { + case .development(let expectedInstanceTag): + return normalizedTag == Self.normalized(expectedInstanceTag) + case .official: + return normalizedTag == "default" || normalizedTag == "nightly" + } + } + + /// Wraps a paired-Mac store so every read and mutation follows this policy. + /// + /// - Parameter store: The underlying persistence implementation. + /// - Returns: A store that hides and rejects incompatible app instances. + public func scoping( + _ store: any MobilePairedMacStoring + ) -> any MobilePairedMacStoring { + MobileMacCompatiblePairedMacStore(inner: store, policy: self) + } + + private static func normalized(_ value: String?) -> String? { + let normalized = value? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + return normalized.isEmpty ? nil : normalized + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatiblePairedMacStore.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatiblePairedMacStore.swift new file mode 100644 index 000000000000..d8e731b7d1ad --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatiblePairedMacStore.swift @@ -0,0 +1,246 @@ +internal import CMUXMobileCore +internal import CmuxMobilePairedMac +internal import Foundation + +/// Applies one build-compatibility policy to every paired-Mac store operation. +struct MobileMacCompatiblePairedMacStore: MobilePairedMacStoring { + private let inner: any MobilePairedMacStoring + private let policy: MobileMacBuildCompatibilityPolicy + + init( + inner: any MobilePairedMacStoring, + policy: MobileMacBuildCompatibilityPolicy + ) { + self.inner = inner + self.policy = policy + } + + func upsert( + macDeviceID: String, + displayName: String?, + routes: [CmxAttachRoute], + instanceTag: String?, + markActive: Bool, + stackUserID: String?, + teamID: String?, + now: Date + ) async throws { + guard isCompatible(instanceTag: instanceTag) else { return } + try await inner.upsert( + macDeviceID: macDeviceID, + displayName: displayName, + routes: routes, + instanceTag: instanceTag, + markActive: markActive, + stackUserID: stackUserID, + teamID: teamID, + now: now + ) + } + + @discardableResult + func upsertIfNewer( + macDeviceID: String, + displayName: String?, + routes: [CmxAttachRoute], + instanceTag: String?, + customName: String?, + customColor: String?, + customIcon: String?, + markActive: Bool, + stackUserID: String?, + teamID: String?, + now: Date + ) async throws -> Bool { + guard isCompatible(instanceTag: instanceTag) else { return false } + return try await inner.upsertIfNewer( + macDeviceID: macDeviceID, + displayName: displayName, + routes: routes, + instanceTag: instanceTag, + customName: customName, + customColor: customColor, + customIcon: customIcon, + markActive: markActive, + stackUserID: stackUserID, + teamID: teamID, + now: now + ) + } + + @discardableResult + func upsertRoutesIfAuthorized( + macDeviceID: String, + displayName: String?, + routes: [CmxAttachRoute], + condition: MobilePairedMacRouteWriteCondition, + markActive: Bool?, + stackUserID: String?, + teamID: String?, + now: Date + ) async throws -> Bool { + let instanceTag: String? + switch condition { + case .matchingInstanceTag(let expectedInstanceTag): + instanceTag = expectedInstanceTag + case .unclaimed: + instanceTag = nil + } + guard isCompatible(instanceTag: instanceTag) else { return false } + return try await inner.upsertRoutesIfAuthorized( + macDeviceID: macDeviceID, + displayName: displayName, + routes: routes, + condition: condition, + markActive: markActive, + stackUserID: stackUserID, + teamID: teamID, + now: now + ) + } + + func loadAll( + stackUserID: String?, + teamID: String? + ) async throws -> [MobilePairedMac] { + try await inner.loadAll(stackUserID: stackUserID, teamID: teamID).filter { + isCompatible(instanceTag: $0.instanceTag) + } + } + + func activeMac( + stackUserID: String?, + teamID: String? + ) async throws -> MobilePairedMac? { + try await loadAll(stackUserID: stackUserID, teamID: teamID) + .first(where: \.isActive) + } + + func setActive( + macDeviceID: String, + stackUserID: String?, + teamID: String? + ) async throws { + guard let target = try await loadAll(stackUserID: stackUserID, teamID: teamID) + .first(where: { $0.macDeviceID == macDeviceID }) else { return } + try await setActive( + macDeviceID: macDeviceID, + instanceTag: target.instanceTag, + stackUserID: stackUserID, + teamID: teamID + ) + } + + func setActive( + macDeviceID: String, + instanceTag: String?, + stackUserID: String?, + teamID: String? + ) async throws { + guard isCompatible(instanceTag: instanceTag) else { return } + try await inner.setActive( + macDeviceID: macDeviceID, + instanceTag: instanceTag, + stackUserID: stackUserID, + teamID: teamID + ) + } + + func clearActive(stackUserID: String?, teamID: String?) async throws { + try await inner.clearActive(stackUserID: stackUserID, teamID: teamID) + } + + func setCustomization( + macDeviceID: String, + customName: String?, + customColor: String?, + customIcon: String?, + stackUserID: String?, + teamID: String?, + now: Date + ) async throws { + guard let target = try await loadAll(stackUserID: stackUserID, teamID: teamID) + .first(where: { $0.macDeviceID == macDeviceID }) else { return } + try await setCustomization( + macDeviceID: macDeviceID, + instanceTag: target.instanceTag, + customName: customName, + customColor: customColor, + customIcon: customIcon, + stackUserID: stackUserID, + teamID: teamID, + now: now + ) + } + + func setCustomization( + macDeviceID: String, + instanceTag: String?, + customName: String?, + customColor: String?, + customIcon: String?, + stackUserID: String?, + teamID: String?, + now: Date + ) async throws { + guard isCompatible(instanceTag: instanceTag) else { return } + try await inner.setCustomization( + macDeviceID: macDeviceID, + instanceTag: instanceTag, + customName: customName, + customColor: customColor, + customIcon: customIcon, + stackUserID: stackUserID, + teamID: teamID, + now: now + ) + } + + func remove( + macDeviceID: String, + stackUserID: String?, + teamID: String? + ) async throws { + guard let target = try await loadAll(stackUserID: stackUserID, teamID: teamID) + .first(where: { $0.macDeviceID == macDeviceID }) else { return } + try await remove( + macDeviceID: macDeviceID, + instanceTag: target.instanceTag, + stackUserID: stackUserID, + teamID: teamID + ) + } + + func remove( + macDeviceID: String, + instanceTag: String?, + stackUserID: String?, + teamID: String? + ) async throws { + guard isCompatible(instanceTag: instanceTag) else { return } + try await inner.remove( + macDeviceID: macDeviceID, + instanceTag: instanceTag, + stackUserID: stackUserID, + teamID: teamID + ) + } + + func removeAll() async throws { + for mac in try await loadAll(stackUserID: nil, teamID: nil) { + try await inner.remove( + macDeviceID: mac.macDeviceID, + instanceTag: mac.instanceTag, + stackUserID: mac.stackUserID, + teamID: mac.teamID + ) + } + } + + /// Legacy rows remain visible long enough to be claimed by an + /// authenticated tagged instance. Live route adoption still fails closed + /// in ``MobileMacBuildCompatibilityPolicy/allows(instanceTag:)``. + private func isCompatible(instanceTag: String?) -> Bool { + instanceTag == nil || policy.allows(instanceTag: instanceTag) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift index fa45d0ed42a1..e28cf4f22d71 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift @@ -59,11 +59,13 @@ public enum MobilePairingFailureCategory: Equatable, Sendable { /// failures and stay ``authFailed``. /// /// `macChannelIsRelease` is the direction: `true` = development-auth - /// phone scanning a release Mac's QR (the sideloaded-dogfood bug; remedy - /// `--prod-auth`), `false` = production-auth phone (TestFlight/App Store, - /// or a `--prod-auth` dev build) scanning a dev Mac's QR (remedy: pair - /// with a release Mac, or use a development-auth phone build). + /// phone scanning a release Mac's QR, `false` = production-auth phone + /// scanning a dev Mac's QR. The remedy follows build compatibility: + /// official iOS with Stable/Nightly, or exact-tag DEV with DEV. case authEnvironmentMismatch(macChannelIsRelease: Bool) + /// The authenticated Mac app instance is outside this iOS build's + /// compatibility policy. + case buildIncompatible /// The pairing link/QR expired; a fresh one is needed. case ticketExpired /// The scanned/typed input was not a pairing QR cmux recognizes (malformed, @@ -109,6 +111,7 @@ extension MobilePairingFailureCategory { case .emailMismatch: return "email_mismatch" case .authFailed: return "auth" case .authEnvironmentMismatch: return "auth_environment_mismatch" + case .buildIncompatible: return "build_incompatible" case .ticketExpired: return "ticket_expired" case .invalidCode: return "invalid_code" case .unrecognizedVersion: return "unrecognized_version" @@ -124,8 +127,8 @@ extension MobilePairingFailureCategory { /// Whether a definitive auth failure that should drive the re-auth prompt /// (Sign Out) instead of a "could not connect / Retry" banner. /// ``authEnvironmentMismatch`` is deliberately NOT one: re-authenticating - /// cannot move the account to another Stack project — the remedy is a - /// build-level change (rebuild with `--prod-auth`), not signing out. + /// cannot move the account to another Stack project. The remedy is choosing + /// compatible app builds, not signing out. public var isAuthorizationFailure: Bool { switch self { case .accountMismatch, .emailMismatch, .authFailed, .ticketExpired: @@ -232,6 +235,11 @@ extension MobilePairingFailureCategory { "mobile.pairing.authEnvironmentMismatch.devMac", defaultValue: "This iPhone uses cmux's production sign-in, but this Mac runs a dev build on the development auth environment, so their accounts can never match — even with the same email." ) + case .buildIncompatible: + return L10n.string( + "mobile.pairing.buildIncompatible", + defaultValue: "This iPhone build cannot connect to that cmux build." + ) case .ticketExpired: return L10n.string( "mobile.pairing.attachTicketExpired", @@ -312,7 +320,7 @@ extension MobilePairingFailureCategory { if macChannelIsRelease { return L10n.string( "mobile.pairing.guidance.authEnvironment", - defaultValue: "Rebuild this app with production auth (ios/scripts/reload.sh --prod-auth), or pair with a dev-channel Mac signed in to the same account." + defaultValue: "Use BETA, INTERNAL, or the App Store app with Stable or Nightly. Use this DEV app with a Mac that has the same DEV tag." ) } // Reaches production users (TestFlight/App Store scanning a dev @@ -321,6 +329,11 @@ extension MobilePairingFailureCategory { "mobile.pairing.guidance.authEnvironment.devMac", defaultValue: "Pair with a Mac running the release cmux app, or use a development-channel iPhone build for dev Macs." ) + case .buildIncompatible: + return L10n.string( + "mobile.pairing.guidance.buildIncompatible", + defaultValue: "DEV builds must use the same DEV tag. BETA, INTERNAL, and App Store builds connect only to Stable or Nightly." + ) case .ticketExpired, .unsupportedRoute, .noSupportedRoute: return L10n.string( "mobile.pairing.guidance.rescanFresh", @@ -423,6 +436,9 @@ extension MobilePairingFailureCategory { ) -> MobilePairingFailureCategory { let normalizedCode = code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if let normalizedCode { + if normalizedCode == "build_incompatible" { + return .buildIncompatible + } if normalizedCode == "account_mismatch" { return .accountMismatch } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+BuildCompatibility.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+BuildCompatibility.swift new file mode 100644 index 000000000000..00f0b3ad6ec4 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+BuildCompatibility.swift @@ -0,0 +1,59 @@ +internal import CmuxMobileShellModel +internal import Foundation + +@MainActor +extension MobileShellComposite { + /// Whether authenticated host status belongs to this iOS build's audience. + func macBuildIsCompatible(instanceTag: String?) -> Bool { + buildCompatibilityPolicy?.allows(instanceTag: instanceTag) ?? true + } + + /// Removes registry app instances this iOS build is not allowed to use. + func compatibleRegistryDevices(_ devices: [RegistryDevice]) -> [RegistryDevice] { + guard let buildCompatibilityPolicy else { return devices } + return devices.compactMap { device in + let compatibleInstances = device.instances.filter { + buildCompatibilityPolicy.allows(instanceTag: $0.tag) + } + guard !compatibleInstances.isEmpty else { return nil } + var compatibleDevice = device + compatibleDevice.instances = compatibleInstances + compatibleDevice.lastSeenAt = compatibleInstances + .map(\.lastSeenAt) + .max() ?? .distantPast + return compatibleDevice + } + } + + /// Removes incompatible Mac instances from live presence updates. + func compatiblePresenceUpdate(_ update: PresenceUpdate) -> PresenceUpdate? { + guard let buildCompatibilityPolicy else { return update } + switch update { + case .snapshot(var snapshot): + snapshot.devices = snapshot.devices.compactMap { device in + let instances = device.instances.filter { + buildCompatibilityPolicy.allows(instanceTag: $0.tag) + } + guard !instances.isEmpty else { return nil } + var compatibleDevice = device + compatibleDevice.instances = instances + compatibleDevice.online = instances.contains(where: \.online) + compatibleDevice.lastSeenAt = instances.map(\.lastSeenAt).max() ?? 0 + return compatibleDevice + } + return .snapshot(snapshot) + case .online(let instance): + return buildCompatibilityPolicy.allows(instanceTag: instance.tag) + ? .online(instance) : nil + case .offline(let instance, let reason): + return buildCompatibilityPolicy.allows(instanceTag: instance.tag) + ? .offline(instance, reason: reason) : nil + case .seen(let deviceId, let tag, let lastSeenAt): + return buildCompatibilityPolicy.allows(instanceTag: tag) + ? .seen(deviceId: deviceId, tag: tag, lastSeenAt: lastSeenAt) : nil + case .routes(let instance): + return buildCompatibilityPolicy.allows(instanceTag: instance.tag) + ? .routes(instance) : nil + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ManualAttachTicket.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ManualAttachTicket.swift index 5ebcb497e8a6..932e5ff3d411 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ManualAttachTicket.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ManualAttachTicket.swift @@ -7,7 +7,7 @@ import Foundation @MainActor extension MobileShellComposite { - func boundedPairingRequestTimeoutNanoseconds( + nonisolated static func boundedPairingRequestTimeoutNanoseconds( runtime: any MobileSyncRuntime, attemptStartedAt: Date ) -> UInt64 { @@ -115,7 +115,7 @@ extension MobileShellComposite { ) let timeoutNanoseconds: UInt64 if let attemptStartedAt { - timeoutNanoseconds = boundedPairingRequestTimeoutNanoseconds( + timeoutNanoseconds = Self.boundedPairingRequestTimeoutNanoseconds( runtime: runtime, attemptStartedAt: attemptStartedAt ) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 77d175a90352..ebe7e79b02a7 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -579,6 +579,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { let runtime: (any MobileSyncRuntime)? let pairedMacStore: (any MobilePairedMacStoring)? + /// Single compatibility authority shared by registry, persistence, and live connections. + let buildCompatibilityPolicy: MobileMacBuildCompatibilityPolicy? private let pairedMacRestoreBoundary: PairedMacRestoreBoundary? /// Best-effort, team-scoped lookup of fresher attach routes from the device /// registry. Optional and failure-tolerant: when `nil` or unreachable, @@ -883,6 +885,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { pairingCode: String = "", workspaces: [MobileWorkspacePreview] = [], pairedMacStore: (any MobilePairedMacStoring)? = nil, + buildCompatibilityPolicy: MobileMacBuildCompatibilityPolicy? = nil, pairedMacRestoreBoundary: PairedMacRestoreBoundary? = nil, deviceRegistry: (any DeviceRegistryRefreshing)? = nil, presence: (any PresenceSubscribing)? = nil, @@ -907,6 +910,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.draftStore = draftStore self.groupCollapseStore = groupCollapseStore self.pairedMacStore = pairedMacStore + self.buildCompatibilityPolicy = buildCompatibilityPolicy self.pairedMacRestoreBoundary = pairedMacRestoreBoundary self.deviceRegistry = deviceRegistry self.presence = presence @@ -1914,7 +1918,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { let connectedID = connectedMacDeviceID let forgottenIDs = await forgottenMacDeviceIDs(scope: scope) guard await isScopeCurrent(scope) else { return } - registryDevices = loaded.filter { !forgottenIDs.contains($0.deviceId) }.sorted { lhs, rhs in + registryDevices = compatibleRegistryDevices(loaded) + .filter { !forgottenIDs.contains($0.deviceId) }.sorted { lhs, rhs in let lhsConnected = lhs.deviceId == connectedID let rhsConnected = rhs.deviceId == connectedID if lhsConnected != rhsConnected { return lhsConnected } @@ -2018,6 +2023,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } func applyPresenceUpdate(_ update: PresenceUpdate, scope: MobileShellScopeSnapshot) { + guard let update = compatiblePresenceUpdate(update) else { return } presenceMap.apply(update) switch update { case .routes(let instance), .online(let instance): @@ -2579,6 +2585,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectedHostName = resolvedName } let resolvedTag = instanceTag?.trimmingCharacters(in: .whitespacesAndNewlines) + guard macBuildIsCompatible(instanceTag: resolvedTag) else { + rejectForegroundHostIdentity(client: client, reason: "build_incompatible") + return + } if let activeMacInstanceTag, let resolvedTag, !resolvedTag.isEmpty, @@ -4639,7 +4649,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { do { let requestTimeoutNanoseconds: UInt64 if let connectionAttemptStartedAt { - requestTimeoutNanoseconds = boundedPairingRequestTimeoutNanoseconds( + requestTimeoutNanoseconds = Self.boundedPairingRequestTimeoutNanoseconds( runtime: runtime, attemptStartedAt: connectionAttemptStartedAt ) @@ -4649,38 +4659,47 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } else { requestTimeoutNanoseconds = runtime.pairingRequestTimeoutNanoseconds } - let resultData = try await client.sendRequest( + let exchange = try await client.sendRequestAndAuthenticatedHostStatus( workspaceListRequest.data, - timeoutNanoseconds: requestTimeoutNanoseconds + timeoutNanoseconds: requestTimeoutNanoseconds, + hostStatusTimeoutNanoseconds: { + if let connectionAttemptStartedAt { + return Self.boundedPairingRequestTimeoutNanoseconds( + runtime: runtime, + attemptStartedAt: connectionAttemptStartedAt + ) + } + return runtime.pairingRequestTimeoutNanoseconds + } ) - let response = try MobileSyncWorkspaceListResponse.decode(resultData) + let response = try MobileSyncWorkspaceListResponse.decode(exchange.response) guard isConnectCurrent() else { await client.disconnect() return nil } - let hostStatusTimeoutNanoseconds: UInt64 - if let connectionAttemptStartedAt { - hostStatusTimeoutNanoseconds = boundedPairingRequestTimeoutNanoseconds( - runtime: runtime, - attemptStartedAt: connectionAttemptStartedAt - ) - guard hostStatusTimeoutNanoseconds > 0 else { - throw MobileShellConnectionError.requestTimedOut - } - } else { - hostStatusTimeoutNanoseconds = runtime.pairingRequestTimeoutNanoseconds - } // Bind the route to the authenticated Mac process before // persisting or labeling workspaces. A stale A endpoint may // now be served by tag B on the same physical Mac. - let status = await requestHostStatus( - on: client, - timeoutNanoseconds: hostStatusTimeoutNanoseconds - ) - let reportedDeviceID = status?.macDeviceID? + guard let status = try? MobileHostStatusResponse.decode(exchange.hostStatusResponse) else { + await client.disconnect() + lastError = MobileShellConnectionError.invalidResponse + continue routeLoop + } + let reportedDeviceID = status.macDeviceID? .trimmingCharacters(in: .whitespacesAndNewlines) let hasAuthenticatedIdentity = reportedDeviceID?.isEmpty == false - let reportedInstanceTag = hasAuthenticatedIdentity ? status?.macInstanceTag : nil + let reportedInstanceTag = hasAuthenticatedIdentity ? status.macInstanceTag : nil + guard macBuildIsCompatible(instanceTag: reportedInstanceTag) else { + mobileShellLog.error( + "rejecting route from incompatible Mac build reported=\(reportedInstanceTag ?? "missing", privacy: .public)" + ) + await client.disconnect() + lastError = MobileShellConnectionError.rpcError( + "build_incompatible", + "Mac build is incompatible with this iOS build" + ) + continue routeLoop + } let authority = MobileMacInstanceTagAuthority.resolve( expectation: instanceTagExpectation, reportedInstanceTag: reportedInstanceTag @@ -4736,7 +4755,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { adoptingReportedDeviceID: reportedDeviceID ) activeTicket = resolvedTicket - let reportedName = hasAuthenticatedIdentity ? status?.macDisplayName : nil + let reportedName = hasAuthenticatedIdentity ? status.macDisplayName : nil if let reportedName = reportedName? .trimmingCharacters(in: .whitespacesAndNewlines), !reportedName.isEmpty { @@ -4768,7 +4787,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { replaceRemoteClient(with: client) activeMacInstanceTag = resolvedInstanceTag prepareTerminalThemeRevisionAuthority( - macInstanceTag: resolvedInstanceTag, producerEpoch: status?.terminalThemeRevisionEpoch, + macInstanceTag: resolvedInstanceTag, producerEpoch: status.terminalThemeRevisionEpoch, connectionID: generation.uuidString ) // Reuse the authenticated status response that bound this @@ -4948,24 +4967,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return requests } - private func requestHostStatus( - on client: MobileCoreRPCClient, - timeoutNanoseconds: UInt64 - ) async -> MobileHostStatusResponse? { - do { - let data = try await client.sendRequest( - MobileCoreRPCClient.requestData(method: "mobile.host.status", params: [:]), - timeoutNanoseconds: timeoutNanoseconds - ) - return try? MobileHostStatusResponse.decode(data) - } catch { - mobileShellLog.info( - "authenticated host status unavailable during connect: \(String(describing: error), privacy: .private)" - ) - return nil - } - } - private static func ticket( _ ticket: CmxAttachTicket, adoptingReportedDeviceID reportedDeviceID: String? diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceServiceConfiguration.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceServiceConfiguration.swift index 5fe355dd54bc..bda8202655f0 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceServiceConfiguration.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceServiceConfiguration.swift @@ -34,9 +34,9 @@ extension PresenceClient { /// one (`isDevelopmentAuthChannel`), not just the build config: each worker /// verifies its own Stack project's tokens, so a Debug build resolved to /// production auth (`ios/scripts/reload.sh --prod-auth`, issue 7145) must - /// subscribe to the production worker or no release Mac could ever appear - /// in Computers. The worker URLs live only here — build scripts bake no - /// copy — so they cannot drift from the runtime. + /// subscribe to the production worker so its token is accepted. The build + /// compatibility policy separately filters Mac instances. The worker URLs + /// live only here, so build scripts cannot drift from the runtime. public static func resolvedServiceBaseURL( environment: [String: String] = ProcessInfo.processInfo.environment, defaults: UserDefaults = .standard, diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IOSBuildScopedPairedMacStoreTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IOSBuildScopedPairedMacStoreTests.swift index 9af61a8e022c..4389e91d05f9 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IOSBuildScopedPairedMacStoreTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IOSBuildScopedPairedMacStoreTests.swift @@ -26,6 +26,17 @@ import Testing try CmxAttachRoute(id: "manual", kind: .tailscale, endpoint: .hostPort(host: host, port: 22)) } + private func irohRoute(_ endpointID: Character) throws -> CmxAttachRoute { + try CmxAttachRoute( + id: "iroh", + kind: .iroh, + endpoint: .peer( + identity: CmxIrohPeerIdentity(endpointID: String(repeating: endpointID, count: 64)), + pathHints: [] + ) + ) + } + @Test func scopesRowsByIOSBuildTag() async throws { let (inner, directory) = try makeInnerStore() defer { try? FileManager.default.removeItem(at: directory) } @@ -36,6 +47,7 @@ import Testing macDeviceID: "mac-a", displayName: "A", routes: [try route("10.0.0.1")], + instanceTag: "feature", markActive: true, stackUserID: "user-1", teamID: "team-a", @@ -45,6 +57,7 @@ import Testing macDeviceID: "mac-b", displayName: "B", routes: [try route("10.0.0.2")], + instanceTag: "other", markActive: true, stackUserID: "user-1", teamID: "team-a", @@ -87,6 +100,7 @@ import Testing macDeviceID: "teamless", displayName: "Teamless", routes: [try route("10.0.0.1")], + instanceTag: "feature", markActive: true, stackUserID: "user-1", teamID: nil, @@ -96,6 +110,7 @@ import Testing macDeviceID: "other-scope", displayName: "Other", routes: [try route("10.0.0.2")], + instanceTag: "other", markActive: true, stackUserID: "user-1", teamID: nil, @@ -107,46 +122,91 @@ import Testing #expect(rows.first?.teamID == nil) } - @Test func newerTeamlessDuplicateKeepsLogicalActiveSelection() async throws { + @Test func buildScopeHidesSiblingTagAndMatchingLegacyPeer() async throws { let (inner, directory) = try makeInnerStore() defer { try? FileManager.default.removeItem(at: directory) } + let scope = try #require(MobileIOSBuildScope("feature")) let feature = IOSBuildScopedPairedMacStore( inner: inner, - scope: try #require(MobileIOSBuildScope("feature")) + scope: scope + ) + + try await feature.upsert( + macDeviceID: "mac-a", + displayName: "Current", + routes: [try irohRoute("a")], + instanceTag: "feature", + markActive: true, + stackUserID: "user-1", + teamID: "team-a", + now: Date(timeIntervalSince1970: 30) + ) + try await inner.upsert( + macDeviceID: "mac-a", + displayName: "Other app instance", + routes: [try irohRoute("b")], + instanceTag: "other", + markActive: false, + stackUserID: "user-1", + teamID: "\u{1F}\(scope.serializedScope)", + now: Date(timeIntervalSince1970: 20) + ) + try await inner.upsert( + macDeviceID: "mac-a", + displayName: "Legacy alias", + routes: [try irohRoute("a")], + instanceTag: nil, + markActive: false, + stackUserID: "user-1", + teamID: "\u{1F}\(scope.serializedScope)", + now: Date(timeIntervalSince1970: 10) + ) + + let rows = try await feature.loadAll(stackUserID: "user-1", teamID: "team-a") + + #expect(rows.map(\.instanceTag) == ["feature"]) + } + + @Test func newerTeamlessSiblingTagIsNotVisibleOrActive() async throws { + let (inner, directory) = try makeInnerStore() + defer { try? FileManager.default.removeItem(at: directory) } + let scope = try #require(MobileIOSBuildScope("feature")) + let feature = IOSBuildScopedPairedMacStore( + inner: inner, + scope: scope ) try await feature.upsert( macDeviceID: "mac-a", displayName: "Selected", routes: [try route("10.0.0.1")], - instanceTag: "feature-a", + instanceTag: "feature", markActive: true, stackUserID: "user-1", teamID: "team-a", now: Date(timeIntervalSince1970: 1) ) - try await feature.upsert( + try await inner.upsert( macDeviceID: "mac-a", displayName: "Fallback", routes: [try route("10.0.0.2")], instanceTag: "feature-b", markActive: false, stackUserID: "user-1", - teamID: nil, + teamID: "\u{1F}\(scope.serializedScope)", now: Date(timeIntervalSince1970: 2) ) let rows = try await feature.loadAll( stackUserID: "user-1", teamID: "team-a" ) - let row = try #require(rows.first) #expect(rows.count == 1) - #expect(row.teamID == nil) - #expect(row.instanceTag == "feature-b") - #expect(row.routes == [try route("10.0.0.2")]) - #expect(row.isActive) + let selected = try #require(rows.first { $0.instanceTag == "feature" }) + #expect(selected.teamID == "team-a") + #expect(selected.routes == [try route("10.0.0.1")]) + #expect(selected.isActive) #expect(try await feature.activeMac( stackUserID: "user-1", teamID: "team-a" - )?.instanceTag == "feature-b") + )?.instanceTag == "feature") } @Test func selectedTeamUpsertClaimsTeamlessScopedRow() async throws { @@ -158,6 +218,7 @@ import Testing macDeviceID: "mac-a", displayName: "A", routes: [try route("10.0.0.1")], + instanceTag: "feature", markActive: true, stackUserID: "user-1", teamID: nil, @@ -176,6 +237,7 @@ import Testing macDeviceID: "mac-a", displayName: "A", routes: [try route("10.0.0.9")], + instanceTag: "feature", markActive: true, stackUserID: "user-1", teamID: "team-a", @@ -200,6 +262,7 @@ import Testing macDeviceID: "teamless", displayName: "Teamless", routes: [try route("10.0.0.1")], + instanceTag: "feature", markActive: true, stackUserID: "user-1", teamID: nil, @@ -209,6 +272,7 @@ import Testing macDeviceID: "team-row", displayName: "Team", routes: [try route("10.0.0.2")], + instanceTag: "feature", markActive: true, stackUserID: "user-1", teamID: "team-a", @@ -228,13 +292,13 @@ import Testing ) try await feature.upsert( macDeviceID: "mac-b", displayName: "B", routes: [try route("10.0.0.2")], - instanceTag: "feature-b", markActive: true, stackUserID: "user-1", + instanceTag: "feature", markActive: true, stackUserID: "user-1", teamID: nil, now: Date(timeIntervalSince1970: 20) ) _ = try await feature.upsertIfNewer( macDeviceID: "mac-a", displayName: "A", routes: [try route("10.0.0.1")], - instanceTag: "feature-a", customName: nil, customColor: nil, + instanceTag: "feature", customName: nil, customColor: nil, customIcon: nil, markActive: true, stackUserID: "user-1", teamID: "team-a", now: Date(timeIntervalSince1970: 10) ) @@ -252,7 +316,7 @@ import Testing ) try await feature.upsert( macDeviceID: "mac-a", displayName: "A", routes: [try route("10.0.0.1")], - instanceTag: "feature-a", markActive: false, stackUserID: "user-1", + instanceTag: "feature", markActive: false, stackUserID: "user-1", teamID: nil, now: Date(timeIntervalSince1970: 1) ) try await feature.setActive( @@ -261,7 +325,7 @@ import Testing _ = try await feature.upsertIfNewer( macDeviceID: "mac-a", displayName: "A", routes: [try route("10.0.0.9")], - instanceTag: "feature-a", customName: nil, customColor: nil, + instanceTag: "feature", customName: nil, customColor: nil, customIcon: nil, markActive: false, stackUserID: "user-1", teamID: "team-a", now: Date(timeIntervalSince1970: 10) ) @@ -279,7 +343,7 @@ import Testing let seedStore = IOSBuildScopedPairedMacStore(inner: inner, scope: scope) try await seedStore.upsert( macDeviceID: "mac-a", displayName: "A", routes: [try route("10.0.0.1")], - instanceTag: "feature-a", markActive: true, stackUserID: "user-1", + instanceTag: "feature", markActive: true, stackUserID: "user-1", teamID: nil, now: Date(timeIntervalSince1970: 1) ) let gatedInner = GatedUpsertStore(inner: inner) @@ -288,7 +352,7 @@ import Testing let restore = Task { try await feature.upsertIfNewer( macDeviceID: "mac-a", displayName: "Restored A", - routes: [try route("10.0.0.9")], instanceTag: "feature-a", + routes: [try route("10.0.0.9")], instanceTag: "feature", customName: nil, customColor: nil, customIcon: nil, markActive: true, stackUserID: "user-1", teamID: "team-a", now: Date(timeIntervalSince1970: 10) @@ -298,7 +362,7 @@ import Testing let liveWrite = Task { try await feature.upsert( macDeviceID: "mac-a", displayName: "Live B", - routes: [try route("10.0.0.2")], instanceTag: "feature-b", + routes: [try route("10.0.0.2")], instanceTag: "feature", markActive: true, stackUserID: "user-1", teamID: nil, now: Date(timeIntervalSince1970: 20) ) @@ -310,7 +374,7 @@ import Testing let current = try #require(await feature.loadAll( stackUserID: "user-1", teamID: "team-a" ).first(where: { $0.macDeviceID == "mac-a" })) - #expect(current.instanceTag == "feature-b") + #expect(current.instanceTag == "feature") #expect(current.displayName == "Live B") #expect(current.routes.first?.endpoint == .hostPort(host: "10.0.0.2", port: 22)) #expect(current.isActive) @@ -326,6 +390,7 @@ import Testing macDeviceID: "mac-a", displayName: "A", routes: [try route("10.0.0.1")], + instanceTag: "feature", markActive: true, stackUserID: "user-1", teamID: "team-a", @@ -335,6 +400,7 @@ import Testing macDeviceID: "mac-b", displayName: "B", routes: [try route("10.0.0.2")], + instanceTag: "other", markActive: true, stackUserID: "user-1", teamID: "team-a", diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift index bab60a5dfbc2..7195dce9b12b 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift @@ -936,10 +936,8 @@ extension ReconnectRouteSelectionTests { let recovered = try await pollUntil(attempts: 100) { guard let current = box.get() else { return false } - let foregroundProbeCount = await router.count(of: "mobile.workspace.list") return current !== firstTransport && store.connectionState == .connected - && foregroundProbeCount >= 1 } #expect(recovered) #expect(store.activeRoute?.kind == .iroh) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacBuildCompatibilityPolicyTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacBuildCompatibilityPolicyTests.swift new file mode 100644 index 000000000000..c708589ee0c4 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacBuildCompatibilityPolicyTests.swift @@ -0,0 +1,276 @@ +import CMUXMobileCore +import CmuxMobilePairedMac +import CmuxMobileShellModel +import Foundation +import Testing +@testable import CmuxMobileShell + +@Suite struct MobileMacBuildCompatibilityPolicyTests { + @Test func developmentRequiresExactTag() { + let policy = MobileMacBuildCompatibilityPolicy.development( + expectedInstanceTag: "icap" + ) + + #expect(policy.allows(instanceTag: "icap")) + #expect(policy.allows(instanceTag: " ICAP ")) + #expect(!policy.allows(instanceTag: "tsmig")) + #expect(!policy.allows(instanceTag: "default")) + #expect(!policy.allows(instanceTag: "nightly")) + #expect(!policy.allows(instanceTag: nil)) + } + + @Test func officialKeepsStableAndNightlyAsDistinctAllowedIdentities() { + let policy = MobileMacBuildCompatibilityPolicy.official + + #expect(policy.allows(instanceTag: "default")) + #expect(policy.allows(instanceTag: "nightly")) + #expect(!policy.allows(instanceTag: "icap")) + #expect(!policy.allows(instanceTag: "rc")) + #expect(!policy.allows(instanceTag: "staging")) + #expect(!policy.allows(instanceTag: nil)) + } + + @Test func scopedStoreHidesAndRejectsIncompatibleRows() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let raw = try MobilePairedMacStore( + databaseURL: directory.appendingPathComponent("paired.sqlite3") + ) + let route = try CmxAttachRoute( + id: "test", + kind: .tailscale, + endpoint: .hostPort(host: "100.64.0.1", port: 22) + ) + for (tag, seen) in [("icap", 1.0), ("tsmig", 2.0)] { + try await raw.upsert( + macDeviceID: "shared-mac", + displayName: tag, + routes: [route], + instanceTag: tag, + markActive: tag == "tsmig", + stackUserID: "user-1", + teamID: "team-a", + now: Date(timeIntervalSince1970: seen) + ) + } + let scoped = MobileMacBuildCompatibilityPolicy + .development(expectedInstanceTag: "icap") + .scoping(raw) + + #expect(try await scoped.loadAll( + stackUserID: "user-1", teamID: "team-a" + ).map(\.instanceTag) == ["icap"]) + #expect(try await scoped.activeMac( + stackUserID: "user-1", teamID: "team-a" + ) == nil) + + try await scoped.upsert( + macDeviceID: "other-mac", + displayName: "Other", + routes: [route], + instanceTag: "tsmig", + markActive: true, + stackUserID: "user-1", + teamID: "team-a", + now: Date(timeIntervalSince1970: 3) + ) + #expect(try await raw.loadAll( + stackUserID: "user-1", teamID: "team-a" + ).allSatisfy { $0.macDeviceID != "other-mac" }) + } + + @Test func scopedStoreKeepsUnclaimedLegacyRowsMigratable() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let raw = try MobilePairedMacStore( + databaseURL: directory.appendingPathComponent("paired.sqlite3") + ) + let originalRoute = try CmxAttachRoute( + id: "legacy", + kind: .tailscale, + endpoint: .hostPort(host: "100.64.0.1", port: 22) + ) + let updatedRoute = try CmxAttachRoute( + id: "legacy-updated", + kind: .tailscale, + endpoint: .hostPort(host: "100.64.0.2", port: 22) + ) + try await raw.upsert( + macDeviceID: "legacy-mac", + displayName: "Legacy", + routes: [originalRoute], + instanceTag: nil, + markActive: true, + stackUserID: "user-1", + teamID: "team-a", + now: Date(timeIntervalSince1970: 1) + ) + let scoped = MobileMacBuildCompatibilityPolicy + .development(expectedInstanceTag: "icap") + .scoping(raw) + + #expect(try await scoped.loadAll( + stackUserID: "user-1", teamID: "team-a" + ).map(\.macDeviceID) == ["legacy-mac"]) + let updated = try await scoped.upsertRoutesIfAuthorized( + macDeviceID: "legacy-mac", + displayName: "Legacy", + routes: [updatedRoute], + condition: .unclaimed, + markActive: nil, + stackUserID: "user-1", + teamID: "team-a", + now: Date(timeIntervalSince1970: 2) + ) + + #expect(updated) + #expect(try await raw.loadAll( + stackUserID: "user-1", teamID: "team-a" + ).first?.routes == [updatedRoute]) + } + + @Test func scopedRemoveAllPreservesIncompatibleRows() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let raw = try MobilePairedMacStore( + databaseURL: directory.appendingPathComponent("paired.sqlite3") + ) + let route = try CmxAttachRoute( + id: "test", + kind: .tailscale, + endpoint: .hostPort(host: "100.64.0.1", port: 22) + ) + for (tag, seenAt) in [("icap", 1.0), ("tsmig", 2.0)] { + try await raw.upsert( + macDeviceID: "shared-mac", + displayName: tag, + routes: [route], + instanceTag: tag, + markActive: false, + stackUserID: "user-1", + teamID: "team-a", + now: Date(timeIntervalSince1970: seenAt) + ) + } + let scoped = MobileMacBuildCompatibilityPolicy + .development(expectedInstanceTag: "icap") + .scoping(raw) + + try await scoped.removeAll() + + #expect(try await raw.loadAll( + stackUserID: nil, teamID: nil + ).compactMap(\.instanceTag) == ["tsmig"]) + } + + @Test func officialStoreKeepsStableAndNightlyButRejectsDevelopment() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let raw = try MobilePairedMacStore( + databaseURL: directory.appendingPathComponent("paired.sqlite3") + ) + let route = try CmxAttachRoute( + id: "test", + kind: .tailscale, + endpoint: .hostPort(host: "100.64.0.1", port: 22) + ) + for (tag, seen) in [("default", 1.0), ("nightly", 2.0), ("icap", 3.0)] { + try await raw.upsert( + macDeviceID: "shared-mac", + displayName: tag, + routes: [route], + instanceTag: tag, + markActive: false, + stackUserID: "user-1", + teamID: "team-a", + now: Date(timeIntervalSince1970: seen) + ) + } + let official = MobileMacBuildCompatibilityPolicy.official.scoping(raw) + + #expect(Set(try await official.loadAll( + stackUserID: "user-1", teamID: "team-a" + ).compactMap(\.instanceTag)) == ["default", "nightly"]) + } + + @MainActor + @Test func registryProjectionKeepsOnlyCompatibleInstances() { + let store = MobileShellComposite( + buildCompatibilityPolicy: .development(expectedInstanceTag: "icap") + ) + let device = RegistryDevice( + deviceId: "shared-mac", + platform: "mac", + displayName: "Mac", + lastSeenAt: Date(timeIntervalSince1970: 20), + instances: [ + RegistryAppInstance( + tag: "icap", routes: [], lastSeenAt: Date(timeIntervalSince1970: 10) + ), + RegistryAppInstance( + tag: "tsmig", routes: [], lastSeenAt: Date(timeIntervalSince1970: 20) + ), + ] + ) + + let projected = store.compatibleRegistryDevices([device]) + + #expect(projected.count == 1) + #expect(projected[0].instances.map(\.tag) == ["icap"]) + #expect(projected[0].lastSeenAt == Date(timeIntervalSince1970: 10)) + } + + @MainActor + @Test func presenceProjectionKeepsOnlyCompatibleInstances() { + let store = MobileShellComposite( + buildCompatibilityPolicy: .development(expectedInstanceTag: "icap") + ) + let icap = PresenceInstance( + deviceId: "shared-mac", + tag: "icap", + platform: "mac", + online: false, + lastSeenAt: 10 + ) + let tsmig = PresenceInstance( + deviceId: "shared-mac", + tag: "tsmig", + platform: "mac", + online: true, + lastSeenAt: 20 + ) + let update = PresenceUpdate.snapshot(PresenceSnapshot( + teamId: "team-a", + now: 20, + heartbeatIntervalMs: 5, + offlineTimeoutMs: 15, + devices: [PresenceDevice( + deviceId: "shared-mac", + platform: "mac", + displayName: "Mac", + online: true, + lastSeenAt: 20, + instances: [icap, tsmig] + )] + )) + + let projected = store.compatiblePresenceUpdate(update) + guard case .snapshot(let snapshot) = projected else { + Issue.record("expected a compatible presence snapshot") + return + } + #expect(snapshot.devices.count == 1) + #expect(snapshot.devices[0].instances.map(\.tag) == ["icap"]) + #expect(!snapshot.devices[0].online) + #expect(snapshot.devices[0].lastSeenAt == 10) + #expect(store.compatiblePresenceUpdate(.online(tsmig)) == nil) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift index 50984d9e0d28..98cab49ef3f7 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift @@ -142,17 +142,34 @@ import Testing #expect(category.analyticsReason == "unsupported_route") } + @Test func incompatibleBuildNamesTheCompatibilityBoundary() { + let category = MobilePairingFailureCategory.classify( + error: MobileShellConnectionError.rpcError( + "build_incompatible", + "Mac build is incompatible with this iOS build" + ), + route: nil + ) + + #expect(category == .buildIncompatible) + #expect(category.analyticsReason == "build_incompatible") + #expect(category.message.contains("cannot connect")) + #expect(category.guidance?.contains("same DEV tag") == true) + #expect(!category.isAuthorizationFailure) + } + @Test func authEnvironmentMismatchTellsTheTruthAboutTheChannel() { // A dev-channel build failing a release Mac's QR user-id binding: the // emails DO match, so the copy must name the auth-environment cause and - // the --prod-auth remedy instead of the authFailed "same email" advice + // the matching-build remedy instead of the authFailed "same email" advice // (https://github.com/manaflow-ai/cmux/issues/7145). let category = MobilePairingFailureCategory.authEnvironmentMismatch(macChannelIsRelease: true) #expect(category.analyticsReason == "auth_environment_mismatch") #expect(category.message.contains("development auth environment")) #expect(category.message != MobilePairingFailureCategory.authFailed.message) #expect(!category.message.contains("Make sure both devices are signed in")) - #expect(category.guidance?.contains("--prod-auth") == true) + #expect(category.guidance?.contains("BETA") == true) + #expect(category.guidance?.contains("same DEV tag") == true) // Signing out cannot move the account to another Stack project, so this // must not drive the re-auth (Sign Out) prompt. #expect(!category.isAuthorizationFailure) @@ -235,6 +252,7 @@ import Testing .authFailed, .authEnvironmentMismatch(macChannelIsRelease: true), .authEnvironmentMismatch(macChannelIsRelease: false), + .buildIncompatible, .ticketExpired, .invalidCode, .unrecognizedVersion, diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildPresenceRouteIsolationTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildPresenceRouteIsolationTests.swift index 3c1cb28890d6..776d4d35540d 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildPresenceRouteIsolationTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildPresenceRouteIsolationTests.swift @@ -128,7 +128,7 @@ import Testing #expect(try await storedRoutes(in: pairedStore) == [restartedB]) } - @Test func productionAuthTaggedBuildUsesDefaultMacForRoutesAndRecovery() async throws { + @Test func officialBuildUsesStableMacForRoutesAndRecovery() async throws { let original = try route(id: "original", host: "100.64.0.1", port: 50_000) let defaultRoute = try route(id: "stable", host: "100.64.0.2", port: 51_001) let routeB = try route(id: "b", host: "100.64.0.3", port: 51_002) @@ -143,6 +143,7 @@ import Testing let store = MobileShellComposite( isSignedIn: true, pairedMacStore: pairedStore, + buildCompatibilityPolicy: .official, identityProvider: StaticIdentityProvider(userID: "user-1"), teamIDProvider: { "team-a" }, reachability: AlwaysOnlineReachability() diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildRegistryRouteIsolationTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildRegistryRouteIsolationTests.swift index 568354157196..51da30315950 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildRegistryRouteIsolationTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildRegistryRouteIsolationTests.swift @@ -38,7 +38,7 @@ import Testing ) == nil) } - @Test func productionAuthTaggedIOSReadsOnlyDefaultMacRoutes() throws { + @Test func officialIOSReadsOnlyRequestedStableMacRoutes() throws { let data = try JSONSerialization.data(withJSONObject: [ "devices": [[ "deviceId": "shared-physical-mac", diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index 4020956e3317..c3bb712deb19 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -186,7 +186,10 @@ struct CMUXMobileRootView: View { .onChange(of: authManager.isRestoringSession) { _, isRestoringSession in syncShellAuthentication(isAuthenticated, isRestoringSession: isRestoringSession) guard !isRestoringSession else { return } - _ = consumePendingURLIfReady() + if consumePendingURLIfReady() { + return + } + reconnectStoredMacIfNeeded() } .onChange(of: store.connectionState) { _, connectionState in if connectionState == .connected { @@ -377,12 +380,13 @@ struct CMUXMobileRootView: View { /// sign-in that completes after mount) so the restoring gate always resolves /// even when the auth state never transitions while this view is mounted. private func reconnectStoredMacIfNeeded() { - guard isAuthenticated else { return } + guard isAuthenticated, !authManager.isRestoringSession else { return } let startedUITestAttachURL = connectUITestAttachURLIfNeeded() guard !startedUITestAttachURL, MobileRootAuthGate.shouldReconnectStoredMac( stackAuthenticated: authManager.isAuthenticated, attachTicketAuthenticated: hasActiveAttachTicketAuthentication, + isRestoringSession: authManager.isRestoringSession, connectionState: store.connectionState ) else { return } let stackUserID = authManager.currentUser?.id diff --git a/Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileRootAuthGate.swift b/Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileRootAuthGate.swift index 16d04bfd5553..c39b957d68cf 100644 --- a/Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileRootAuthGate.swift +++ b/Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileRootAuthGate.swift @@ -78,14 +78,19 @@ public struct MobileRootAuthGate { /// - Parameters: /// - stackAuthenticated: Whether Stack auth is established. /// - attachTicketAuthenticated: Whether a temporary attach ticket grants access. + /// - isRestoringSession: Whether cached auth is still being validated or recreated. /// - connectionState: The current connection state. - /// - Returns: `true` when Stack-authenticated without a temporary ticket and not yet connected. + /// - Returns: `true` when Stack-authenticated, auth restore is complete, no temporary ticket is active, and the Mac is not yet connected. public static func shouldReconnectStoredMac( stackAuthenticated: Bool, attachTicketAuthenticated: Bool, + isRestoringSession: Bool, connectionState: MobileConnectionState ) -> Bool { - stackAuthenticated && !attachTicketAuthenticated && connectionState != .connected + stackAuthenticated + && !isRestoringSession + && !attachTicketAuthenticated + && connectionState != .connected } /// Whether the restoring-session UI should be shown while reconnecting a known diff --git a/Packages/iOS/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/MobileRootAuthGateTests.swift b/Packages/iOS/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/MobileRootAuthGateTests.swift index 47597cc3d866..c879db9c86ef 100644 --- a/Packages/iOS/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/MobileRootAuthGateTests.swift +++ b/Packages/iOS/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/MobileRootAuthGateTests.swift @@ -84,16 +84,25 @@ import Testing #expect(MobileRootAuthGate.shouldReconnectStoredMac( stackAuthenticated: true, attachTicketAuthenticated: false, + isRestoringSession: false, connectionState: .disconnected )) #expect(!MobileRootAuthGate.shouldReconnectStoredMac( stackAuthenticated: true, attachTicketAuthenticated: true, + isRestoringSession: false, connectionState: .disconnected )) #expect(!MobileRootAuthGate.shouldReconnectStoredMac( stackAuthenticated: false, attachTicketAuthenticated: true, + isRestoringSession: false, + connectionState: .disconnected + )) + #expect(!MobileRootAuthGate.shouldReconnectStoredMac( + stackAuthenticated: true, + attachTicketAuthenticated: false, + isRestoringSession: true, connectionState: .disconnected )) } diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index 4aa07d0269a5..86846c64a7f8 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -1013,6 +1013,9 @@ final class MobileHostService { ) }, onAuthorizedRequest: { request in + await MobileHostService.shared.retireSupersededIrohConnections( + newestConnectionID: id + ) guard let clientID = Self.clientID(from: request.params) else { return } @@ -1202,6 +1205,14 @@ final class MobileHostService { MobileHostRequestActivity.endConnection() } + private func retireSupersededIrohConnections(newestConnectionID: UUID) async { + let superseded = MobileHostConnectionRegistry.shared + .removeOlderIrohConnectionsIfNewest(id: newestConnectionID) + for connection in superseded { + await connection.close(reason: "superseded by newer authenticated iroh session") + } + } + private func recordClientID(_ clientID: String, for connectionID: UUID) { var clientIDs = clientIDsByConnectionID[connectionID] ?? [] clientIDs.insert(clientID) @@ -1906,14 +1917,14 @@ actor MobileHostConnection { guard !isClosed, !Task.isCancelled else { return } - if let intercepted = await handleSubscriptionRPC(request) { - _ = await sendResponse(MobileHostRPCEnvelope.encodeResponse(id: request.id, result: intercepted)) - return - } await onAuthorizedRequest(request) guard !isClosed, !Task.isCancelled else { return } + if let intercepted = await handleSubscriptionRPC(request) { + _ = await sendResponse(MobileHostRPCEnvelope.encodeResponse(id: request.id, result: intercepted)) + return + } let result = await handleRequest(request) guard !isClosed, !Task.isCancelled else { return diff --git a/Sources/Mobile/MobileHostTransportAuthorization.swift b/Sources/Mobile/MobileHostTransportAuthorization.swift index 0f66fb1857ed..ad330aed3c5f 100644 --- a/Sources/Mobile/MobileHostTransportAuthorization.swift +++ b/Sources/Mobile/MobileHostTransportAuthorization.swift @@ -39,6 +39,7 @@ final class MobileHostConnectionRegistry: @unchecked Sendable { private struct Entry { let connection: MobileHostConnection let authorization: MobileHostConnectionAuthorizationContext + let insertionSequence: UInt64 } static let shared = MobileHostConnectionRegistry() @@ -46,6 +47,7 @@ final class MobileHostConnectionRegistry: @unchecked Sendable { private let lock = NSLock() private let irohBindingConnectionQuota = CmxIrohActiveBindingConnectionQuota() private var connections: [UUID: Entry] = [:] + private var nextInsertionSequence: UInt64 = 0 var count: Int { lock.lock() @@ -79,7 +81,12 @@ final class MobileHostConnectionRegistry: @unchecked Sendable { return false } } - connections[id] = Entry(connection: connection, authorization: authorization) + nextInsertionSequence &+= 1 + connections[id] = Entry( + connection: connection, + authorization: authorization, + insertionSequence: nextInsertionSequence + ) lock.unlock() // Notify after the authoritative count actually changes (this registry // backs `MobileHostServiceStatus.activeConnectionCount`), so the Mobile @@ -132,6 +139,41 @@ final class MobileHostConnectionRegistry: @unchecked Sendable { } } + /// Retires reconnect overlap only after the replacement has processed an + /// authorized request. An older connection can never evict a newer one, + /// even if its delayed request finishes after the replacement arrived. + func removeOlderIrohConnectionsIfNewest(id: UUID) -> [MobileHostConnection] { + lock.lock() + guard let current = connections[id], + case let .irohAdmission(currentPeer) = current.authorization else { + lock.unlock() + return [] + } + let sameBinding = connections.filter { _, entry in + guard case let .irohAdmission(peer) = entry.authorization else { + return false + } + return peer.bindingID == currentPeer.bindingID + } + guard sameBinding.values.allSatisfy({ + $0.insertionSequence <= current.insertionSequence + }) else { + lock.unlock() + return [] + } + let older = sameBinding.filter { candidateID, entry in + candidateID != id && entry.insertionSequence < current.insertionSequence + } + for olderID in older.keys { + connections[olderID] = nil + } + lock.unlock() + if !older.isEmpty { + NotificationCenter.default.post(name: .mobileHostStatusDidChange, object: nil) + } + return older.values.map(\.connection) + } + private func removeConnections( where shouldRemove: (MobileHostConnectionAuthorizationContext) -> Bool ) -> [MobileHostConnection] { diff --git a/cmuxTests/MobileHostConnectionLifecycleTests.swift b/cmuxTests/MobileHostConnectionLifecycleTests.swift index a125e6ff25a4..c74eb04c434d 100644 --- a/cmuxTests/MobileHostConnectionLifecycleTests.swift +++ b/cmuxTests/MobileHostConnectionLifecycleTests.swift @@ -42,6 +42,84 @@ extension MobileHostAuthorizationTests { #expect(await closeRecorder.recordedIDs() == [connectionID]) } + @Test func testNewestAuthorizedIrohConnectionSupersedesOlderOverlap() async throws { + let service = MobileHostService.shared + service.debugResetMobileLifecycleStateForTesting() + let registry = MobileHostConnectionRegistry.shared + for connection in registry.removeAll() { + await connection.close(reason: "test setup") + } + + let first = ScriptedMobileHostByteTransport() + let second = ScriptedMobileHostByteTransport() + let authorization = try irohAdmissionContext() + let firstTask = Task { + await MobileHostService.acceptTransport( + first, + authorization: authorization, + isCurrent: { true } + ) + } + await waitForMobileHostConnectionCount(1) + try await first.enqueue(Self.mobileHostStatusFrame(id: "first")) + _ = await first.waitForSentBufferCount(1) + + let secondTask = Task { + await MobileHostService.acceptTransport( + second, + authorization: authorization, + isCurrent: { true } + ) + } + await waitForMobileHostConnectionCount(2) + try await first.enqueue(Self.mobileHostStatusFrame(id: "first-delayed")) + _ = await first.waitForSentBufferCount(2) + #expect(registry.count == 2) + #expect(await second.observedCloseCount() == 0) + + try await second.enqueue(Self.mobileHostSubscribeFrame(id: "second")) + _ = await second.waitForSentBufferCount(1) + await waitForMobileHostConnectionCount(1) + await first.waitForCloseCount(1) + + #expect(registry.count == 1) + #expect(await first.observedCloseCount() == 1) + #expect(await second.observedCloseCount() == 0) + + await first.finishReceiving() + await second.finishReceiving() + await firstTask.value + await secondTask.value + for connection in registry.removeAll() { + await connection.close(reason: "test cleanup") + } + service.debugResetMobileLifecycleStateForTesting() + } + + private static func mobileHostStatusFrame(id: String) throws -> Data { + try MobileSyncFrameCodec.encodeFrame( + Data("{\"id\":\"\(id)\",\"method\":\"mobile.host.status\",\"params\":{}}".utf8) + ) + } + + private static func mobileHostSubscribeFrame(id: String) throws -> Data { + try MobileSyncFrameCodec.encodeFrame( + Data("{\"id\":\"\(id)\",\"method\":\"mobile.events.subscribe\",\"params\":{\"stream_id\":\"events\",\"topics\":[\"terminal.updated\"]}}".utf8) + ) + } + + private func waitForMobileHostConnectionCount(_ expected: Int) async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + while clock.now < deadline { + if MobileHostConnectionRegistry.shared.count == expected { return } + await Task.yield() + } + Issue.record( + "Timed out waiting for \(expected) mobile host connections; observed \(MobileHostConnectionRegistry.shared.count)" + ) + } + @Test func testIrohEventWriterTimesOutBackpressureWithInjectedClock() async { let stream = BlockingMobileHostIrohSendStream() let writer = MobileHostIrohServerEventWriter( @@ -325,3 +403,79 @@ private actor GatedMobileHostByteTransport: CmxByteTransport { closeCount } } + +private actor ScriptedMobileHostByteTransport: CmxByteTransport { + private var receiveQueue: [Data?] = [] + private var receiveWaiter: CheckedContinuation? + private var sent: [Data] = [] + private var closeCount = 0 + private var sentWaiters: [(count: Int, continuation: CheckedContinuation<[Data], Never>)] = [] + private var closeWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + func connect() async throws {} + + func receive() async throws -> Data? { + if !receiveQueue.isEmpty { + return receiveQueue.removeFirst() + } + return await withCheckedContinuation { receiveWaiter = $0 } + } + + func send(_ data: Data) async throws { + sent.append(data) + let ready = sentWaiters.filter { sent.count >= $0.count } + sentWaiters.removeAll { sent.count >= $0.count } + for waiter in ready { + waiter.continuation.resume(returning: sent) + } + } + + func close() async { + closeCount += 1 + let ready = closeWaiters.filter { closeCount >= $0.count } + closeWaiters.removeAll { closeCount >= $0.count } + for waiter in ready { + waiter.continuation.resume() + } + receiveWaiter?.resume(returning: nil) + receiveWaiter = nil + } + + func enqueue(_ data: Data) { + if let receiveWaiter { + self.receiveWaiter = nil + receiveWaiter.resume(returning: data) + } else { + receiveQueue.append(data) + } + } + + func finishReceiving() { + if let receiveWaiter { + self.receiveWaiter = nil + receiveWaiter.resume(returning: nil) + } else { + receiveQueue.append(nil) + } + } + + func waitForSentBufferCount(_ count: Int) async -> [Data] { + if sent.count >= count { + return sent + } + return await withCheckedContinuation { continuation in + sentWaiters.append((count, continuation)) + } + } + + func observedCloseCount() -> Int { closeCount } + + func waitForCloseCount(_ count: Int) async { + if closeCount >= count { + return + } + await withCheckedContinuation { continuation in + closeWaiters.append((count, continuation)) + } + } +} diff --git a/ios/Config/Shared.xcconfig b/ios/Config/Shared.xcconfig index 2eed0a960e30..88e745605c60 100644 --- a/ios/Config/Shared.xcconfig +++ b/ios/Config/Shared.xcconfig @@ -38,13 +38,12 @@ CMUX_PRESENCE_BASE_URL = // Auth-environment override, baked into the CMUXAuthEnvironment Info.plist key // so a normally-tapped dev device build (no shell env) can sign in against the -// PRODUCTION Stack project and pair with a release (beta/stable) Mac — a -// DEBUG build otherwise signs in to the development project, whose user ids -// can never match a release Mac's QR account binding -// (https://github.com/manaflow-ai/cmux/issues/7145). Empty by default -// (DEBUG -> development, Release -> production). ios/scripts/reload.sh -// --prod-auth sets it to "production". Keep the Info.plist key name in sync -// with MobileAuthComposition.authEnvironmentInfoPlistKey. +// PRODUCTION Stack project when testing production-account behavior. Build +// compatibility remains separate: a tagged DEV iOS build may connect only to +// the same tagged DEV Mac build. Empty by default (DEBUG -> development, +// Release -> production). ios/scripts/reload.sh --prod-auth sets it to +// "production". Keep the Info.plist key name in sync with +// MobileAuthComposition.authEnvironmentInfoPlistKey. CMUX_IOS_AUTH_ENV = // Tagged reloads bake the matching macOS tag's isolated web origin here. diff --git a/ios/README.md b/ios/README.md index 4d0b8af9ce3a..11e614e761f2 100644 --- a/ios/README.md +++ b/ios/README.md @@ -25,14 +25,14 @@ Run package tests: swift test --package-path ios/cmuxPackage ``` -## Pairing a sideloaded dev build with a real (beta/stable) Mac +## Build compatibility and production-auth DEV builds -A plain dev (DEBUG) build signs in to cmux's development Stack project. Stack -user ids are per-project, so a dev build's user id can never match the -production account binding (`ub`) a release Mac stamps into its pairing QR — -pairing fails instantly, even with the same email on the same tailnet -(https://github.com/manaflow-ai/cmux/issues/7145). To dogfood a device build -against your real Mac, build with production auth: +A DEV iOS build connects only to the Mac DEV build with the same tag. BETA, +INTERNAL, and App Store iOS builds connect only to Stable or Nightly Mac builds. +Account environment does not change that compatibility boundary. + +Use `--prod-auth` only when a tagged DEV build needs to test production account, +registry, or API behavior: ```bash ios/scripts/reload.sh --tag my-tag --device-only --prod-auth @@ -46,21 +46,21 @@ What `--prod-auth` does: registry/API and the magic-link callback. - Makes the presence worker follow the auth channel: the app resolves the production presence instance (see `PresenceClient.productionServiceURL`) so - your real Macs appear in Computers. The worker URLs live only in Swift — - the script bakes no copy — and an explicit `CMUX_PRESENCE_BASE_URL` still + compatible Macs appear in Computers. The worker URLs live only in Swift; + the script bakes no copy, and an explicit `CMUX_PRESENCE_BASE_URL` still wins. - Skips the dogfood auto sign-in/auto-pair (those credentials belong to the development Stack project). Sign in in-app with the same account as your - Mac. + matching tagged DEV Mac. - On first launch after switching auth environments on the same install, the app clears the previous environment's session/caches (tokens and user ids are per-Stack-project), so you start signed out instead of restoring a stale identity. -Scan the Mac's pairing QR with the **in-app** scanner. The system Camera app -routes release QR links (`cmux-ios://…`) to the beta/App Store app because -pairing URL schemes are channel-specific; the in-app scanner accepts both -schemes. +The system Camera routes release QR links (`cmux-ios://…`) to an official iOS +app and DEV QR links (`cmux-ios-dev://…`) to a DEV iOS app. The authenticated +Mac status supplies the exact instance tag, which the app validates before it +saves or adopts the connection. Without the flag, the same override is available by bundling a `LocalConfig.plist` with an `AuthEnvironment` string of `production` (see diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 705e082f7cd2..ccc46cffd1cd 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -3350,6 +3350,23 @@ } } }, + "mobile.pairing.buildIncompatible": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This iPhone build cannot connect to that cmux build." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この iPhone ビルドは、その cmux ビルドに接続できません。" + } + } + } + }, "mobile.pairing.emailMismatchFormat": { "extractionState": "manual", "localizations": { @@ -3407,13 +3424,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Rebuild this app with production auth (ios/scripts/reload.sh --prod-auth), or pair with a dev-channel Mac signed in to the same account." + "value": "Use BETA, INTERNAL, or the App Store app with Stable or Nightly. Use this DEV app with a Mac that has the same DEV tag." } }, "ja": { "stringUnit": { "state": "translated", - "value": "本番認証を使ってこのアプリをビルドし直す(ios/scripts/reload.sh --prod-auth)か、同じアカウントでサインインした開発チャネルの Mac とペアリングしてください。" + "value": "BETA、INTERNAL、または App Store アプリは Stable または Nightly と一緒に使用してください。この DEV アプリは同じ DEV タグを持つ Mac と一緒に使用してください。" } } } @@ -3435,6 +3452,23 @@ } } }, + "mobile.pairing.guidance.buildIncompatible": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "DEV builds must use the same DEV tag. BETA, INTERNAL, and App Store builds connect only to Stable or Nightly." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "DEV ビルド同士は同じ DEV タグを使用する必要があります。BETA、INTERNAL、App Store ビルドは Stable または Nightly にのみ接続できます。" + } + } + } + }, "mobile.pairing.guidance.localNetwork": { "extractionState": "manual", "localizations": { diff --git a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift index d1ba7ff76d83..5887ad27cdc8 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift @@ -198,9 +198,8 @@ public struct CMUXMobileRootScene: View { /// the current session and selected team. @MainActor private func makePresenceClient() -> PresenceClient? { - // Presence follows the resolved auth channel (not the build config): - // a --prod-auth dev build must subscribe to the production worker its - // production Macs heartbeat to (issue 7145). + // Presence follows the resolved auth channel so each worker can verify + // the token. Build compatibility filters the returned Mac instances. guard let baseURL = PresenceClient.resolvedServiceBaseURL( isDevelopmentAuthChannel: auth.authEnvironment == .development ) else { return nil } @@ -221,7 +220,8 @@ public struct CMUXMobileRootScene: View { @MainActor private func makeBackedUpPairedMacStore( restoreBoundary: PairedMacRestoreBoundary, - buildScope: MobileIOSBuildScope? + buildScope: MobileIOSBuildScope?, + buildCompatibilityPolicy: MobileMacBuildCompatibilityPolicy ) -> (any MobilePairedMacStoring)? { guard let store = pairedMacStore else { return nil } let coordinator = auth.coordinator @@ -232,7 +232,7 @@ public struct CMUXMobileRootScene: View { buildScopedStore = store } let scopedStore = TeamScopedPairedMacStore( - inner: buildScopedStore, + inner: buildCompatibilityPolicy.scoping(buildScopedStore), teamIDProvider: { await coordinator.resolvedTeamID } ) guard MobilePairedMacBackup.resolved().isEnabled, @@ -305,6 +305,9 @@ public struct CMUXMobileRootScene: View { private func makeStore() -> CMUXMobileShellStore { let coordinator = auth.coordinator let buildScope = MobileIOSBuildScope.current() + let buildCompatibilityPolicy = MobileMacBuildCompatibilityPolicy.current( + buildScope: buildScope + ) let identityProvider = AuthCoordinatorIdentityProvider( coordinator: auth.coordinator, isDevelopmentAuthEnvironment: auth.authEnvironment == .development @@ -312,7 +315,8 @@ public struct CMUXMobileRootScene: View { let restoreBoundary = PairedMacRestoreBoundary() let backedUpPairedMacStore = makeBackedUpPairedMacStore( restoreBoundary: restoreBoundary, - buildScope: buildScope + buildScope: buildScope, + buildCompatibilityPolicy: buildCompatibilityPolicy ) let deviceRegistry = makeDeviceRegistry(pairedMacStore: backedUpPairedMacStore) let forgottenMacStore = UserDefaultsPairedMacForgottenStore() @@ -324,6 +328,7 @@ public struct CMUXMobileRootScene: View { return CMUXMobileShellStore( runtime: runtime, pairedMacStore: backedUpPairedMacStore, + buildCompatibilityPolicy: buildCompatibilityPolicy, pairedMacRestoreBoundary: restoreBoundary, deviceRegistry: deviceRegistry, presence: makePresenceClient(), @@ -341,6 +346,7 @@ public struct CMUXMobileRootScene: View { return CMUXMobileShellStore( runtime: runtime, pairedMacStore: backedUpPairedMacStore, + buildCompatibilityPolicy: buildCompatibilityPolicy, pairedMacRestoreBoundary: restoreBoundary, deviceRegistry: deviceRegistry, presence: makePresenceClient(), diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileAuthComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileAuthComposition.swift index df606202dc25..5e3848723808 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileAuthComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileAuthComposition.swift @@ -28,8 +28,8 @@ public struct MobileAuthComposition { /// development and Release to production, but an ``authEnvironmentOverrideKey`` /// entry (from `LocalConfig.plist`, or the Info.plist value /// `ios/scripts/reload.sh --prod-auth` bakes) flips it, so a sideloaded - /// dev build can run production auth and pair with a release Mac - /// (https://github.com/manaflow-ai/cmux/issues/7145). Exposed so the + /// dev build can test production account behavior. Build compatibility is + /// enforced separately and remains exact-tag DEV to DEV. Exposed so the /// identity provider can label the channel its user ids belong to. public let authEnvironment: CMUXAuthEnvironment diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileAuthEnvironmentOverrideTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileAuthEnvironmentOverrideTests.swift index 0532b309c5a0..1951021f338b 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileAuthEnvironmentOverrideTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileAuthEnvironmentOverrideTests.swift @@ -19,10 +19,9 @@ private struct OfflineReachabilityStub: ReachabilityProviding { /// project, so its user id can never match the production account binding /// (`ub`) a release Mac stamps into its pairing QR — every prod QR fails the /// preflight before any route is dialed, even for the same email. The -/// supported fix is running a dev build against production auth through the -/// `AuthEnvironment` override (a `LocalConfig.plist` entry, or the Info.plist -/// value `ios/scripts/reload.sh --prod-auth` bakes). These tests pin that -/// override to the resolved auth configuration. +/// `AuthEnvironment` override still lets a DEV build test production account +/// behavior, but the separate build policy does not let it connect to an +/// official Mac. These tests pin only the auth override to its configuration. @MainActor @Suite struct MobileAuthEnvironmentOverrideTests { /// The production Stack project id (`CmuxAuthRuntime.AuthConfig`). @@ -61,8 +60,7 @@ private struct OfflineReachabilityStub: ReachabilityProviding { let composition = try makeComposition(bundle: bundle) // A dev build overridden to production auth must resolve the - // production Stack project and the production web API/callback, or its - // signed-in user id can never match a release Mac's QR account binding. + // production Stack project and the production web API/callback. #expect(composition.config.stack.projectId == Self.productionProjectID) #expect(composition.config.apiBaseURL == "https://cmux.com") #expect(composition.config.magicLinkCallbackURL == "https://cmux.com/auth/callback") @@ -338,10 +336,9 @@ private struct OfflineReachabilityStub: ReachabilityProviding { // MARK: - Presence follows the auth channel @Test func presenceDefaultFollowsAuthChannelNotBuildConfig() throws { - // A --prod-auth dev build (Debug config, production channel) must - // subscribe to the production worker its real Macs heartbeat to; the - // worker URLs live only in PresenceClient so build scripts cannot - // bake a stale copy. + // A --prod-auth dev build (Debug config, production channel) must use + // the worker that accepts its production token. Build compatibility + // filters the returned Mac instances separately. #expect(PresenceClient.resolvedServiceBaseURL( environment: [:], defaults: try freshDefaults(), diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobilePairingAccountPreflightTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobilePairingAccountPreflightTests.swift index 3186dd0c3b8d..d60220bf126f 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobilePairingAccountPreflightTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobilePairingAccountPreflightTests.swift @@ -10,7 +10,7 @@ import Testing /// "make sure both devices are signed in with the same email" copy even though /// the emails matched. The preflight must report that case as /// ``MobilePairingFailureCategory/authEnvironmentMismatch`` (truthful cause + -/// the --prod-auth remedy), keyed on the Mac's DECLARED channel (the pairing +/// the compatible-build remedy), keyed on the Mac's DECLARED channel (the pairing /// URL scheme, #6038) plus the phone's resolved auth environment — never /// inferred from the phone alone — while leaving the production↔production /// and dev↔dev bindings exactly as strict as before. @@ -51,7 +51,8 @@ import Testing #expect(message.contains("development auth environment")) #expect(message != MobilePairingFailureCategory.authFailed.message) #expect(!message.contains("Make sure both devices are signed in")) - #expect(category?.guidance?.contains("--prod-auth") == true) + #expect(category?.guidance?.contains("BETA") == true) + #expect(category?.guidance?.contains("same DEV tag") == true) #expect(category?.analyticsReason == "auth_environment_mismatch") // Re-authenticating cannot move the account to another Stack project, // so this must not drive the Sign Out re-auth prompt. @@ -59,8 +60,8 @@ import Testing } @Test func prodPhoneScanningDevMacQRNamesTheAuthEnvironmentToo() throws { - // The reverse direction: a production-auth phone (TestFlight, or a - // --prod-auth dev build) scanning a dev Mac's QR (scheme cmux-ios-dev) + // The reverse direction: a production-auth phone scanning a dev Mac's + // QR (scheme cmux-ios-dev) // hits the same per-project impossibility and must get the truthful // channel copy — not the "same email" advice. let category = MobilePairingAccountPreflight( diff --git a/ios/scripts/reload.sh b/ios/scripts/reload.sh index 3064c00c462e..27fca121a8d0 100755 --- a/ios/scripts/reload.sh +++ b/ios/scripts/reload.sh @@ -20,13 +20,12 @@ the tagged Mac app. Opt out granularly: --prod-auth sign this DEV build in against PRODUCTION auth (bakes CMUXAuthEnvironment=production into Info.plist; the presence - worker and API base follow the channel in-app), so it can - pair with a real beta/stable Mac via QR. A plain dev build - uses the development Stack project, whose user ids can never - match a release Mac's QR account binding. Implies + worker and API base follow the channel in-app). This does not + change build compatibility: the DEV iOS app still connects + only to the Mac DEV build with the same tag. Implies --no-sign-in (dogfood auto-login creds are dev-channel); sign in in-app with your real account and use the IN-APP - scanner (the system Camera routes prod QRs to the beta app). + scanner. Device signing uses the local Xcode account, or App Store Connect API credentials from ASC_API_KEY_ID, ASC_API_ISSUER_ID, ASC_API_KEY_PATH, or @@ -72,7 +71,8 @@ NO_SETUP=0 # Also honored via CMUX_SWIFT_FRONTEND_WORKAROUND=1. SWIFT_FRONTEND_WORKAROUND="${CMUX_SWIFT_FRONTEND_WORKAROUND:-0}" # --prod-auth: bake CMUXAuthEnvironment=production so the dev build signs in -# against the production Stack project and can pair with a release Mac. +# against the production Stack project. Build compatibility remains exact-tag +# DEV to DEV. PROD_AUTH=0 while [[ $# -gt 0 ]]; do @@ -189,8 +189,8 @@ if [[ "$SWIFT_FRONTEND_WORKAROUND" == "1" ]]; then ) fi -# --prod-auth: point the build at the production auth channel so it can pair -# with a real beta/stable Mac (https://github.com/manaflow-ai/cmux/issues/7145). +# --prod-auth: point the build at the production auth channel for production +# account, registry, and API testing (https://github.com/manaflow-ai/cmux/issues/7145). # The value lands in the CMUXAuthEnvironment Info.plist key (a tapped device # build sees no shell env), read by MobileAuthComposition. Presence needs no # URL here: PresenceClient.resolvedServiceBaseURL follows the resolved auth @@ -605,7 +605,7 @@ EOF cat < Date: Fri, 17 Jul 2026 07:25:18 -0700 Subject: [PATCH 84/92] fix: restore main's submodule pointers again The main-merge commit swept stale worktree submodule checkouts in via git add -A; pointer drift breaks main's newer bonsplit API call sites. Co-Authored-By: Claude Fable 5 --- ghostty | 2 +- vendor/bonsplit | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ghostty b/ghostty index eb500e9f45c8..bb30526cdab8 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit eb500e9f45c8b6ffa6043350ec1488a42d195406 +Subproject commit bb30526cdab8f5fb08ae43e404e3aacc40d3ffc3 diff --git a/vendor/bonsplit b/vendor/bonsplit index a0400b3533db..10563e2fda6f 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit a0400b3533db21afda4a5a59e695cbff588a39b7 +Subproject commit 10563e2fda6fc18c47adf1864d55e0e25087a864 From ef9382bbbba3c99937a34d0eadf653d25ebcb256 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Fri, 17 Jul 2026 08:31:12 -0700 Subject: [PATCH 85/92] fix: take main's sidebar row slot view verbatim A merge auto-resolution dropped an explicit self capture in a file this branch never intentionally touched. Co-Authored-By: Claude Fable 5 --- .../Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift index 659d7dc50925..3d858b3208b7 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift @@ -96,7 +96,7 @@ final class SidebarRowPullRequestIconView: NSView { ) // Tint inside the image first: .sourceAtop against the view's // transparent backing draws nothing (no destination pixels). - let tinted = NSImage(size: image.size, flipped: false) { drawRect in + let tinted = NSImage(size: image.size, flipped: false) { [color] drawRect in image.draw(in: drawRect, from: .zero, operation: .sourceOver, fraction: 1) color.set() drawRect.fill(using: .sourceAtop) From 1eec555286d705f4a06ce71645d510afc63d40fb Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Fri, 17 Jul 2026 12:06:34 -0700 Subject: [PATCH 86/92] test(ios): require present-first artifact routing --- ...tifactViewerPresentationRoutingTests.swift | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPresentationRoutingTests.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPresentationRoutingTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPresentationRoutingTests.swift new file mode 100644 index 000000000000..2dcee9751bf3 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPresentationRoutingTests.swift @@ -0,0 +1,86 @@ +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact viewer present-first routing") +struct ChatArtifactViewerPresentationRoutingTests { + @Test( + "presents the destination before a blocking route finishes", + arguments: ["/remote/README.md", "/remote/report.pdf"] + ) + @MainActor + func presentsBeforeRouteFinishes(path: String) async throws { + let isMarkdown = path.hasSuffix(".md") + let firstData = Data((isMarkdown ? "# Heading\n" : "%PDF-first").utf8) + let lastData = Data((isMarkdown ? "\nBody" : "-last").utf8) + let totalSize = Int64(firstData.count + lastData.count) + let stream = ControlledArtifactStream(chunks: [ + ChatArtifactChunk( + data: firstData, + offset: 0, + totalSize: totalSize, + eof: false + ), + ChatArtifactChunk( + data: lastData, + offset: Int64(firstData.count), + totalSize: totalSize, + eof: true + ), + ]) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: totalSize, + modifiedAt: Date(timeIntervalSince1970: 1), + kind: isMarkdown ? .text : .binary, + mimeType: isMarkdown ? "text/markdown" : "application/pdf" + ) + }, + stream: { _, onChunk in + try await stream.fetch(onChunk: onChunk) + } + ) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-present-first-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let model = ChatArtifactViewerModel( + temporaryFileStore: ChatArtifactTemporaryFileStore(directory: directory) + ) + let presentation = ChatArtifactViewerPresentationCoordinator() + + presentation.present() + let loadTask = Task { + await presentation.loadAfterPresentation { + await model.load(path: path, loader: loader) + } + } + await stream.waitUntilFirstChunkDelivered() + + #expect(presentation.isPresented) + #expect(model.fetchedBytes == Int64(firstData.count)) + #expect(model.fetchedBytes < totalSize) + if isMarkdown { + #expect(model.state == .markdown) + } else { + #expect(model.state == .loading) + } + + await stream.resume() + await loadTask.value + + if isMarkdown { + #expect(model.state == .markdown) + } else if case .pdf = model.state { + // The fully fetched PDF resolved in-page after presentation. + } else { + Issue.record("Expected the PDF route to resolve after presentation") + } + await model.cleanup() + } +} From 0573e03ea425092e8956f6becc9c998fd95fd854 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Fri, 17 Jul 2026 12:09:01 -0700 Subject: [PATCH 87/92] fix(ios): present artifact viewers before loading --- ...rtifactViewerPresentationCoordinator.swift | 35 +++++++++++++++++++ .../ChatArtifactViewerRouteView.swift | 14 ++++++-- ...tifactViewerPresentationRoutingTests.swift | 2 +- 3 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPresentationCoordinator.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPresentationCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPresentationCoordinator.swift new file mode 100644 index 000000000000..5860ec2fc91f --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPresentationCoordinator.swift @@ -0,0 +1,35 @@ +import Observation + +/// Orders viewer loading after the destination has entered the view hierarchy. +@Observable +@MainActor +final class ChatArtifactViewerPresentationCoordinator { + private(set) var isPresented = false + private(set) var generation = 0 + + /// Records a new appearance before any route-specific loader can run. + func present() { + guard !isPresented else { return } + isPresented = true + generation &+= 1 + } + + /// Records that the destination has left the view hierarchy. + func dismiss() { + isPresented = false + } + + /// Starts content resolution only after presentation has had a scheduler turn. + /// + /// - Parameter load: The shared stat, fetch, and route-resolution operation. + /// - Returns: Whether the operation started for a presented destination. + func loadAfterPresentation( + _ load: @MainActor () async -> Void + ) async -> Bool { + guard isPresented else { return false } + await Task.yield() + guard isPresented, !Task.isCancelled else { return false } + await load() + return true + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index 85b75e2f4676..d3c28296262b 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -16,6 +16,7 @@ struct ChatArtifactViewerRouteView: View { let onImageMinimumZoomChanged: (Bool) -> Void @Environment(\.colorScheme) private var colorScheme + @State private var presentation = ChatArtifactViewerPresentationCoordinator() init( snapshot: ChatArtifactViewerPageSnapshot, @@ -33,8 +34,17 @@ struct ChatArtifactViewerRouteView: View { var body: some View { content - .task(id: "\(path)\u{0}\(snapshot.retryGeneration)") { - await actions.load() + .onAppear { + presentation.present() + } + .onDisappear { + presentation.dismiss() + } + .task(id: "\(path)\u{0}\(snapshot.retryGeneration)\u{0}\(presentation.generation)") { + let didStart = await presentation.loadAfterPresentation { + await actions.load() + } + guard didStart else { return } await waitForViewerTaskCancellation() await actions.cleanup() } diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPresentationRoutingTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPresentationRoutingTests.swift index 2dcee9751bf3..5c15a0cfe62d 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPresentationRoutingTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPresentationRoutingTests.swift @@ -72,7 +72,7 @@ struct ChatArtifactViewerPresentationRoutingTests { } await stream.resume() - await loadTask.value + _ = await loadTask.value if isMarkdown { #expect(model.state == .markdown) From 8e8b80b68bb81cca890cc63bfd2f58605ecfa999 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Fri, 17 Jul 2026 12:20:06 -0700 Subject: [PATCH 88/92] feat(ios): gate terminal files chip behind beta setting --- .../GhosttySurfaceCoordinator+Artifacts.swift | 33 +++++++++----- .../GhosttySurfaceRepresentable.swift | 21 +++++---- .../MobileDisplaySettings.swift | 11 +++++ .../MobileSettingsView.swift | 10 +++++ .../TerminalArtifactChipFeatureGate.swift | 20 +++++++++ ...orkspaceDetailView+TerminalArtifacts.swift | 1 + .../WorkspaceDetailView.swift | 3 ++ .../MobileDisplaySettingsTests.swift | 16 +++++++ ...TerminalArtifactChipFeatureGateTests.swift | 44 +++++++++++++++++++ ios/cmux/Resources/Localizable.xcstrings | 34 ++++++++++++++ 10 files changed, 172 insertions(+), 21 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChipFeatureGate.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChipFeatureGateTests.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceCoordinator+Artifacts.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceCoordinator+Artifacts.swift index a90eb6497607..3a410556b843 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceCoordinator+Artifacts.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceCoordinator+Artifacts.swift @@ -12,11 +12,18 @@ extension GhosttySurfaceRepresentable.Coordinator { @discardableResult func updateArtifactCountMode( artifactFilesEnabled: Bool, + terminalFilesChipEnabled: Bool, sessionArtifactCountEnabled: Bool ) -> Bool { + let artifactChipGate = TerminalArtifactChipFeatureGate( + artifactsAvailable: artifactFilesEnabled, + preferenceEnabled: terminalFilesChipEnabled + ) let changed = self.artifactFilesEnabled != artifactFilesEnabled + || self.artifactChipGate != artifactChipGate || self.sessionArtifactCountEnabled != sessionArtifactCountEnabled self.artifactFilesEnabled = artifactFilesEnabled + self.artifactChipGate = artifactChipGate self.sessionArtifactCountEnabled = sessionArtifactCountEnabled guard changed else { return false } @@ -24,7 +31,7 @@ extension GhosttySurfaceRepresentable.Coordinator { artifactCountTask = nil artifactCountTaskRequest = nil artifactCountState.reset() - artifactCountNeedsRefresh = artifactFilesEnabled + artifactCountNeedsRefresh = artifactChipGate.isEnabled visibleArtifactCount = 0 freshestLocalArtifactCount = 0 return true @@ -57,21 +64,21 @@ extension GhosttySurfaceRepresentable.Coordinator { ) { let workspaceID = workspaceID let surfaceID = surfaceID + let artifactChipGate = artifactChipGate artifactCountTaskRequest = request artifactCountTask = Task { @MainActor [weak self, weak surfaceView] in let sessionTotal: Int? - if let source = self?.store?.makeChatEventSource() { - do { + do { + sessionTotal = try await artifactChipGate.performScan { [weak self] in + guard let source = self?.store?.makeChatEventSource() else { return nil } let response = try await source.terminalArtifactScan( workspaceID: workspaceID, surfaceID: surfaceID, countOnly: true ) - sessionTotal = response.sessionArtifactTotal - } catch { - sessionTotal = nil + return response.sessionArtifactTotal } - } else { + } catch { sessionTotal = nil } @@ -105,9 +112,10 @@ extension GhosttySurfaceRepresentable.Coordinator { /// Projects the workspace's value count into a small SwiftUI chip hosted /// by the terminal surface, preserving the dock's keyboard geometry. @MainActor - func updateArtifactChip(count: Int, enabled: Bool) { + func updateArtifactChip(count: Int) { visibleArtifactCount = count guard let surfaceView else { return } + let enabled = artifactChipGate.isEnabled let renderState = (count: count, enabled: enabled) if let lastArtifactChipRender, lastArtifactChipRender == renderState { return @@ -137,6 +145,7 @@ extension GhosttySurfaceRepresentable.Coordinator { @MainActor private func requestArtifactFilesFromChip() { + guard artifactChipGate.isEnabled else { return } guard let surfaceView, let chipView = artifactChipController?.view else { return } let frame = chipView.convert(chipView.bounds, to: surfaceView) let width = max(surfaceView.bounds.width, 1) @@ -193,7 +202,7 @@ extension GhosttySurfaceRepresentable.Coordinator { didDetectVisibleArtifactCount count: Int, generation: UInt64 ) { - guard artifactFilesEnabled else { return } + guard artifactChipGate.isEnabled else { return } freshestLocalArtifactCount = count let action = artifactCountState.trigger( localCount: count, @@ -208,12 +217,12 @@ extension GhosttySurfaceRepresentable.Coordinator { artifactCountTask = nil artifactCountTaskRequest = nil artifactCountState.reset() - artifactCountNeedsRefresh = artifactFilesEnabled + artifactCountNeedsRefresh = artifactChipGate.isEnabled freshestLocalArtifactCount = 0 let previousCount = visibleArtifactCount visibleArtifactCount = 0 guard self.surfaceView === surfaceView else { return } - updateArtifactChip(count: 0, enabled: artifactFilesEnabled) + updateArtifactChip(count: 0) guard previousCount != 0 else { return } onVisibleArtifactCountChanged(0) onArtifactGalleryRefreshSignal(TerminalArtifactGalleryRefreshSignal( @@ -227,7 +236,7 @@ extension GhosttySurfaceRepresentable.Coordinator { didChangeVisibleArtifactCount count: Int ) { artifactCountNeedsRefresh = false - guard artifactFilesEnabled, count != visibleArtifactCount else { return } + guard artifactChipGate.isEnabled, count != visibleArtifactCount else { return } visibleArtifactCount = count onVisibleArtifactCountChanged(count) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift index 1d847b954db9..b8b65535a10c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift @@ -46,6 +46,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { /// Ghostty config update without remounting or changing another scene. var configThemeGeneration: UInt64 = 0 var artifactFilesEnabled: Bool = false + var terminalFilesChipEnabled: Bool = false var sessionArtifactCountEnabled: Bool = false var visibleArtifactCount: Int = 0 var onArtifactFilesRequested: @MainActor (_ anchor: UnitPoint) -> Void = { _ in } @@ -59,6 +60,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { surfaceID: surfaceID, store: store, artifactFilesEnabled: artifactFilesEnabled, + terminalFilesChipEnabled: terminalFilesChipEnabled, sessionArtifactCountEnabled: sessionArtifactCountEnabled, visibleArtifactCount: visibleArtifactCount, onArtifactFilesRequested: onArtifactFilesRequested, @@ -131,6 +133,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { context.coordinator.onArtifactGalleryRefreshSignal = onArtifactGalleryRefreshSignal let artifactCountModeChanged = context.coordinator.updateArtifactCountMode( artifactFilesEnabled: artifactFilesEnabled, + terminalFilesChipEnabled: terminalFilesChipEnabled, sessionArtifactCountEnabled: sessionArtifactCountEnabled ) surfaceView.artifactFilesEnabled = artifactFilesEnabled @@ -140,10 +143,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { let projectedArtifactCount = context.coordinator.artifactCountNeedsRefresh ? 0 : visibleArtifactCount - context.coordinator.updateArtifactChip( - count: projectedArtifactCount, - enabled: artifactFilesEnabled - ) + context.coordinator.updateArtifactChip(count: projectedArtifactCount) surfaceView.setComposerActive(isComposerActive) context.coordinator.setComposerMounted(isComposerActive) context.coordinator.scheduleTheme(terminalConfigTheme, generation: configThemeGeneration) @@ -166,6 +166,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { weak var store: CMUXMobileShellStore? weak var surfaceView: GhosttySurfaceView? var artifactFilesEnabled: Bool + var artifactChipGate: TerminalArtifactChipFeatureGate var sessionArtifactCountEnabled: Bool var visibleArtifactCount: Int var onArtifactFilesRequested: @MainActor (_ anchor: UnitPoint) -> Void @@ -207,6 +208,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { surfaceID: String, store: CMUXMobileShellStore, artifactFilesEnabled: Bool, + terminalFilesChipEnabled: Bool, sessionArtifactCountEnabled: Bool, visibleArtifactCount: Int, onArtifactFilesRequested: @escaping @MainActor (_ anchor: UnitPoint) -> Void, @@ -218,9 +220,13 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { self.surfaceID = surfaceID self.store = store self.artifactFilesEnabled = artifactFilesEnabled + self.artifactChipGate = TerminalArtifactChipFeatureGate( + artifactsAvailable: artifactFilesEnabled, + preferenceEnabled: terminalFilesChipEnabled + ) self.sessionArtifactCountEnabled = sessionArtifactCountEnabled self.visibleArtifactCount = visibleArtifactCount - self.artifactCountNeedsRefresh = artifactFilesEnabled + self.artifactCountNeedsRefresh = artifactChipGate.isEnabled self.onArtifactFilesRequested = onArtifactFilesRequested self.onArtifactPathTapped = onArtifactPathTapped self.onVisibleArtifactCountChanged = onVisibleArtifactCountChanged @@ -231,10 +237,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { func attach(surfaceView: GhosttySurfaceView) { self.surfaceView = surfaceView surfaceView.artifactFilesEnabled = artifactFilesEnabled - updateArtifactChip( - count: artifactCountNeedsRefresh ? 0 : visibleArtifactCount, - enabled: artifactFilesEnabled - ) + updateArtifactChip(count: artifactCountNeedsRefresh ? 0 : visibleArtifactCount) guard let store else { return } let surfaceID = surfaceID viewportReportScheduler = TerminalViewportReportScheduler( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift index 9c731d76db64..ff63145fd2c6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift @@ -23,6 +23,7 @@ public final class MobileDisplaySettings { private static let wrapWorkspaceTitlesKey = "cmux.mobile.wrapWorkspaceTitles" private static let showAltScreenNoticeKey = "cmux.mobile.showAltScreenNotice" private static let showMissingFilesKey = "cmux.mobile.showMissingFiles" + private static let terminalFilesChipEnabledKey = "cmux.mobile.terminalFilesChipEnabled" private static let workspacePreviewLineCountKey = "cmux.mobile.workspacePreviewLineCount" private static let unreadIndicatorLeftShiftKey = "cmux.mobile.debug.unreadIndicatorLeftShift.v2" private static let profilePictureLeftShiftKey = "cmux.mobile.debug.profilePictureLeftShift" @@ -66,6 +67,15 @@ public final class MobileDisplaySettings { didSet { defaults.set(showMissingFiles, forKey: Self.showMissingFilesKey) } } + /// Whether the beta terminal files chip and its count scan are enabled. + /// Defaults to `false`. Mutating this writes through to the injected + /// ``UserDefaults``. + public var terminalFilesChipEnabled: Bool { + didSet { + defaults.set(terminalFilesChipEnabled, forKey: Self.terminalFilesChipEnabledKey) + } + } + /// How many lines a workspace row's activity preview shows (1 or 2). /// Defaults to 2. Mutating this clamps to the supported range and writes /// through to the injected ``UserDefaults``. @@ -118,6 +128,7 @@ public final class MobileDisplaySettings { self.wrapWorkspaceTitles = defaults.bool(forKey: Self.wrapWorkspaceTitlesKey) self.showAltScreenNotice = defaults.object(forKey: Self.showAltScreenNoticeKey) as? Bool ?? true self.showMissingFiles = defaults.bool(forKey: Self.showMissingFilesKey) + self.terminalFilesChipEnabled = defaults.bool(forKey: Self.terminalFilesChipEnabledKey) let storedPreviewLines = defaults.object(forKey: Self.workspacePreviewLineCountKey) as? Int self.workspacePreviewLineCount = Self.clampedWorkspacePreviewLineCount( storedPreviewLines ?? Self.defaultWorkspacePreviewLineCount diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 60e23f985c06..75233db0ef96 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -167,6 +167,16 @@ struct MobileSettingsView: View { .accessibilityIdentifier("MobileSettingsTerminalShortcuts") } + Section(L10n.string("mobile.settings.betaFeatures", defaultValue: "Beta Features")) { + Toggle(isOn: $displaySettings.terminalFilesChipEnabled) { + Text(L10n.string( + "mobile.settings.terminalFilesChip", + defaultValue: "Terminal Files Chip" + )) + } + .accessibilityIdentifier("MobileSettingsTerminalFilesChip") + } + #if DEBUG Section(L10n.string("mobile.settings.developer", defaultValue: "Developer")) { Button { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChipFeatureGate.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChipFeatureGate.swift new file mode 100644 index 000000000000..b4a9f6c50bae --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChipFeatureGate.swift @@ -0,0 +1,20 @@ +/// Owns the beta preference and capability checks for the terminal files chip. +/// +/// The coordinator uses this single seam both to decide whether the chip can +/// mount and to guard the count-only artifact scan that feeds it. +struct TerminalArtifactChipFeatureGate: Equatable, Sendable { + let isEnabled: Bool + + init(artifactsAvailable: Bool, preferenceEnabled: Bool) { + self.isEnabled = artifactsAvailable && preferenceEnabled + } + + /// Runs the chip's count scan only while the feature gate is enabled. + @MainActor + func performScan( + _ scan: @MainActor () async throws -> Result? + ) async rethrows -> Result? { + guard isEnabled else { return nil } + return try await scan() + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift index 7cd370345f1c..ce657dfc93eb 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift @@ -29,6 +29,7 @@ extension WorkspaceDetailView { // scrollback survives a theme change. configThemeGeneration: store.terminalConfigThemeGeneration, artifactFilesEnabled: store.supportsTerminalArtifacts, + terminalFilesChipEnabled: terminalFilesChipEnabled, sessionArtifactCountEnabled: store.supportsChatArtifactGallery, visibleArtifactCount: visibleArtifactCount, onArtifactFilesRequested: { anchor in diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 4581bd097009..b9bae4de0821 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -81,6 +81,9 @@ struct WorkspaceDetailView: View { browserStore.activeBrowser(for: workspace.id.rawValue) } #if os(iOS) + var terminalFilesChipEnabled: Bool { + displaySettings.terminalFilesChipEnabled + } var activeSurface: WorkspaceActiveSurface { WorkspaceActiveSurface.derive( isChatMode: isChatMode, diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift index 7433db33919a..0ac88ba7c827 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift @@ -58,6 +58,22 @@ import Testing #expect(!MobileDisplaySettings(defaults: defaults).showMissingFiles) } + @Test func terminalFilesChipDefaultsToFalseWithoutAWrite() throws { + let defaults = try makeDefaults("terminalFilesChipDefaults") + let settings = MobileDisplaySettings(defaults: defaults) + #expect(!settings.terminalFilesChipEnabled) + #expect(defaults.object(forKey: "cmux.mobile.terminalFilesChipEnabled") == nil) + } + + @Test func terminalFilesChipPersistsAcrossInstances() throws { + let defaults = try makeDefaults("terminalFilesChipPersists") + let settings = MobileDisplaySettings(defaults: defaults) + settings.terminalFilesChipEnabled = true + #expect(MobileDisplaySettings(defaults: defaults).terminalFilesChipEnabled) + settings.terminalFilesChipEnabled = false + #expect(!MobileDisplaySettings(defaults: defaults).terminalFilesChipEnabled) + } + @Test func previewLineCountPersistsAcrossInstances() throws { let defaults = try makeDefaults("persists") let settings = MobileDisplaySettings(defaults: defaults) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChipFeatureGateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChipFeatureGateTests.swift new file mode 100644 index 000000000000..954e7b44ca33 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChipFeatureGateTests.swift @@ -0,0 +1,44 @@ +import Testing + +@testable import CmuxMobileShellUI + +@Suite("Terminal artifact chip feature gate") +struct TerminalArtifactChipFeatureGateTests { + @Test("does not invoke the count scan when the preference is off") + @MainActor + func skipsScanWhenPreferenceIsOff() async { + let gate = TerminalArtifactChipFeatureGate( + artifactsAvailable: true, + preferenceEnabled: false + ) + var scanInvocationCount = 0 + + let result: Int? = await gate.performScan { + scanInvocationCount += 1 + return 7 + } + + #expect(!gate.isEnabled) + #expect(scanInvocationCount == 0) + #expect(result == nil) + } + + @Test("runs the count scan only when capability and preference are enabled") + @MainActor + func scansWhenFullyEnabled() async { + let gate = TerminalArtifactChipFeatureGate( + artifactsAvailable: true, + preferenceEnabled: true + ) + var scanInvocationCount = 0 + + let result: Int? = await gate.performScan { + scanInvocationCount += 1 + return 7 + } + + #expect(gate.isEnabled) + #expect(scanInvocationCount == 1) + #expect(result == 7) + } +} diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 67b852e2c561..4af65b265f6c 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -4489,6 +4489,23 @@ } } }, + "mobile.settings.betaFeatures": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Beta Features" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベータ機能" + } + } + } + }, "mobile.settings.deleteAccount": { "extractionState": "manual", "localizations": { @@ -5322,6 +5339,23 @@ } } }, + "mobile.settings.terminalFilesChip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal Files Chip" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルのファイルチップ" + } + } + } + }, "mobile.settings.wrapTitles": { "extractionState": "manual", "localizations": { From 68048d43f8027cbc458438bd6ef343ede52429d8 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Fri, 17 Jul 2026 12:22:39 -0700 Subject: [PATCH 89/92] test(ios): cover cold-open End rearming --- ...tifactTextBottomPinStateMachineTests.swift | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift index 62b3183ff6cf..38c1ee3f8259 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift @@ -157,4 +157,58 @@ struct ChatArtifactTextBottomPinStateMachineTests { #expect(pin.target == .end) #expect(pin.visibleBoundary == finalBoundary) } + + @Test("cold-open End re-arms across immediate appends before concluding at the visible end") + func coldOpenEndRearmsAcrossImmediateAppends() { + var appendPolicy = ChatArtifactTextAppendPolicy() + var pin = ChatArtifactTextBottomPinStateMachine() + let lineOneBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 1_024, + contentOffsetY: 0 + ) + let streamedBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 174_000, + contentOffsetY: 168_000 + ) + + #expect( + pin.engage(target: .latest, boundary: lineOneBoundary) + == .scrollToBottom(boundary: lineOneBoundary, animated: true) + ) + appendPolicy.beginProgrammaticAnimation() + + // Commit 62f57bab4a deliberately applies these chunks immediately. + #expect(appendPolicy.enqueue(chunkCount: 3) == 3) + #expect( + pin.appendsFlushed(at: streamedBoundary) + == .scrollToBottom(boundary: streamedBoundary, animated: true) + ) + #expect(pin.phase == .initialAnimation) + + var convergence = ChatArtifactTextJumpConvergence( + initialTargetOffset: streamedBoundary.contentOffsetY + ) + #expect( + convergence.decision( + observedOffset: lineOneBoundary.contentOffsetY, + targetOffset: streamedBoundary.contentOffsetY + ) == .retarget(offset: streamedBoundary.contentOffsetY) + ) + #expect(pin.phase == .initialAnimation) + + // A settle callback cannot conclude the pin until the requested bottom + // boundary is genuinely visible. + #expect( + pin.initialAnimationSettled( + at: streamedBoundary, + isBoundaryVisible: false + ) == .scrollToBottom(boundary: streamedBoundary, animated: false) + ) + #expect(pin.phase == .initialAnimation) + pin.didApplyPin(at: streamedBoundary) + + #expect(appendPolicy.endProgrammaticAnimation() == 0) + #expect(pin.phase == .following) + #expect(pin.visibleBoundary == streamedBoundary) + } } From 813ca7a352b9f24a1d2baf0a34421ded77665b2d Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Fri, 17 Jul 2026 12:23:37 -0700 Subject: [PATCH 90/92] fix(ios): rearm streamed End jumps --- ...hatArtifactTextBottomPinStateMachine.swift | 41 +++++++++++++------ .../ChatArtifactTextViewCoordinator.swift | 26 +++++++++++- ...tifactTextBottomPinStateMachineTests.swift | 5 ++- 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomPinStateMachine.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomPinStateMachine.swift index 0fc5fc420b1f..09f82eaeeb6c 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomPinStateMachine.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomPinStateMachine.swift @@ -17,7 +17,6 @@ struct ChatArtifactTextBottomPinStateMachine: Equatable, Sendable { private(set) var phase: Phase? private(set) var visibleBoundary: ChatArtifactTextBottomBoundary? private var requestedBoundary: ChatArtifactTextBottomBoundary? - private var pendingFlushedBoundary: ChatArtifactTextBottomBoundary? var isPinned: Bool { target != nil @@ -31,20 +30,27 @@ struct ChatArtifactTextBottomPinStateMachine: Equatable, Sendable { phase = .initialAnimation visibleBoundary = nil requestedBoundary = boundary - pendingFlushedBoundary = nil return .scrollToBottom(boundary: boundary, animated: true) } mutating func initialAnimationSettled( - at boundary: ChatArtifactTextBottomBoundary + at boundary: ChatArtifactTextBottomBoundary, + isBoundaryVisible: Bool = false ) -> Action { guard isPinned else { return .none } - let boundary = pendingFlushedBoundary ?? boundary - pendingFlushedBoundary = nil + let targetBoundary = latestBoundary( + requestedBoundary, + or: boundary + ) + requestedBoundary = targetBoundary + guard isBoundaryVisible, targetBoundary == boundary else { + visibleBoundary = nil + return .scrollToBottom(boundary: targetBoundary, animated: false) + } phase = .following - visibleBoundary = nil + visibleBoundary = boundary requestedBoundary = boundary - return .scrollToBottom(boundary: boundary, animated: false) + return .none } mutating func layoutChanged( @@ -62,10 +68,10 @@ struct ChatArtifactTextBottomPinStateMachine: Equatable, Sendable { ) -> Action { guard isPinned else { return .none } guard phase == .following else { - pendingFlushedBoundary = boundary - return .none + visibleBoundary = nil + requestedBoundary = boundary + return .scrollToBottom(boundary: boundary, animated: true) } - pendingFlushedBoundary = nil visibleBoundary = nil requestedBoundary = boundary return .scrollToBottom(boundary: boundary, animated: false) @@ -89,7 +95,8 @@ struct ChatArtifactTextBottomPinStateMachine: Equatable, Sendable { } mutating func didApplyPin(at boundary: ChatArtifactTextBottomBoundary) { - guard phase == .following else { return } + guard isPinned, requestedBoundary == boundary else { return } + phase = .following visibleBoundary = boundary requestedBoundary = boundary } @@ -99,6 +106,16 @@ struct ChatArtifactTextBottomPinStateMachine: Equatable, Sendable { phase = nil visibleBoundary = nil requestedBoundary = nil - pendingFlushedBoundary = nil + } + + private func latestBoundary( + _ first: ChatArtifactTextBottomBoundary?, + or second: ChatArtifactTextBottomBoundary + ) -> ChatArtifactTextBottomBoundary { + guard let first else { return second } + if first.storageEnd != second.storageEnd { + return first.storageEnd > second.storageEnd ? first : second + } + return first.contentOffsetY > second.contentOffsetY ? first : second } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift index 9cb3e66633a9..92d2d3a88714 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift @@ -284,6 +284,22 @@ final class ChatArtifactTextViewCoordinator: guard !appendPolicy.isDeferring, pendingTextChunks.isEmpty else { return } + let appendsFlushedMask = 1 << BottomPinTrigger.appendsFlushed.rawValue + if bottomPin.phase == .initialAnimation, + pendingBottomPinTriggerMask & appendsFlushedMask != 0 { + pendingBottomPinTriggerMask &= ~appendsFlushedMask + let boundary = documentEndBoundary(in: textView) + endJumpConvergence = ChatArtifactTextJumpConvergence( + initialTargetOffset: boundary.contentOffsetY + ) + if applyBottomPinAction( + bottomPin.appendsFlushed(at: boundary), + in: textView + ) { + return + } + } + while true { while settleBoundaryJumpOnceIfReady(in: textView) {} @@ -377,7 +393,10 @@ final class ChatArtifactTextViewCoordinator: case .finish: endJumpConvergence = nil _ = applyBottomPinAction( - bottomPin.initialAnimationSettled(at: boundary), + bottomPin.initialAnimationSettled( + at: boundary, + isBoundaryVisible: true + ), in: textView ) return false @@ -394,7 +413,10 @@ final class ChatArtifactTextViewCoordinator: case .force: endJumpConvergence = nil _ = applyBottomPinAction( - bottomPin.initialAnimationSettled(at: boundary), + bottomPin.initialAnimationSettled( + at: boundary, + isBoundaryVisible: false + ), in: textView ) return false diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift index 38c1ee3f8259..c889bdc1eed6 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift @@ -146,7 +146,10 @@ struct ChatArtifactTextBottomPinStateMachineTests { #expect(didReachEOF) #expect(appendPolicy.endProgrammaticAnimation() == 0) - #expect(pin.appendsFlushed(at: finalBoundary) == .none) + #expect( + pin.appendsFlushed(at: finalBoundary) + == .scrollToBottom(boundary: finalBoundary, animated: true) + ) #expect( pin.initialAnimationSettled(at: preFlushBoundary) == .scrollToBottom(boundary: finalBoundary, animated: false) From 21419ea7ec64fa78423a784ea601184eacf44f16 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Fri, 17 Jul 2026 12:24:30 -0700 Subject: [PATCH 91/92] test(ios): require fitted image swipes to page --- .../ChatArtifactZoomPolicyTests.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactZoomPolicyTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactZoomPolicyTests.swift index 6cc20ac0f6a7..9c34d91c9841 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactZoomPolicyTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactZoomPolicyTests.swift @@ -23,4 +23,21 @@ struct ChatArtifactZoomPolicyTests { #expect(policy.isAtMinimum(1.005)) #expect(!policy.isAtMinimum(1.02)) } + + @Test("a horizontal image swipe at one-times zoom pages to the adjacent file") + func fittedImageSwipeBelongsToPager() throws { + let policy = ChatArtifactZoomPolicy() + var pager = ChatArtifactPageControllerState( + paths: ["/screenshots/shot-03.png", "/guide.md"], + selectedPath: "/screenshots/shot-03.png" + ) + + #expect(policy.horizontalSwipeOwner(at: 1) == .pager) + let adjacentPath = try #require(pager.path(after: pager.selectedPath)) + let didTransition = pager.completeTransition(to: adjacentPath) + #expect(didTransition) + #expect(pager.selectedPath == "/guide.md") + + #expect(policy.horizontalSwipeOwner(at: 3) == .image) + } } From b7780149820193e30c5e26ba1c21c2ea127e7e12 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Fri, 17 Jul 2026 12:24:54 -0700 Subject: [PATCH 92/92] fix(ios): let fitted image swipes page --- .../Artifacts/ChatArtifactZoomPolicy.swift | 9 +++++++++ .../Artifacts/ChatArtifactZoomableImageView.swift | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomPolicy.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomPolicy.swift index 5925213c5be7..adf875201931 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomPolicy.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomPolicy.swift @@ -2,6 +2,11 @@ import Foundation /// Scale rules shared by artifact image zoom gestures and tests. struct ChatArtifactZoomPolicy: Equatable, Sendable { + enum HorizontalSwipeOwner: Equatable, Sendable { + case pager + case image + } + let minimumScale: Double let doubleTapScale: Double let maximumScale: Double @@ -20,6 +25,10 @@ struct ChatArtifactZoomPolicy: Equatable, Sendable { abs(scale - minimumScale) <= 0.01 } + func horizontalSwipeOwner(at scale: Double) -> HorizontalSwipeOwner { + isAtMinimum(scale) ? .pager : .image + } + func scaleAfterDoubleTap(currentScale: Double) -> Double { isAtMinimum(currentScale) ? doubleTapScale : minimumScale } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomableImageView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomableImageView.swift index 79f6e9367a1e..387363ae6aa7 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomableImageView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomableImageView.swift @@ -102,7 +102,12 @@ struct ChatArtifactZoomableImageView: UIViewRepresentable { func reportMinimumZoomIfNeeded(force: Bool) { guard let scrollView else { return } - let isAtMinimum = policy.isAtMinimum(Double(scrollView.zoomScale)) + let swipeOwner = policy.horizontalSwipeOwner(at: Double(scrollView.zoomScale)) + // The page controller's ancestor pan recognizer must own fitted + // images. A nested UIScrollView pan still wins gesture arbitration + // even when it has no scrollable content, swallowing the page swipe. + scrollView.panGestureRecognizer.isEnabled = swipeOwner == .image + let isAtMinimum = swipeOwner == .pager guard force || lastReportedMinimumState != isAtMinimum else { return } lastReportedMinimumState = isAtMinimum onMinimumZoomChanged(isAtMinimum)