From 40b2f83d967975ef088d50a967dd847daf674a1f Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Fri, 10 Jul 2026 04:18:32 -0700 Subject: [PATCH] Merge nucleic/gentle-breezy-egret into dev --- .../Transcript/TranscriptProjection.swift | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift index 2b60f2e..71f7be0 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift @@ -42,15 +42,27 @@ struct TranscriptItem: Identifiable, Equatable { } /// A row that draws nothing visible — an empty/redacted `.thinking` block (the agent streams - /// a finalized empty thinking item whenever the reasoning itself is redacted). Held aside - /// during tool-run coalescing so it doesn't split an otherwise-contiguous run of calls into - /// two cards with a gap. Mirrors the desktop projection's `rendersNothing`. - var rendersNothing: Bool { + /// a finalized empty thinking item whenever the reasoning itself is redacted). `ThinkingRow` + /// renders no content for it, so `flatItems` drops it outright rather than carrying a phantom + /// row into the transcript. + /// + /// It has to be dropped, not merely rendered as an empty view: the transcript is a + /// `VStack(spacing: 14)` over `TranscriptRow`s, and a row whose body resolves to nothing is + /// still a *child* of that stack — SwiftUI can't elide an opaque `View` struct the way it + /// elides a literal `EmptyView`, so it still claims its 14pt inter-row gap. A long tool run + /// interleaved with redacted thinking (one empty block per call) held them all aside and + /// re-emitted them after the block, stacking one gap each into a large band of blank space + /// between the card and the next row. Mirrors the desktop projection's `isEmptyThinking`. + var isEmptyThinking: Bool { if case .thinking(let text) = kind { return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } return false } + + /// Items that draw no visible row. They must not break a run of tool calls when coalescing: + /// an invisible row sitting between two calls would otherwise split them into two cards. + var rendersNothing: Bool { isEmptyThinking } } /// One tool call's coalesced lifecycle: start → input deltas → complete → result → file changes. @@ -409,10 +421,13 @@ enum TranscriptProjection { /// Collapses maximal runs of adjacent `.tool` items (length ≥2) into one `.toolBlock`. /// - /// Items that draw nothing (empty `.thinking` blocks — see `rendersNothing`) never break a - /// run: an invisible row landing between two tool calls must not split them into two cards - /// with a gap. Such items are held aside and re-emitted right after the block, so they still - /// render in place while the calls stay one contiguous block. Mirrors the desktop projection. + /// Items that draw nothing (see `rendersNothing`) never break a run: an invisible row landing + /// between two tool calls must not split them into two cards with a gap. Such items are held + /// aside and re-emitted right after the block, so the calls stay one contiguous block. + /// Mirrors the desktop projection. + /// + /// `flatItems` already drops the only kind the phone has (empty redacted thinking), so the + /// hold path is a guard for a caller passing unfiltered items — not a live case. static func coalesceToolRuns(_ flat: [TranscriptItem]) -> [TranscriptItem] { var out: [TranscriptItem] = [] var run: [TranscriptItem] = [] // consecutive `.tool` items @@ -510,7 +525,12 @@ enum TranscriptProjection { kind: .raw(type: event.nativeType ?? "raw", body: raw.native.compactSummary))) } } - return items + // A redacted (empty) thinking block draws no row but would still cost one inter-row gap + // in the transcript's spaced VStack — see `isEmptyThinking`. Drop it here, after the + // coalescing upserts, so a *streaming* thinking item is judged on its accumulated text + // rather than on the empty first delta. A thinking message never straddles a segment + // seam (`buildSegment`'s precondition), so this stays fold-equivalent per segment. + return items.filter { !$0.isEmptyThinking } } // MARK: - Coalescing helpers