@@ -85,9 +99,9 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
{op.running && {t('changesRunning')}}
{op.isError && {t('changesError')}}
- {op.kind !== 'read' && !op.isError && (
+ {opSizes.get(op.callId) !== undefined && (
- {formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)}
+ {opSizes.get(op.callId)}
)}
{relativeTime(new Date(op.time).toISOString())}
diff --git a/src/client/sidechat-transcript.ts b/src/client/sidechat-transcript.ts
index 61dde7a..0c9e400 100644
--- a/src/client/sidechat-transcript.ts
+++ b/src/client/sidechat-transcript.ts
@@ -307,7 +307,7 @@ function resultCard(
* @param entries - history rows (event + host-computed view) in seq order.
* @returns display rows in log order.
*/
-export function transcriptRows(entries: readonly SidebarHistoryEntry[]): SidechatTranscriptRow[] {
+export function transcriptRows(entries: readonly SidebarHistoryEntry[], prev?: readonly SidechatTranscriptRow[]): SidechatTranscriptRow[] {
const events = entries.map(entry => entry.event)
const seedEnd = lastSeedEnd(events)
const rows: SidechatTranscriptRow[] = []
@@ -484,5 +484,59 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): Sidecha
}
}
}
+ return reuseRows(rows, prev)
+}
+
+/** Whether two rows carry identical display content (identity fields plus
+ * every rendered field of their kind). */
+function rowsEqual(a: SidechatTranscriptRow, b: SidechatTranscriptRow): boolean {
+ if (a.kind !== b.kind || a.seq !== b.seq) return false
+ switch (a.kind) {
+ case 'user':
+ case 'injection': {
+ const other = b as typeof a
+ return a.text === other.text
+ }
+ case 'assistant':
+ case 'reasoning': {
+ const other = b as typeof a
+ return a.text === other.text && a.settled === other.settled
+ }
+ case 'tool': {
+ // The card is NOT compared by value: its content is a pure function of
+ // the call-time args and the tool/result landing, and every transition
+ // (output/exit arriving) flips `executing`/`failed`/`resultText` —
+ // compared here — so a card whose content changed never slips through
+ // an equal compare, and an equal compare re-adopting the OLD card
+ // object (identical content, stale reference) only helps React skip.
+ const other = b as typeof a
+ return a.name === other.name && a.failed === other.failed && a.args === other.args
+ && a.resultText === other.resultText && a.executing === other.executing
+ }
+ case 'turnSummary': {
+ const other = b as typeof a
+ return a.inputTokens === other.inputTokens && a.outputTokens === other.outputTokens
+ && a.durationMs === other.durationMs
+ }
+ }
+}
+
+/** Re-adopt the PREVIOUS poll's row objects wherever the content is
+ * unchanged (position-aligned, append-mostly): settled rows keep their
+ * object identity across the 2s polls, so React's reconciler and the
+ * markdown/DOMPurify caches downstream skip them instead of re-rendering
+ * the whole transcript every poll. Comparing the strings is far cheaper
+ * than what a fresh reference costs the row below. Rows past the first
+ * mismatch (an inserted/superseded streaming row) rebuild as usual — that
+ * is exactly the changed tail. */
+function reuseRows(rows: SidechatTranscriptRow[], prev: readonly SidechatTranscriptRow[] | undefined): SidechatTranscriptRow[] {
+ if (prev === undefined) return rows
+ const shared = Math.min(rows.length, prev.length)
+ for (let index = 0; index < shared; index++) {
+ const before = prev[index]
+ const after = rows[index]
+ if (before === undefined || after === undefined) continue
+ if (rowsEqual(before, after)) rows[index] = before
+ }
return rows
}
diff --git a/tests/sidechat-transcript.spec.ts b/tests/sidechat-transcript.spec.ts
index fbe5a6a..6c8e532 100644
--- a/tests/sidechat-transcript.spec.ts
+++ b/tests/sidechat-transcript.spec.ts
@@ -371,3 +371,75 @@ describe('toolArgsSummary', () => {
expect(toolArgsSummary('{"command":" "}')).toBe('{"command":" "}')
})
})
+
+describe('transcriptRows row reuse (the poll-to-poll identity pass)', () => {
+ it('re-adopts the previous poll\'s row objects wherever content is unchanged', () => {
+ const entries = [
+ entry(ev('session/end-seed', 0)),
+ entry(ev('user/message', 1, { content: textBlocks('the side question'), source: { kind: 'user' } })),
+ entry(ev('assistant/message', 2, { message: { content: textBlocks('the answer') } })),
+ ]
+ const first = transcriptRows(entries)
+ const second = transcriptRows(entries, first)
+ expect(second).toEqual(first)
+ // Settled rows keep their object identity (fresh references defeat the
+ // downstream memo and re-render the whole transcript every 2s poll).
+ expect(second[1]).toBe(first[1])
+ expect(second[2]).toBe(first[2])
+ })
+
+ it('rebuilds changed rows and everything past the change', () => {
+ const before = transcriptRows([
+ entry(ev('session/end-seed', 0)),
+ entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })),
+ ])
+ const after = transcriptRows([
+ entry(ev('session/end-seed', 0)),
+ entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })),
+ entry(ev('user/message', 2, { content: textBlocks('q2'), source: { kind: 'user' } })),
+ ], before)
+ expect(after).toHaveLength(2)
+ expect(after[0]).toBe(before[0])
+ expect(after[1]).not.toBe(before[1])
+ expect(after[1]).toMatchObject({ kind: 'user', seq: 2, text: 'q2' })
+ })
+
+ it('never re-adopts a row whose rendered fields changed (tool result lands)', () => {
+ const call = entry(ev('tool/call', 1, { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }))
+ const before = transcriptRows([entry(ev('session/end-seed', 0)), call])
+ expect(before[0]).toMatchObject({ kind: 'tool', executing: true })
+ const result = entry(ev('tool/result', 2, {
+ message: { source: { callId: 'c1' }, content: [{ type: 'tool-result', content: [{ type: 'text', text: 'out' }] }] },
+ }))
+ const after = transcriptRows([entry(ev('session/end-seed', 0)), call, result], before)
+ expect(after[0]).not.toBe(before[0])
+ expect(after[0]).toMatchObject({ kind: 'tool', executing: false, resultText: 'out' })
+ })
+})
+
+describe('transcriptRows row reuse (newer row kinds)', () => {
+ it('re-adopts settled turnSummary rows (token usage tail)', () => {
+ const entries = [
+ entry(ev('session/end-seed', 0)),
+ entry(ev('turn/start', 1, { turn: 1 })),
+ entry(ev('turn/end', 2, { turn: 1, reason: 'completed' })),
+ ]
+ const first = transcriptRows(entries)
+ const summary = first.find(row => row.kind === 'turnSummary')
+ expect(summary).toBeDefined()
+ const second = transcriptRows(entries, first)
+ expect(second[second.length - 1]).toBe(summary)
+ })
+
+ it('re-adopts a tool row whose card landed (executing flip forces the rebuild)', () => {
+ const call = entry(ev('tool/call', 1, { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }))
+ const before = transcriptRows([entry(ev('session/end-seed', 0)), call])
+ expect(before[0]).toMatchObject({ kind: 'tool', executing: true })
+ const result = entry(ev('tool/result', 2, {
+ message: { source: { callId: 'c1' }, content: [{ type: 'tool-result', content: [{ type: 'text', text: 'out\n[exit 0]' }] }] },
+ }))
+ const after = transcriptRows([entry(ev('session/end-seed', 0)), call, result], before)
+ expect(after[0]).not.toBe(before[0])
+ expect(after[0]).toMatchObject({ kind: 'tool', executing: false })
+ })
+})