From 01c770fd3a4fecfc0684e534472bdbc827cd6099 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 5 Aug 2026 07:29:40 +0000 Subject: [PATCH 1/2] Parse transcript updates incrementally --- .../StreamTranscript.incremental.test.tsx | 56 ++++++++++++++++++ frontend/src/components/StreamTranscript.tsx | 58 ++++++++++++++++++- 2 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/StreamTranscript.incremental.test.tsx diff --git a/frontend/src/components/StreamTranscript.incremental.test.tsx b/frontend/src/components/StreamTranscript.incremental.test.tsx new file mode 100644 index 00000000..6fe41b0b --- /dev/null +++ b/frontend/src/components/StreamTranscript.incremental.test.tsx @@ -0,0 +1,56 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { StreamTranscript } from './StreamTranscript' + +const eventLine = (text: string) => + JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text }] } }) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('StreamTranscript incremental parsing', () => { + it('parses only newly appended JSONL lines', () => { + const first = eventLine('first row') + const second = eventLine('second row') + const parseSpy = vi.spyOn(JSON, 'parse') + const { rerender } = render() + + expect(screen.getByText('first row')).toBeTruthy() + + rerender() + + expect(screen.getByText('second row')).toBeTruthy() + expect(screen.getAllByText(/^(first|second) row$/).map((node) => node.textContent)).toEqual([ + 'first row', + 'second row', + ]) + expect(parseSpy.mock.calls.filter(([input]) => input === first)).toHaveLength(1) + expect(parseSpy.mock.calls.filter(([input]) => input === second)).toHaveLength(1) + }) + + it('buffers split lines and flushes an unterminated final line once', () => { + const split = eventLine('split row') + const final = eventLine('final row') + const splitAt = Math.floor(split.length / 2) + const { rerender } = render() + + expect(screen.queryByText('split row')).toBeNull() + + rerender() + expect(screen.getAllByText('split row')).toHaveLength(1) + + rerender() + expect(screen.queryByText('final row')).toBeNull() + + rerender() + expect(screen.getAllByText('split row')).toHaveLength(1) + expect(screen.getAllByText('final row')).toHaveLength(1) + + rerender() + expect(screen.getAllByText('split row')).toHaveLength(1) + expect(screen.getAllByText('final row')).toHaveLength(1) + }) +}) diff --git a/frontend/src/components/StreamTranscript.tsx b/frontend/src/components/StreamTranscript.tsx index da93a179..1a820a05 100644 --- a/frontend/src/components/StreamTranscript.tsx +++ b/frontend/src/components/StreamTranscript.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' /** * Render the harness transcript (Claude or Codex) as readable rows. @@ -32,7 +32,14 @@ import { useEffect, useMemo, useRef } from 'react' * stderr leakage stays legible. */ export function StreamTranscript({ text, complete = false }: { text: string; complete?: boolean }) { - const rows = useMemo(() => parseStream(text, complete), [text, complete]) + const [parseState, setParseState] = useState(() => + appendStreamText(emptyParseState, text, complete), + ) + const rows = parseState.rows + + useEffect(() => { + setParseState((previous) => appendStreamText(previous, text, complete)) + }, [text, complete]) // Stick-to-bottom: when the transcript renders inside its own scroll box // (the detail page caps `.ins-xscript .stream-transcript`), follow new rows @@ -92,6 +99,47 @@ type Row = | { kind: 'unknown'; label: string; detail: string } | { kind: 'raw'; line: string } +interface IncrementalParseState { + rows: Row[] + receivedLength: number + partialLine: string + tailFlushed: boolean +} + +const emptyParseState: IncrementalParseState = { + rows: [], + receivedLength: 0, + partialLine: '', + tailFlushed: false, +} + +function appendStreamText( + previous: IncrementalParseState, + text: string, + complete: boolean, +): IncrementalParseState { + const suffix = text.slice(previous.receivedLength) + if (suffix === '' && complete === previous.tailFlushed) return previous + + const pending = previous.partialLine + suffix + 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])) + partialLine = '' + } + + return { + rows: newRows.length === 0 ? previous.rows : [...previous.rows, ...newRows], + receivedLength: text.length, + partialLine, + tailFlushed: complete, + } +} + // Exported for unit tests; not a component (HMR fast-refresh warning is moot). // eslint-disable-next-line react-refresh/only-export-components export function parseStream(text: string, complete: boolean): Row[] { @@ -107,8 +155,12 @@ export function parseStream(text: string, complete: boolean): Row[] { // complete list is already correct. } + return rowsForLines(completeLines) +} + +function rowsForLines(lines: string[]): Row[] { const rows: Row[] = [] - for (const line of completeLines) { + for (const line of lines) { if (!line.trim()) continue const event = tryParse(line) if (event === null) { From 10d2064df0839427b390186325c4e64248b1bd75 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 5 Aug 2026 07:44:27 +0000 Subject: [PATCH 2/2] Avoid effect-driven transcript parse updates --- .../components/StreamTranscript.incremental.test.tsx | 12 ++++++++++++ frontend/src/components/StreamTranscript.tsx | 11 ++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/StreamTranscript.incremental.test.tsx b/frontend/src/components/StreamTranscript.incremental.test.tsx index 6fe41b0b..81a05a86 100644 --- a/frontend/src/components/StreamTranscript.incremental.test.tsx +++ b/frontend/src/components/StreamTranscript.incremental.test.tsx @@ -31,6 +31,18 @@ describe('StreamTranscript incremental parsing', () => { expect(parseSpy.mock.calls.filter(([input]) => input === second)).toHaveLength(1) }) + it('does not parse identical props again', () => { + const line = eventLine('only row') + const text = `${line}\n` + const parseSpy = vi.spyOn(JSON, 'parse') + const { rerender } = render() + + rerender() + + expect(screen.getAllByText('only row')).toHaveLength(1) + expect(parseSpy.mock.calls.filter(([input]) => input === line)).toHaveLength(1) + }) + it('buffers split lines and flushes an unterminated final line once', () => { const split = eventLine('split row') const final = eventLine('final row') diff --git a/frontend/src/components/StreamTranscript.tsx b/frontend/src/components/StreamTranscript.tsx index 1a820a05..fcca2acb 100644 --- a/frontend/src/components/StreamTranscript.tsx +++ b/frontend/src/components/StreamTranscript.tsx @@ -35,11 +35,12 @@ export function StreamTranscript({ text, complete = false }: { text: string; com const [parseState, setParseState] = useState(() => appendStreamText(emptyParseState, text, complete), ) - const rows = parseState.rows - - useEffect(() => { - setParseState((previous) => appendStreamText(previous, text, complete)) - }, [text, complete]) + let rows = parseState.rows + if (parseState.receivedLength !== text.length || parseState.tailFlushed !== complete) { + const nextParseState = appendStreamText(parseState, text, complete) + setParseState(nextParseState) + rows = nextParseState.rows + } // Stick-to-bottom: when the transcript renders inside its own scroll box // (the detail page caps `.ins-xscript .stream-transcript`), follow new rows