Merge nucleic/gentle-breezy-egret into dev

This commit is contained in:
2026-07-10 04:18:32 -07:00
parent 4781a8741c
commit 40b2f83d96
@@ -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