diff --git a/frontend/src/components/StreamTranscript.incremental.test.tsx b/frontend/src/components/StreamTranscript.incremental.test.tsx
new file mode 100644
index 0000000..81a05a8
--- /dev/null
+++ b/frontend/src/components/StreamTranscript.incremental.test.tsx
@@ -0,0 +1,68 @@
+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('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')
+ 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 da93a17..fcca2ac 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,15 @@ 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),
+ )
+ 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
@@ -92,6 +100,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 +156,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) {