diff --git a/frontend/src/components/RunTranscript.tsx b/frontend/src/components/RunTranscript.tsx index d4634bf0..928ca4d9 100644 --- a/frontend/src/components/RunTranscript.tsx +++ b/frontend/src/components/RunTranscript.tsx @@ -129,5 +129,5 @@ function RunTranscriptLive({ ), }) - return + return } diff --git a/frontend/src/components/StreamTranscript.tsx b/frontend/src/components/StreamTranscript.tsx index 946f9ec7..8381097b 100644 --- a/frontend/src/components/StreamTranscript.tsx +++ b/frontend/src/components/StreamTranscript.tsx @@ -31,16 +31,26 @@ import { useEffect, useRef, useState } from 'react' * Lines that don't parse as JSON pass through as plain text — partial * stderr leakage stays legible. */ -export function StreamTranscript({ text, complete = false }: { text: string; complete?: boolean }) { +export function StreamTranscript({ + text, + complete = false, + isLive = false, +}: { + text: string + complete?: boolean + isLive?: boolean +}) { const [parseState, setParseState] = useState(() => appendStreamText(emptyParseState, text, complete), ) - let rows = parseState.rows + let current = parseState if (parseState.receivedLength !== text.length || parseState.tailFlushed !== complete) { const nextParseState = appendStreamText(parseState, text, complete) setParseState(nextParseState) - rows = nextParseState.rows + current = nextParseState } + const { rows, thinkingTokens } = current + const showThinking = isLive && !complete && thinkingTokens !== null // Stick-to-bottom: when the transcript renders inside its own scroll box // (the detail page caps `.ins-xscript .stream-transcript`), follow new rows @@ -52,9 +62,9 @@ export function StreamTranscript({ text, complete = false }: { text: string; com useEffect(() => { const el = boxRef.current if (el && pinnedRef.current) el.scrollTop = el.scrollHeight - }, [rows]) + }, [rows, thinkingTokens]) - if (rows.length === 0) { + if (rows.length === 0 && !showThinking) { return
waiting for output…
} return ( @@ -70,6 +80,12 @@ export function StreamTranscript({ text, complete = false }: { text: string; com {rows.map((row, i) => ( ))} + {showThinking && ( +
+ Thinking… + +
+ )} ) } @@ -105,6 +121,7 @@ type Row = // the suffix by `receivedLength` sound. interface IncrementalParseState { rows: Row[] + thinkingTokens: number | null receivedLength: number partialLine: string tailFlushed: boolean @@ -112,6 +129,7 @@ interface IncrementalParseState { const emptyParseState: IncrementalParseState = { rows: [], + thinkingTokens: null, receivedLength: 0, partialLine: '', tailFlushed: false, @@ -129,15 +147,15 @@ function appendStreamText( const lastNewline = pending.lastIndexOf('\n') const terminated = lastNewline === -1 ? [] : pending.slice(0, lastNewline).split('\n') let partialLine = lastNewline === -1 ? pending : pending.slice(lastNewline + 1) - const newRows = rowsForLines(terminated) - if (complete && partialLine !== '') { - newRows.push(...rowsForLines([partialLine])) + terminated.push(partialLine) partialLine = '' } + const { rows: newRows, thinkingTokens } = rowsForLines(terminated, previous.thinkingTokens) return { rows: newRows.length === 0 ? previous.rows : [...previous.rows, ...newRows], + thinkingTokens, receivedLength: text.length, partialLine, tailFlushed: complete, @@ -150,18 +168,28 @@ export function parseStream(text: string, complete: boolean): Row[] { return appendStreamText(emptyParseState, text, complete).rows } -function rowsForLines(lines: string[]): Row[] { +function rowsForLines( + lines: string[], + thinkingTokens: number | null, +): Pick { const rows: Row[] = [] for (const line of lines) { if (!line.trim()) continue const event = tryParse(line) if (event === null) { rows.push({ kind: 'raw', line }) + thinkingTokens = null + continue + } + if (event.type === 'system' && event.subtype === 'thinking_tokens') { + if (typeof event.estimated_tokens === 'number') thinkingTokens = event.estimated_tokens continue } - rows.push(...rowsForEvent(event, line)) + const eventRows = rowsForEvent(event, line) + if (eventRows.length) thinkingTokens = null + rows.push(...eventRows) } - return rows + return { rows, thinkingTokens } } function tryParse(line: string): Record | null { @@ -187,10 +215,7 @@ function rowsForEvent(event: Record, raw: string): Row[] { }, ] } - // Every other system subtype — thinking_tokens (a token-count progress - // ticker), hook_started / hook_response, task_started / task_notification — - // is infra with no content. Drop it so the assistant text and tool calls - // aren't buried under raw JSON noise. + // Hook and task notifications have no transcript content. return [] } if (eventType === 'rate_limit_event') {