Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
99 commits
Select commit Hold shift + click to select a range
21c826e
test: cover artifact canonical identity dedup
Jul 14, 2026
c4a1f16
fix: deduplicate artifact gallery by canonical path
Jul 14, 2026
0d3b3ce
test: drop duplicated iOS session gallery snapshot suite
Jul 14, 2026
ad349f9
fix: use one canonical identity in TerminalArtifactScope.canonicalPat…
Jul 14, 2026
771a2b7
feat: add mobile artifact folder access policy
Jul 14, 2026
08f2bbe
feat: add terminal artifact folder listing
Jul 14, 2026
6a55ff2
feat: route artifacts through recursive folder browser
Jul 14, 2026
d795475
feat: include folders in artifact galleries
Jul 14, 2026
6247776
fix: scope folder routing predicate onto ChatArtifactStat
Jul 14, 2026
95d3482
fix: keep budget-capped files from growing
Jul 14, 2026
10bfcc7
refactor: share progressive artifact fetch loop
Jul 14, 2026
14e153f
perf: render artifact text with TextKit 1
Jul 14, 2026
f0fc4ca
feat: stream artifact text progressively
Jul 14, 2026
233fa7d
feat: add artifact text jump controls
Jul 14, 2026
8e61ed4
fix: correct guard fall-through in artifact text chunk reset
Jul 14, 2026
e46ba3a
perf: trim gallery directory probes to single syscalls
Jul 14, 2026
fd8e938
feat: dispatch rich artifact previews client-side
Jul 14, 2026
3dc89ef
feat: preview PDF artifacts with PDFKit
Jul 14, 2026
d5febb6
feat: stream media artifacts to AVPlayer
Jul 14, 2026
af3be69
feat: preview document artifacts with Quick Look
Jul 14, 2026
59f91a2
feat: toggle raw and rendered Markdown previews
Jul 14, 2026
1683ae2
perf: stop directory child counting at the listing cap
Jul 14, 2026
72e4a1d
feat: sniff extensionless artifacts for UTF-8 text
Jul 14, 2026
c28b9bc
fix: route MIME-only media artifacts to the player
Jul 14, 2026
b9e6c53
feat(ios): highlight streamed artifact text
Jul 14, 2026
3bafe46
feat(ios): search within streamed artifacts
Jul 14, 2026
a59c343
feat(ios): add artifact line navigation
Jul 14, 2026
a47f696
feat(ios): persist artifact text layout
Jul 14, 2026
284870b
chore: retrigger CI after Actions stall
Jul 14, 2026
aa5e744
feat(ios): anchor the files chip to the terminal top
Jul 14, 2026
3638eb7
feat(ios): classify gallery filter buckets
Jul 14, 2026
7e755c5
fix: type extensionless media temp files from the wire MIME
Jul 14, 2026
20759ab
perf: bound folder listing metadata reads to the cap
Jul 14, 2026
ff97474
feat(ios): filter and sort gallery groups
Jul 14, 2026
316f15a
feat(ios): add three-column artifact grid
Jul 14, 2026
404c84a
fix(ios): surface incomplete gallery paging
Jul 14, 2026
a16cc28
feat(ios): page through gallery artifacts
Jul 14, 2026
2928f28
feat(ios): cache fetched artifact content
Jul 15, 2026
b0a0f64
feat(ios): refresh artifact gallery without scroll jumps
Jul 15, 2026
f2f45bd
feat(ios): add artifact file actions
Jul 15, 2026
42a04b9
feat(ios): zoom artifact images without paging
Jul 15, 2026
3d7b372
perf: page artifact stats across gallery sections
Jul 15, 2026
1d9a5c8
perf: cache gallery ordering by index generation
Jul 15, 2026
e790a3a
fix: restart gallery paging across generations
Jul 15, 2026
2593f14
perf: bound artifact index snapshots with LRU
Jul 15, 2026
e357450
perf: index chat sessions by terminal surface
Jul 15, 2026
db87311
fix: measure artifact tap targets in terminal cells
Jul 15, 2026
c16c482
fix(ios): isolate unscoped artifact content streams
Jul 15, 2026
9b52805
Merge remote-tracking branch 'origin/feat-chipgal-2-folders' into fea…
Jul 15, 2026
2ea7162
Merge remote-tracking branch 'origin/feat-chipgal-3-renderer' into fe…
Jul 15, 2026
c00c18e
Merge remote-tracking branch 'origin/feat-chipgal-4-kinds' into feat-…
Jul 15, 2026
ea92959
Merge remote-tracking branch 'origin/feat-chipgal-5-textfeat' into fe…
Jul 15, 2026
bf46870
Merge remote-tracking branch 'origin/feat-chipgal-6a-gallery' into fe…
Jul 15, 2026
65a8bcf
fix: fill gallery pages sequentially across sections
Jul 15, 2026
24ed390
fix(ios): push artifact folders inline
Jul 15, 2026
024cbfd
fix(ios): animate text viewer jump to top
Jul 15, 2026
0df1e97
fix(ios): move artifact viewer Done action trailing
Jul 15, 2026
063b3b8
test: cover missing gallery files by default
Jul 15, 2026
246b090
feat(ios): hide missing gallery files by default
Jul 15, 2026
0af7e1e
test(ios): cover stable artifact viewer ownership
Jul 15, 2026
8362ddb
fix(ios): stabilize artifact viewer ownership
Jul 15, 2026
6188976
test(ios): cover bounded artifact stream updates
Jul 15, 2026
5a2b124
test(ios): cover artifact presentation ownership regressions
Jul 15, 2026
3c1414a
test(ios): cover pager neighbor reload after selection
Jul 15, 2026
04cdfc1
fix(ios): isolate artifact paging and large text presentation
Jul 15, 2026
74c7bb6
fix(ios): restore artifact taps and folder routing
Jul 15, 2026
88d125c
test(ios): cover remaining artifact viewer defects
Jul 16, 2026
baa852c
fix(ios): preserve rendered markdown structure
Jul 16, 2026
5be5740
fix(ios): authorize transcript artifact taps by session
Jul 16, 2026
5544b19
fix(ios): defer streamed text edits while scrolling
Jul 16, 2026
ed83c4f
test: cover restored session surface lookup
Jul 16, 2026
191c06f
fix: keep chat session surface index coherent
Jul 16, 2026
37cb16e
test: reproduce stale In View authorization
Jul 16, 2026
9c12bc2
fix: preserve In View scan authorization
Jul 16, 2026
b3044e1
fix(ios): unify gallery document glyphs
Jul 16, 2026
7a8c57e
fix(ios): keep text end jumps at final content
Jul 16, 2026
5d8e9b7
Merge remote-tracking branch 'origin/main' into feat-chipgal-complete
Jul 16, 2026
b46bad5
fix: restore main's ghostty and bonsplit submodule pointers
Jul 17, 2026
6839a26
test(ios): cover jump convergence and artifact glyph rules
Jul 17, 2026
35d18ed
fix(ios): converge viewer jumps and unify glyphs
Jul 17, 2026
0877122
chore(ios): resolve workspace Highlightr pin
Jul 17, 2026
fa3775c
test(ios): reproduce cold-open end-jump recursion
Jul 17, 2026
30f7a9f
fix(ios): bound artifact end-jump settling
Jul 17, 2026
8a10687
test(ios): specify durable artifact bottom pin
Jul 17, 2026
be1bf85
fix(ios): keep artifact End bottom-pinned
Jul 17, 2026
9ebd2a0
test(ios): reproduce final artifact pin flush gap
Jul 17, 2026
82b61d5
fix(ios): drain final artifact bottom pin
Jul 17, 2026
62f57ba
fix(ios): never defer appends for pin-owned scrolls
Jul 17, 2026
fc22f84
Fix iOS reconnect and build isolation (#8299)
azooz2003-bit Jul 17, 2026
7049e86
Merge remote-tracking branch 'origin/main' into feat-chipgal-complete
Jul 17, 2026
46db74b
fix: restore main's submodule pointers again
Jul 17, 2026
ef9382b
fix: take main's sidebar row slot view verbatim
Jul 17, 2026
1eec555
test(ios): require present-first artifact routing
Jul 17, 2026
0573e03
fix(ios): present artifact viewers before loading
Jul 17, 2026
8e8b80b
feat(ios): gate terminal files chip behind beta setting
Jul 17, 2026
68048d4
test(ios): cover cold-open End rearming
Jul 17, 2026
813ca7a
fix(ios): rearm streamed End jumps
Jul 17, 2026
21419ea
test(ios): require fitted image swipes to page
Jul 17, 2026
b778014
fix(ios): let fitted image swipes page
Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ 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
private static let utf8SniffByteCount = 8 * 1024

/// Filesystem/decoder failures surfaced by artifact RPC handlers.
public enum Error: Swift.Error, Sendable {
/// The scoped path no longer exists or cannot be statted.
Expand Down Expand Up @@ -96,37 +100,45 @@ 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.
///
/// 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 listed = try entries
.sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }
.prefix(500)
.map { entry -> ChatArtifactDirectoryEntry in
let names = try FileManager.default.contentsOfDirectory(atPath: path)
let sortedNames = names.sorted {
$0.localizedStandardCompare($1) == .orderedAscending
}
let directoryURL = URL(fileURLWithPath: path, isDirectory: true)
let listed = try sortedNames
.prefix(Self.maximumDirectoryEntryCount)
Comment on lines +111 to +117

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Apply the directory cap before materializing and sorting every child.

contentsOfDirectory plus sorted still allocates O(n) names and performs O(n log n) work for an unbounded directory; prefix(500) only limits metadata reads. Stream the directory and maintain the smallest 500 names in a bounded heap/buffer, tracking whether a 501st entry exists.

As per coding guidelines, “avoid repeated full scans, sorting, filtering… over scalable collections” and identify the smallest source-of-truth fix. <coding_guidelines> As per path instructions, apply the algorithmic-complexity rule to scalable production data. <path_instructions>

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift`
around lines 111 - 117, Update the directory-reading logic around
ArtifactByteReader’s contentsOfDirectory flow to avoid materializing or fully
sorting all child names. Stream directory entries, maintain only the smallest
Self.maximumDirectoryEntryCount names using a bounded heap or equivalent buffer,
and track whether an additional entry exists so truncation metadata remains
accurate.

Sources: Coding guidelines, Path instructions

Comment on lines +111 to +117

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Bound directory ordering before sorting.

This materializes and sorts every child—O(n log n) time and O(n) memory—although only 500 names are returned. For large artifact folders, enumerate once and retain only the lexically smallest 500 entries in bounded storage.

As per path instructions, flag “full-collection scans” and “repeated sort/filter/map work” over scalable user data.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift`
around lines 111 - 117, Update the directory listing flow around the names
collection and sortedNames so it does not materialize and sort the full
directory contents; enumerate entries once and maintain only the lexically
smallest Self.maximumDirectoryEntryCount names in bounded storage, then preserve
the existing ordering and listed output behavior.

Sources: Coding guidelines, Path instructions

.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)
)
}
return ChatArtifactDirectoryListing(entries: listed)
return ChatArtifactDirectoryListing(
entries: listed,
isTruncated: names.count > Self.maximumDirectoryEntryCount
)
}

/// 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) {
Expand All @@ -135,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)
Comment on lines +150 to +168

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate the bytes following a partial UTF-8 scalar.

The extra byte read on Line 157 is used only to prove the file continues. For a sample ending in E2 82, a following non-continuation byte still yields true, misclassifying malformed/binary content as UTF-8 text. Read enough lookahead to complete the boundary scalar and validate those bytes before accepting the sample.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift`
around lines 150 - 168, Update isUTF8Text to validate the lookahead bytes that
follow a sample ending in a partial UTF-8 scalar, rather than using the extra
byte only as an existence check. Read enough bytes to complete the boundary
scalar, verify the continuation sequence is valid, and return true only when
that boundary validation succeeds; preserve the existing behavior for fully
valid samples and short files.

Comment on lines +157 to +168

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validate the lookahead byte before accepting a partial UTF-8 scalar.

The extra byte read on Line 157 is discarded. For 8,191 ASCII bytes followed by 0xE0, 0x20, the helper sees only trailing 0xE0 and returns .text, although the available lookahead already proves the sequence invalid. Pass the lookahead into validation and add this boundary case to the tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift`
around lines 157 - 168, Update the UTF-8 detection logic around
ArtifactByteReader’s partial-scalar validation to pass the extra lookahead byte
into hasValidUTF8PrefixEndingInPartialScalar and validate it before accepting
the prefix. Preserve valid partial-scalar handling while rejecting cases such as
8,191 ASCII bytes followed by 0xE0, 0x20, and add a test covering this boundary
condition.

}

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[..<start])
return String(data: prefix, encoding: .utf8) != nil
}
return false
}

private func utf8ScalarLength(leadingByte: UInt8) -> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
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.
/// - 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.
/// - 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],
orderedItems: [ChatArtifactIndexedReference]? = nil,
generation: String,
cursor: ChatArtifactGalleryCursor?,
pageSize: Int,
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)
let isSearch = normalizedQuery?.isEmpty == false
let createdCandidates: [ChatArtifactIndexedReference]
let attachedCandidates: [ChatArtifactIndexedReference]
let referencedCandidates: [ChatArtifactIndexedReference]
if let normalizedQuery, !normalizedQuery.isEmpty {
createdCandidates = []
attachedCandidates = []
referencedCandidates = ordering.matching(stableItems, query: normalizedQuery)
} else {
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(
cursor: cursor,
created: createdCandidates,
attached: attachedCandidates,
referenced: referencedCandidates,
ordering: ordering
)
// 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
let nextCursor: String?
if nextCreatedOffset < createdCandidates.count
|| nextAttachedOffset < attachedCandidates.count
|| nextReferencedOffset < referencedCandidates.count {
let last = pageReferenced.last
nextCursor = try? ChatArtifactGalleryCursor(
generation: generation,
seq: last?.lastReferencedSeq ?? cursor?.seq ?? .max,
path: last?.path ?? cursor?.path ?? "",
createdOffset: nextCreatedOffset,
attachedOffset: nextAttachedOffset,
referencedOffset: nextReferencedOffset
).token()
} else {
nextCursor = nil
}

return ChatArtifactGalleryPage(
sessionID: sessionID,
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,
Comment on lines +67 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Apply directory eligibility before deriving page boundaries and totals.

Filtering directories after pagination can return an empty visible page while advancing the cursor, and page-local subtraction cannot produce stable snapshot totals.

  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift#L67-L100: paginate the capability-eligible collection so excluded directories do not consume offsets or page capacity.
  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift#L147-L177: move directory eligibility into the authoritative enrichment/filtering stage before cursor calculation.
  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift#L80-L94: do not infer snapshot-wide visible totals from directories found in only the current page; preserve authoritative totals or carry server-computed eligible totals.
📍 Affects 2 files
  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift#L67-L100 (this comment)
  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift#L147-L177
  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift#L80-L94
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift`
around lines 67 - 100, Apply directory eligibility during the authoritative
enrichment/filtering stage in ChatArtifactGalleryBuilder.swift (lines 147-177),
before cursor calculation, pagination, and totals are derived. Update the
page-boundary logic at ChatArtifactGalleryBuilder.swift (lines 67-100) to
paginate only eligible collections so excluded directories consume neither
offsets nor page capacity. In ChatArtifactGalleryPage.swift (lines 80-94),
preserve authoritative snapshot-wide eligible totals or use server-computed
eligible totals instead of inferring totals from directories present on the
current page.

generation: generation
)
}

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)
}

/// Counts immediate children for a gallery directory row without sorting
/// 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 enumerator = FileManager.default.enumerator(
at: URL(fileURLWithPath: path, isDirectory: true),
includingPropertiesForKeys: [],
options: [.skipsSubdirectoryDescendants]
) else {
return nil
}
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(
_ references: [ChatArtifactIndexedReference],
includeDirectories: Bool
) -> [ChatArtifactGalleryItem] {
let reader = ArtifactByteReader()
return references.compactMap { reference in
do {
let stat = try reader.stat(path: reference.path)
guard includeDirectories || !stat.isDirectory else { return nil }
let children = stat.isDirectory ? directoryChildCount(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: children?.count,
childCountIsCapped: children?.isCapped ?? 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
)
}
}
}
}
Loading