diff options
| author | Adam Malczewski <[email protected]> | 2026-10-22 15:48:50 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-22 15:48:50 +0900 |
| commit | 53686ae6446c5c59928244f65c08150fb564b375 (patch) | |
| tree | 1b033bf09d30872c04f098a3c016e09481a7209b /src/core/chunks/reducer.ts | |
| parent | 511d415b4ec07d435ea73ab8e5421949392d922d (diff) | |
| download | dispatch-web-53686ae6446c5c59928244f65c08150fb564b375.tar.gz dispatch-web-53686ae6446c5c59928244f65c08150fb564b375.zip | |
fix(chunks): keep tool calls visible when opening an in-flight chat
Opening (or switching back to) a conversation with a running turn showed a
tail of "Thoughts" blocks with no tool cards. The late-join replay re-delivers
the whole in-flight turn as provisional chunks; committedOverlap hides the ones
already in committed history by aligning both runs chunk-by-chunk. When that
alignment failed, the whole replay rendered as a duplicate, and because tool
cards de-dup by toolCallId onto the first (committed) occurrence, the duplicate
tail showed only thinking/text.
The alignment broke in four ways, all reproduced against a live server:
- Claude returns several thinking blocks per step (one signature each).
History persists them separately, but reasoning-deltas carry no block
boundary, so live/replay merges them. Now one provisional text/thinking
chunk matches a run of committed chunks whose texts concatenate to it.
- Images persisted mid-turn (tool attachments) have no live event. The walk
now steps over them.
- The chat-limit window/trim unloads the committed turn's head, so the
committed run is a suffix while the replay starts at turn-start. Alignment
now re-anchors (committed head, else the run's first tool chunk; text
anchors must verify the whole overlap) and hides replayed chunks whose
committed twins were unloaded.
- Re-subscribing (tab switch / reconnect) replays the turn on top of the
existing provisional copy. A turn-start for a turn that already has
provisional content now replaces the stale copy.
trimTranscript also counted those invisible duplicates toward the chat
limit, unloading real history (incl. the turn head) to make room for them;
it now counts rendered chunks (selectRenderedCount).
svelte-check 0 errors · biome clean · 2488 tests pass · build OK
Diffstat (limited to 'src/core/chunks/reducer.ts')
| -rw-r--r-- | src/core/chunks/reducer.ts | 39 |
1 files changed, 34 insertions, 5 deletions
diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts index ee5858f..35bb1bb 100644 --- a/src/core/chunks/reducer.ts +++ b/src/core/chunks/reducer.ts @@ -77,6 +77,26 @@ function tagTrailingUserEcho( } /** + * Drop the previous copy of a turn that is being replayed from its start. + * Keeps the trailing user run — the opening this replay's `user-message` just + * appended (or the sender's echo that `turn-start` just tagged) — and every + * other turn's chunks. + */ +function dropReplayedTurn( + provisional: readonly ProvisionalChunk[], + turnId: string, +): readonly ProvisionalChunk[] { + let tail = provisional.length; + while (tail > 0 && provisional[tail - 1]?.role === "user") tail--; + // A stale steering bubble (replayed again as its own `steering` event) can + // end the old copy and so sit inside the trailing user run. + const stale = (p: ProvisionalChunk, i: number): boolean => + p.turnId === turnId && (i < tail || p.afterToolCallId !== undefined); + if (!provisional.some(stale)) return provisional; + return provisional.filter((p, i) => !stale(p, i)); +} + +/** * Content equality for two chunks of the SAME role (used to de-dup an * optimistic echo against the authoritative committed version). Compares the * discriminating payload only — `stepId` (generation provenance) is ignored @@ -288,17 +308,26 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState case "tool-output": return state; - case "turn-start": + case "turn-start": { + // A turn starts exactly once live, so a `turn-start` for a turn that + // already has provisional content is a late-join REPLAY restarting from + // the top (re-subscribe after a tab switch / WS reconnect): the server + // re-delivers the whole in-flight turn. Replace the stale copy instead of + // appending a second one (a duplicate copy rendered as a tail of thinking + // blocks with its tool cards collapsed onto the first copy's). + const stale = state.accumulating?.turnId === event.turnId; + const provisional = tagTrailingUserEcho( + flushAccumulating(state.provisional, stale ? null : state.accumulating), + event.turnId, + ); return { ...state, - provisional: tagTrailingUserEcho( - flushAccumulating(state.provisional, state.accumulating), - event.turnId, - ), + provisional: dropReplayedTurn(provisional, event.turnId), accumulating: null, currentTurnId: event.turnId, generating: true, }; + } case "user-message": { // The turn's USER prompt, surfaced on the event stream (backend CR-3) so a |
