fix(tui): tool inline_diff renders inline with the active turn

Reported during TUI v2 blitz retest: code-review diffs from tool.complete
appeared at the top of the current interaction thread, out of sequence
with the agent's messages and tool rows below them.

Root cause — `sys(inline_diff)` appends to `historyItems`, which sits
above the `StreamingAssistant` pane that renders the active turn.
Until the turn closed, the diff visually floated above everything
else happening in the same turn.

Route the diff through `turnController.appendSegmentMessage` instead
so it flushes any pending streaming text first, then lands in the
segment stream beside assistant output and tool calls.  On
`message.complete` the segment list is committed to history in emit
order (diff → final text), matching what the gateway sent.

Adds a regression test that exercises tool.complete → message.complete
with an inline_diff payload and asserts both the streaming and final
placement.
This commit is contained in:
Brooklyn Nicholson 2026-04-21 18:35:59 -05:00
parent 35a4b093d8
commit dff1c8fcf1
3 changed files with 50 additions and 1 deletions

View file

@ -143,6 +143,38 @@ describe('createGatewayEventHandler', () => {
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer))
})
it('routes inline_diff into the active segment stream, not historyItems', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const diff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
onEvent({
payload: { context: 'foo.ts', name: 'patch', tool_id: 'tool-1' },
type: 'tool.start'
} as any)
onEvent({
payload: { inline_diff: diff, summary: 'patched', tool_id: 'tool-1' },
type: 'tool.complete'
} as any)
// While streaming, nothing has flowed to historyItems yet — diff must be
// held in segmentMessages so the transcript renders it inline with the
// current turn rather than above it.
expect(appended).toHaveLength(0)
expect(turnController.segmentMessages).toContainEqual({ role: 'system', text: diff })
onEvent({
payload: { text: 'patch applied' },
type: 'message.complete'
} as any)
// After the turn closes, the diff lands in history in the order the
// gateway emitted it — before the assistant's final text, not above it.
expect(appended).toHaveLength(2)
expect(appended[0]).toMatchObject({ role: 'system', text: diff })
expect(appended[1]).toMatchObject({ role: 'assistant', text: 'patch applied' })
})
it('shows setup panel for missing provider startup error', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))

View file

@ -266,7 +266,12 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
turnController.recordToolComplete(ev.payload.tool_id, ev.payload.name, ev.payload.error, ev.payload.summary)
if (ev.payload.inline_diff && getUiState().inlineDiffs) {
sys(ev.payload.inline_diff)
// Push into the active turn's segment stream so the diff renders
// inline with the assistant's output. Routing through `sys()`
// lands it in the completed-history section above the streaming
// bubble — which is why blitz testers saw diffs "appear at the
// top, out of sequence" with the rest of the turn.
turnController.appendSegmentMessage({ role: 'system', text: ev.payload.inline_diff })
}
return

View file

@ -182,6 +182,18 @@ class TurnController {
}, REASONING_PULSE_MS)
}
/**
* Append an inline artifact (e.g. tool-complete inline diff) to the active
* turn's segment stream. Routing through `historyItems` via `sys()` lands
* the artifact above the currently-streaming assistant bubble; adding it
* here keeps the paint order aligned with the order the gateway emitted.
*/
appendSegmentMessage(msg: Msg) {
this.flushStreamingSegment()
this.segmentMessages = [...this.segmentMessages, msg]
patchTurnState({ streamSegments: this.segmentMessages })
}
pushActivity(text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) {
patchTurnState(state => {
const base = replaceLabel