diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift index 38e04fc..2ca4205 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift @@ -102,26 +102,71 @@ enum TranscriptProjection { /// owns them, project the main agent's own events at the top level, and recursively project /// each subagent's events into the `children` of its spawn — so a subagent's inner work nests /// under its card instead of leaking (and interleaving) into the main transcript. + /// + /// One-shot form: folds the whole stream in one pass. The live transcript uses + /// `IncrementalTranscriptProjection`, which folds the stream as seam-delimited segments via + /// `buildSegment` — this wrapper is the `priorEdits: []` whole-stream case of that. static func build(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] { + buildSegment(events[...], worktreeRoot: worktreeRoot(in: events[...]), priorEdits: [], + showRaw: showRaw, showLockEvents: showLockEvents).items + } + + /// An edit-class tool call folded in an *earlier* segment, carried forward so a later + /// segment's lock notes can still fold onto it across the seam (a lock's `released` note + /// lands when the file *lands* in the parent — potentially many turns after the edit). + struct PriorEdit: Equatable { + let toolCallID: String + /// Repo-relative, normalized paths this call writes (`editedPaths` ∘ `normalizeForLock`). + let paths: [String] + } + + /// The folded output of one contiguous slice of the stream. + struct SegmentFold { + var items: [TranscriptItem] + /// Lock lines whose backward path-match crossed the seam onto a `priorEdits` entry, in + /// note order. The caller owns those already-folded items and must attach these to them — + /// that's what keeps `fold(prefix) ++ fold(tail)` byte-identical to `fold(whole)` even + /// for a lock note released turns after its edit. + var priorLockPatches: [(toolCallID: String, lock: NoteLock)] + /// This segment's own edit-class calls (in item order), for the caller's registry. + var edits: [PriorEdit] + } + + /// Fold one contiguous slice of the stream with an injected worktree root (a mid-stream + /// slice lacks the seq-0 `sessionStarted` that `build` rescans for). The slice must be + /// seam-delimited — no coalescing key, subagent scope, or open tool run straddling either + /// end — which is exactly what `IncrementalTranscriptProjection` guarantees before calling. + static func buildSegment( + _ events: ArraySlice, worktreeRoot root: String?, priorEdits: [PriorEdit], + showRaw: Bool, showLockEvents: Bool + ) -> SegmentFold { let (topLevel, byParent) = partition(events) // Fold lock-lifecycle notes onto the edit cards they bracket, exactly as the Mac's // `items(_:worktreeRoot:)` does — matched against the session's working directory so an // edit's absolute `file_path` compares against the note's repo-relative paths. Folding // happens only at the top level (a subagent's inner edits are literal, unlocked); the // subagent recursion below stays plain, matching the desktop projection. - let root = worktreeRoot(in: topLevel) + var patches: [(toolCallID: String, lock: NoteLock)] = [] let flat = foldLockNotes(flatItems(topLevel, showRaw: showRaw, showLockEvents: showLockEvents), - worktreeRoot: root) - return coalesceToolRuns(flat).map { + worktreeRoot: root, priorEdits: priorEdits, priorLockPatches: &patches) + let items = coalesceToolRuns(flat).map { attachSubagentChildren($0, byParent: byParent, depth: 0, showRaw: showRaw, showLockEvents: showLockEvents) } + let edits = flat.compactMap { item -> PriorEdit? in + guard case .tool(let group) = item.kind else { return nil } + let paths = editedPaths(toolName: group.name, input: group.input) + .map { normalizeForLock($0, worktreeRoot: root) } + .filter { !$0.isEmpty } + return paths.isEmpty ? nil : PriorEdit(toolCallID: group.toolCallID, paths: paths) + } + return SegmentFold(items: items, priorLockPatches: patches, edits: edits) } /// The session's working directory, read from its `sessionStarted` event, so an edit's /// absolute `file_path` can be made repo-relative to compare against a lock note's /// repo-relative paths. `nil` before the start event is seen (nothing to fold against yet). - private static func worktreeRoot(in events: [AgentEvent]) -> String? { + static func worktreeRoot(in events: ArraySlice) -> String? { for event in events { if case .sessionStarted(let started) = event.kind, !started.cwd.isEmpty { return started.cwd