diff --git a/surface/centaur-client/src/reducer.ts b/surface/centaur-client/src/reducer.ts index 606b0e09..27175ff2 100644 --- a/surface/centaur-client/src/reducer.ts +++ b/surface/centaur-client/src/reducer.ts @@ -24,6 +24,7 @@ export interface TextItem { type: 'text'; id: string; text: string; + executionId: string | null; messageId?: string; uuid?: string; handle?: string | null; @@ -37,6 +38,7 @@ export interface ToolCallItem { id: string; name: string; input: JsonObject; + executionId: string | null; result?: { content: string; is_error: boolean; @@ -50,6 +52,7 @@ export interface ReasoningItem { type: 'reasoning'; id: string; text: string; + executionId: string | null; summary?: string; messageId?: string; handle?: string | null; @@ -61,6 +64,7 @@ export interface QuestionItem { type: 'question'; id: string; questionId: string; + executionId: string | null; turnId?: string; questions: QuestionPrompt[]; status: 'pending' | 'resolved'; @@ -74,6 +78,7 @@ export interface UserMessageItem { type: 'user_message'; id: string; text: string; + executionId: string | null; handle?: string | null; ts?: string; sourceEventIds: number[]; @@ -149,6 +154,9 @@ export interface ArtifactPresentation { export interface SessionState { status: ExecutionStatus | 'idle'; + /** Execution currently producing transcript items. Null for legacy frames + * that predate execution identity. */ + executionId: string | null; /** Server-stamped start of the current turn — `turn/started` when the harness * emits it, else the execution_state frame that flipped us into running. */ turnStartTs?: string; @@ -198,6 +206,7 @@ export interface SessionState { export function initialSessionState(): SessionState { return { status: 'idle', + executionId: null, frameSeq: 0, deltaChars: 0, transport: 'ok', @@ -249,6 +258,7 @@ function reduceSessionFrame(state: SessionState, frame: CentaurEventFrame): Sess if (frame.event === 'execution_state') { const wasActive = state.status !== 'idle' && !isTerminalExecutionStatus(state.status); next.status = frame.data.status; + next.executionId = frame.data.execution_id ?? null; if (frame.data.result_text) { next.resultText = frame.data.result_text; } @@ -523,6 +533,7 @@ function upsertQuestionItem( type: 'question', id: `question:${event.question_id}`, questionId: event.question_id, + executionId: state.executionId, ...(event.turn_id !== undefined ? { turnId: event.turn_id } : {}), questions: event.questions, status: 'pending', @@ -553,6 +564,7 @@ function resolveQuestionItem( type: 'question', id: `question:${questionId}`, questionId, + executionId: state.executionId, questions: [], status: 'resolved', reason, @@ -640,6 +652,7 @@ function upsertClaudeReasoningItem( type: 'reasoning', id, text, + executionId: state.executionId, messageId: messageKey, ...(handle ? { handle } : {}), sourceEventIds: [eventId], @@ -668,6 +681,7 @@ function appendStreamingText(state: SessionState, eventId: number, text: string, type: 'text', id: `text:${eventId}`, text, + executionId: state.executionId, ...(handle ? { handle } : {}), sourceEventIds: [eventId], }); @@ -709,6 +723,7 @@ function reconcileCompleteText( id: messageId ? `text:${messageId}` : `text:${uuid}`, text, uuid, + executionId: state.executionId, ...optionalProp('messageId', messageId), ...(handle ? { handle } : {}), sourceEventIds: [eventId], @@ -738,6 +753,7 @@ function upsertToolCall( id: block.id, name: block.name, input: block.input, + executionId: state.executionId, ...(handle ? { handle } : {}), sourceEventIds: [eventId], }; @@ -1019,6 +1035,7 @@ function upsertReasoningItem( type: 'reasoning', id, text, + executionId: state.executionId, ...(summary !== undefined ? { summary } : {}), ...(itemId ? { messageId: itemId } : {}), ...(handle ? { handle } : {}), @@ -1117,6 +1134,7 @@ function appendCodexStreamingText( type: 'text', id: itemId ? `text:codex:${itemId}` : `text:codex:${eventId}`, text, + executionId: state.executionId, ...optionalProp('messageId', itemId), sourceEventIds: [eventId], }); @@ -1155,6 +1173,7 @@ function reconcileCodexCompleteText( type: 'text', id: itemId ? `text:codex:${itemId}` : `text:codex:${eventId}`, text, + executionId: state.executionId, ...optionalProp('messageId', itemId), ...(handle ? { handle } : {}), sourceEventIds: [eventId], @@ -1188,6 +1207,7 @@ function upsertCodexCommandExecution( id, name: 'command', input, + executionId: state.executionId, ...(handle ? { handle } : {}), sourceEventIds: [eventId], }; @@ -1234,6 +1254,7 @@ function upsertUserMessage( type: 'user_message', id, text, + executionId: state.executionId, ...(handle ? { handle } : {}), sourceEventIds: [eventId], }; diff --git a/surface/centaur-client/src/toolDisplay.test.ts b/surface/centaur-client/src/toolDisplay.test.ts index 6319178c..bfbe20e1 100644 --- a/surface/centaur-client/src/toolDisplay.test.ts +++ b/surface/centaur-client/src/toolDisplay.test.ts @@ -5,6 +5,7 @@ import type { ToolCallItem } from './reducer.js'; const tool = (name: string, input: ToolCallItem['input'] = {}): ToolCallItem => ({ type: 'tool_call', id: `tool:${name}`, + executionId: null, name, input, sourceEventIds: [1], diff --git a/surface/centaur-client/src/transcriptRows.test.ts b/surface/centaur-client/src/transcriptRows.test.ts index fd7dbdaf..27fa273a 100644 --- a/surface/centaur-client/src/transcriptRows.test.ts +++ b/surface/centaur-client/src/transcriptRows.test.ts @@ -8,7 +8,8 @@ import { toolDefaultOpen, } from './transcriptRows.js'; -const item = (id: string, type: SessionItem['type']): SessionItem => ({ id, type }) as SessionItem; +const item = (id: string, type: SessionItem['type'], executionId: string | null = null): SessionItem => + ({ id, type, executionId }) as SessionItem; describe('focus transcript rows', () => { it('groups contiguous reasoning, tools, and inline changes into one count', () => { @@ -96,18 +97,18 @@ describe('tool default visibility', () => { describe('turn work folds', () => { it('groups work after each human input and before that turn’s final answer', () => { const items = [ - { ...item('ask-1', 'user_message'), text: 'First', ts: '2026-07-14T12:00:00.000Z' }, - { ...item('thought-1', 'reasoning'), text: 'Think', ts: '2026-07-14T12:00:01.000Z' }, + { ...item('ask-1', 'user_message', 'exe-1'), text: 'First', ts: '2026-07-14T12:00:00.000Z' }, + { ...item('thought-1', 'reasoning', 'exe-1'), text: 'Think', ts: '2026-07-14T12:00:01.000Z' }, { - ...item('tool-1', 'tool_call'), + ...item('tool-1', 'tool_call', 'exe-1'), name: 'Bash', input: {}, result: { content: 'ok', is_error: false }, ts: '2026-07-14T12:00:02.000Z', }, - { ...item('answer-1', 'text'), text: 'Done', ts: '2026-07-14T12:00:04.000Z' }, - { ...item('ask-2', 'user_message'), text: 'Again', ts: '2026-07-14T12:01:00.000Z' }, - { ...item('tool-2', 'tool_call'), name: 'Read', input: {}, ts: '2026-07-14T12:01:01.000Z' }, + { ...item('answer-1', 'text', 'exe-1'), text: 'Done', ts: '2026-07-14T12:00:04.000Z' }, + { ...item('ask-2', 'user_message', 'exe-2'), text: 'Again', ts: '2026-07-14T12:01:00.000Z' }, + { ...item('tool-2', 'tool_call', 'exe-2'), name: 'Read', input: {}, ts: '2026-07-14T12:01:01.000Z' }, ] as SessionItem[]; const folds = foldedTurnRows(items); @@ -115,7 +116,7 @@ describe('turn work folds', () => { expect(folds[0]).toMatchObject({ items: [items[1], items[2]], toolNames: ['Bash'], - replyOrdinal: 0, + executionId: 'exe-1', triggerIndex: 0, triggerOrdinal: 0, replyIndex: 3, @@ -125,7 +126,7 @@ describe('turn work folds', () => { expect(folds[1]).toMatchObject({ items: [items[5]], toolNames: ['Read'], - replyOrdinal: null, + executionId: 'exe-2', triggerIndex: 4, triggerOrdinal: 1, replyIndex: null, @@ -133,17 +134,17 @@ describe('turn work folds', () => { }); }); - it('preserves the reply ordinal when an earlier turn has no hidden work', () => { + it('takes identity from the fold’s own work and final reply', () => { const items = [ - { ...item('ask-1', 'user_message'), text: 'First' }, - { ...item('answer-1', 'text'), text: 'Immediate answer' }, - { ...item('ask-2', 'user_message'), text: 'Second' }, - { ...item('thought-2', 'reasoning'), text: 'Think' }, - { ...item('answer-2', 'text'), text: 'Worked answer' }, + { ...item('ask-1', 'user_message', 'exe-1'), text: 'First' }, + { ...item('answer-1', 'text', 'exe-1'), text: 'Immediate answer' }, + { ...item('ask-2', 'user_message', 'exe-2'), text: 'Second' }, + { ...item('thought-2', 'reasoning', 'exe-2'), text: 'Think' }, + { ...item('answer-2', 'text', 'exe-2'), text: 'Worked answer' }, ] as SessionItem[]; expect(foldedTurnRows(items)).toHaveLength(1); - expect(foldedTurnRows(items)[0]).toMatchObject({ replyOrdinal: 1, replyIndex: 4 }); + expect(foldedTurnRows(items)[0]).toMatchObject({ executionId: 'exe-2', replyIndex: 4 }); }); }); diff --git a/surface/centaur-client/src/transcriptRows.ts b/surface/centaur-client/src/transcriptRows.ts index 2ad3dfe3..8ef4f1b4 100644 --- a/surface/centaur-client/src/transcriptRows.ts +++ b/surface/centaur-client/src/transcriptRows.ts @@ -16,8 +16,7 @@ export interface FoldedTurnRow { kind: 'fold'; key: string; turn: number; - /** Zero-based position among completed assistant replies; null for a live turn. */ - replyOrdinal: number | null; + executionId: string | null; items: TurnWorkItem[]; toolNames: string[]; startIndex: number; @@ -61,7 +60,6 @@ export function foldedTurnRows(items: readonly SessionItem[]): FoldedTurnRow[] { let triggerOrdinal: number | null = null; let nextTriggerOrdinal = 0; let turn = 0; - let replyOrdinal = 0; const flush = (segmentEnd: number) => { let replyIndex: number | null = null; @@ -82,11 +80,15 @@ export function foldedTurnRows(items: readonly SessionItem[]): FoldedTurnRow[] { const first = indexedWork[0]!; const lastIndex = replyIndex ?? indexedWork[indexedWork.length - 1]!.index; const durationMs = elapsedMs(first.item, items[lastIndex]); + const executionId = + (replyIndex === null ? null : items[replyIndex]?.executionId) ?? + [...workItems].reverse().find((item) => item.executionId !== null)?.executionId ?? + null; folds.push({ kind: 'fold', key: `turn-${turn}-${first.item.id}`, turn, - replyOrdinal: replyIndex === null ? null : replyOrdinal, + executionId, items: workItems, toolNames, startIndex: first.index, @@ -98,7 +100,6 @@ export function foldedTurnRows(items: readonly SessionItem[]): FoldedTurnRow[] { completed: replyIndex !== null, }); } - if (replyIndex !== null) replyOrdinal += 1; turn += 1; }; diff --git a/surface/centaur-client/test/reducer.test.ts b/surface/centaur-client/test/reducer.test.ts index 05bccbb2..34bfe862 100644 --- a/surface/centaur-client/test/reducer.test.ts +++ b/surface/centaur-client/test/reducer.test.ts @@ -23,6 +23,23 @@ const userMessageFrame = (eventId: number, id: string, text: string): CentaurEve }); describe('reduceSession', () => { + it('stamps items with the execution that produced them', () => { + const running = reduceSession(initialSessionState(), { + event: 'execution_state', + event_id: 1, + data: { + type: 'execution.state', + status: 'running', + thread_key: 'thread-1', + execution_id: 'exe-1', + }, + }); + const state = reduceSession(running, userMessageFrame(2, 'item-1', 'do the thing')); + + expect(state.executionId).toBe('exe-1'); + expect(state.items[0]).toMatchObject({ type: 'user_message', executionId: 'exe-1' }); + }); + it('reduces A_pong without duplicating observed text projections', () => { const state = reduceAll(fixture('A_pong')); diff --git a/surface/mobile/test/threadWorkFold.test.ts b/surface/mobile/test/threadWorkFold.test.ts index 11258a7e..88f4b90d 100644 --- a/surface/mobile/test/threadWorkFold.test.ts +++ b/surface/mobile/test/threadWorkFold.test.ts @@ -12,6 +12,7 @@ describe('thread work fold view mapping', () => { type: 'user_message', text: 'Inspect it', ts: '2026-07-16T12:00:00.000Z', + executionId: null, sourceEventIds, }, { @@ -20,6 +21,7 @@ describe('thread work fold view mapping', () => { summary: 'Inspecting', text: 'I will read the file.', ts: '2026-07-16T12:00:01.000Z', + executionId: null, sourceEventIds, }, { @@ -29,6 +31,7 @@ describe('thread work fold view mapping', () => { questions: [{ id: 'prompt-1', header: 'Choice', question: 'Which file?' }], status: 'resolved', ts: '2026-07-16T12:00:02.000Z', + executionId: null, sourceEventIds, }, { @@ -38,6 +41,7 @@ describe('thread work fold view mapping', () => { input: { file_path: 'src/file.ts' }, result: { content: 'file contents', is_error: false }, ts: '2026-07-16T12:00:03.000Z', + executionId: null, sourceEventIds, }, { @@ -45,6 +49,7 @@ describe('thread work fold view mapping', () => { type: 'text', text: 'Done', ts: '2026-07-16T12:00:05.000Z', + executionId: null, sourceEventIds, }, ]; @@ -73,12 +78,13 @@ describe('thread work fold view mapping', () => { it('maps an incomplete turn with no reply as live tool work', () => { const items: SessionItem[] = [ - { id: 'ask', type: 'user_message', text: 'Run it', sourceEventIds }, + { id: 'ask', type: 'user_message', text: 'Run it', executionId: null, sourceEventIds }, { id: 'run', type: 'tool_call', name: 'Bash', input: { command: 'pnpm test' }, + executionId: null, sourceEventIds, }, ]; diff --git a/surface/mobile/test/turns.test.tsx b/surface/mobile/test/turns.test.tsx index c2ad621e..c1ecc92e 100644 --- a/surface/mobile/test/turns.test.tsx +++ b/surface/mobile/test/turns.test.tsx @@ -11,11 +11,11 @@ import { deriveTurns, type Turn } from '../src/components/work/turns'; afterEach(cleanup); function textItem(id: string, text = 'Agent output'): SessionItem { - return { type: 'text', id, text, sourceEventIds: [1] }; + return { type: 'text', id, text, executionId: null, sourceEventIds: [1] }; } function steerItem(id: string, text: string): SessionItem { - return { type: 'user_message', id, text, sourceEventIds: [2] }; + return { type: 'user_message', id, text, executionId: null, sourceEventIds: [2] }; } describe('deriveTurns', () => { diff --git a/surface/server/src/session-projection-triggers.test.ts b/surface/server/src/session-projection-triggers.test.ts index 22595f49..95809a21 100644 --- a/surface/server/src/session-projection-triggers.test.ts +++ b/surface/server/src/session-projection-triggers.test.ts @@ -142,7 +142,7 @@ describe('session projection triggers', () => { const replies = await pool.query<{ thread_root_event_id: number; - payload: { session_id: string; text: string; broadcast: boolean }; + payload: { session_id: string; text: string; broadcast: boolean; execution_id: string }; }>( `SELECT thread_root_event_id, payload FROM events @@ -156,7 +156,7 @@ describe('session projection triggers', () => { expect(replies.rows).toEqual([ { thread_root_event_id: rootId, - payload: { session_id: sessionId, text: 'done', broadcast: true }, + payload: { session_id: sessionId, text: 'done', broadcast: true, execution_id: 'exec-test' }, }, ]); }); diff --git a/surface/server/src/session-records.ts b/surface/server/src/session-records.ts index a7ec6984..5a857871 100644 --- a/surface/server/src/session-records.ts +++ b/surface/server/src/session-records.ts @@ -660,6 +660,9 @@ function projectToolUse( id: block.id, name: block.name, input: block.input, + // This projection replays stored events, which carry no execution boundary — + // unlike the live stream, where execution_state frames stamp each item. + executionId: null, sourceEventIds: [eventId], }; const change = fileChangeFromToolCall(toolItem); diff --git a/surface/server/src/session-runs.ts b/surface/server/src/session-runs.ts index 589d079d..f56b2110 100644 --- a/surface/server/src/session-runs.ts +++ b/surface/server/src/session-runs.ts @@ -2244,7 +2244,7 @@ export class SessionRuns { if (status === 'completed' && terminalAuthFailureEventId == null) { await this.markProviderConnectedIfProxy(id); } - await this.completeSession(id, status, resultText, frame.event_id); + await this.completeSession(id, status, resultText, frame.event_id, frame.data.execution_id); } else { await this.updateStatus(id, status); } @@ -2623,6 +2623,7 @@ export class SessionRuns { status: SessionStatus, resultText: string | null, lastEventId: number, + executionId?: string, ): Promise { const events = await withTx(this.pool, async (client) => { const before = await client.query('SELECT * FROM sessions WHERE id = $1 FOR UPDATE', [id]); @@ -2656,7 +2657,12 @@ export class SessionRuns { // the channel as an ordinary agent message, not only inside the // session thread. A NEW message (rather than an edit of the // card) is deliberate — edits don't notify or bump unread. - payload: { session_id: id, text: replyText, broadcast: true }, + payload: { + session_id: id, + text: replyText, + broadcast: true, + ...(executionId !== undefined ? { execution_id: executionId } : {}), + }, }); const completedEvent = await appendEvent(client, { workspaceId: next.workspace_id, diff --git a/surface/server/test/sessions.test.ts b/surface/server/test/sessions.test.ts index 493399e0..6ecf4f74 100644 --- a/surface/server/test/sessions.test.ts +++ b/surface/server/test/sessions.test.ts @@ -2446,6 +2446,13 @@ describe('Phase 2 sessions', () => { resultExcerpt: 'PONG', permalink: `/s/${id}`, }); + const reply = await pool.query('SELECT payload FROM events WHERE type = $1', ['session.replied']); + expect(reply.rows[0].payload).toMatchObject({ + session_id: id, + text: 'PONG', + broadcast: true, + execution_id: 'exe_666ae19da2c6459f', + }); }); await app.close(); }); diff --git a/surface/shared/src/timeline.test.ts b/surface/shared/src/timeline.test.ts index 8ede7528..a074607d 100644 --- a/surface/shared/src/timeline.test.ts +++ b/surface/shared/src/timeline.test.ts @@ -451,7 +451,12 @@ describe('the final agent reply reaches the channel', () => { threadRootEventId: 7, type: 'session.replied', actorId: null, - payload: { session_id: 's1', text: 'Done — shipped the dashboard.', broadcast: true }, + payload: { + session_id: 's1', + text: 'Done — shipped the dashboard.', + broadcast: true, + execution_id: 'exe-1', + }, createdAt: '2026-07-13T00:01:00.000Z', author: { id: 'agent:s1', handle: 'agent', displayName: 'Agent' }, broadcast: true, @@ -461,6 +466,23 @@ describe('the final agent reply reaches the channel', () => { expect(main).toHaveLength(1); expect(main[0]!.text).toBe('Done — shipped the dashboard.'); expect(main[0]!.sessionEventType).toBe('replied'); + expect(main[0]!.sessionExecutionId).toBe('exe-1'); + }); + + it('decodes a legacy reply with no execution id to null', () => { + const reply: WireEvent = { + id: 9, + workspaceId: 'w1', + channelId: 'c1', + threadRootEventId: 7, + type: 'session.replied', + actorId: null, + payload: { session_id: 's1', text: 'Legacy answer' }, + createdAt: '2026-07-13T00:01:00.000Z', + author: null, + }; + + expect(messageFromEvent(reply).sessionExecutionId).toBeNull(); }); it('folds the newest live reply into the root preview', () => { diff --git a/surface/shared/src/timeline.ts b/surface/shared/src/timeline.ts index da5a4229..62bc7ae3 100644 --- a/surface/shared/src/timeline.ts +++ b/surface/shared/src/timeline.ts @@ -194,6 +194,9 @@ export interface ChatMessage { sessionTask?: string; sessionEventType?: SessionEventRowType; sessionEventPayload?: Record; + /** Execution that produced a session reply. Decoded to null for legacy + * events that predate execution identity. */ + sessionExecutionId?: string | null; /** Thread-visible steer/suggestion provenance for future chips. */ steeredSessionId?: string; suggestedSessionId?: string; @@ -350,6 +353,8 @@ export function messageFromEvent(ev: WireEvent): ChatMessage { const attachments = parseAttachments(payload.attachments); const suppressedUnfurls = parseSuppressedUnfurls(payload.suppressed_unfurls); const broadcast = ev.broadcast === true || payload.broadcast === true; + const sessionExecutionId = + ev.type === 'session.replied' && typeof payload.execution_id === 'string' ? payload.execution_id : null; return { id: ev.id, clientMsgId: typeof payload.client_msg_id === 'string' ? payload.client_msg_id : null, @@ -379,6 +384,7 @@ export function messageFromEvent(ev: WireEvent): ChatMessage { ...(sessionId !== undefined ? { sessionId } : {}), ...(sessionTask !== undefined ? { sessionTask } : {}), ...(sessionEventType !== undefined ? { sessionEventType, sessionEventPayload: payload } : {}), + sessionExecutionId, ...(typeof payload.steered_session_id === 'string' ? { steeredSessionId: payload.steered_session_id } : {}), ...(typeof payload.suggested_session_id === 'string' ? { suggestedSessionId: payload.suggested_session_id } : {}), ...(typeof payload.suggestion_id === 'string' ? { suggestionId: payload.suggestion_id } : {}), @@ -411,6 +417,7 @@ function messageFromLastReply(root: WireEvent, preview: LastReplyPreview): ChatM lastReplyId: 0, lastModifierId: 0, status: 'confirmed', + sessionExecutionId: null, ...(preview.broadcast === true ? { broadcast: true } : {}), ...(sessionEventType ? { sessionEventType } : {}), ...(sessionId ? { sessionId } : {}), diff --git a/surface/web/src/components/ClampedBlock.test.tsx b/surface/web/src/components/ClampedBlock.test.tsx new file mode 100644 index 00000000..fa9f7e9b --- /dev/null +++ b/surface/web/src/components/ClampedBlock.test.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ClampedBlock } from './ClampedBlock'; + +const observers: MockResizeObserver[] = []; + +class MockResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) { + observers.push(this); + } + + observe(_target: Element) {} + + disconnect() {} + + trigger() { + this.callback([], this as unknown as ResizeObserver); + } +} + +function setMetrics(element: Element, scrollHeight: number, clientHeight: number) { + Object.defineProperties(element, { + scrollHeight: { configurable: true, value: scrollHeight }, + clientHeight: { configurable: true, value: clientHeight }, + }); +} + +function renderClamp(children: React.ReactNode = 'Content') { + return render( + + {children} + , + ); +} + +beforeEach(() => { + observers.length = 0; + vi.stubGlobal('ResizeObserver', MockResizeObserver); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('ClampedBlock', () => { + it('does not show a toggle when the clamped content does not overflow', () => { + const { container } = renderClamp(); + const content = container.querySelector('.test-clamp'); + expect(content).toBeTruthy(); + setMetrics(content!, 80, 80); + + act(() => observers[0]?.trigger()); + + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('toggles its caller-supplied label and expansion direction', () => { + const { container } = renderClamp(); + const content = container.querySelector('.test-clamp'); + expect(content).toBeTruthy(); + setMetrics(content!, 180, 80); + act(() => observers[0]?.trigger()); + + const expand = screen.getByRole('button', { name: 'Show more ↓' }); + expect(expand.getAttribute('aria-expanded')).toBe('false'); + fireEvent.click(expand); + + const collapse = screen.getByRole('button', { name: 'Show less ↑' }); + expect(collapse.getAttribute('aria-expanded')).toBe('true'); + expect(container.querySelector('.test-clamp')).toBeNull(); + }); + + it('disables a nested clamp so only the outer block can collapse', () => { + const { container } = renderClamp( + + Nested content + , + ); + + expect(container.querySelector('.test-clamp')).toBeTruthy(); + expect(container.querySelector('.nested-clamp')).toBeNull(); + expect(observers).toHaveLength(1); + }); +}); diff --git a/surface/web/src/components/ClampedBlock.tsx b/surface/web/src/components/ClampedBlock.tsx new file mode 100644 index 00000000..05cffa9a --- /dev/null +++ b/surface/web/src/components/ClampedBlock.tsx @@ -0,0 +1,69 @@ +import { createContext, useContext, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; + +const ClampContext = createContext(false); + +function classes(...values: Array): string | undefined { + const value = values.filter(Boolean).join(' '); + return value || undefined; +} + +export function ClampedBlock({ + children, + collapsedClassName, + collapseLabel, + contentClassName, + enabled = true, + expandLabel, + toggleClassName, +}: { + children: ReactNode; + collapsedClassName: string; + collapseLabel: ReactNode; + contentClassName?: string; + enabled?: boolean; + expandLabel: ReactNode; + toggleClassName?: string; +}) { + const insideClamp = useContext(ClampContext); + const canClamp = enabled && !insideClamp; + const contentRef = useRef(null); + const [expanded, setExpanded] = useState(false); + const [overflows, setOverflows] = useState(false); + + // Only measure while clamped. Expanded content does not overflow, so measuring + // it would incorrectly remove the control that lets the user collapse it. + useLayoutEffect(() => { + const element = contentRef.current; + if (!element || !canClamp || expanded) return; + + const measure = () => setOverflows(element.scrollHeight > element.clientHeight + 1); + measure(); + + // jsdom and older browser shells do not provide ResizeObserver. + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, [canClamp, expanded]); + + const clamped = canClamp && !expanded; + const showToggle = canClamp && overflows; + + return ( + <> +
+ {children} +
+ {showToggle ? ( + + ) : null} + + ); +} diff --git a/surface/web/src/components/MessageRow.tsx b/surface/web/src/components/MessageRow.tsx index c995db50..9309af6f 100644 --- a/surface/web/src/components/MessageRow.tsx +++ b/surface/web/src/components/MessageRow.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useId, - useLayoutEffect, useMemo, useRef, useState, @@ -38,6 +37,7 @@ import { QuestionCard } from '../sessions/SessionBanners'; import type { Session } from '../sessions/types'; import { formatBytes, formatGutterTime, formatTime } from '@atrium/surface-client'; import { Avatar } from './Avatar'; +import { ClampedBlock } from './ClampedBlock'; import { Tooltip } from './a11y'; import { CornerUpLeftIcon, FileIcon, SmilePlusIcon } from './icons'; import { Lightbox } from './media'; @@ -1415,22 +1415,6 @@ function AgentSessionSlot({ function CompactReply({ message }: { message: ChatMessage }) { const agent = message.sessionEventType === 'replied' || message.sessionEventType === 'question_requested'; - const textRef = useRef(null); - const [expanded, setExpanded] = useState(false); - const [overflows, setOverflows] = useState(false); - // Measure rather than guess: whether text exceeds the clamp depends on the - // rendered width, not its length. Only measured while clamped — once expanded - // the element no longer overflows, and re-measuring would drop "Show less". - useLayoutEffect(() => { - const el = textRef.current; - if (!el || expanded) return; - const measure = () => setOverflows(el.scrollHeight > el.clientHeight + 1); - measure(); - if (typeof ResizeObserver === 'undefined') return; - const observer = new ResizeObserver(measure); - observer.observe(el); - return () => observer.disconnect(); - }, [expanded]); return (
-
`, and sr-only is position: absolute) would - // otherwise resolve its containing block to a positioned ancestor - // ABOVE the clamp, escape the clip, keep its static position - // thousands of px down, and inflate the transcript's scroll height - // into blank unreachable space. - expanded ? '' : 'relative line-clamp-3' - }`} + `, and sr-only is position: absolute) would + // otherwise resolve its containing block to a positioned ancestor + // ABOVE the clamp, escape the clip, keep its static position + // thousands of px down, and inflate the transcript's scroll height + // into blank unreachable space. + collapsedClassName="relative line-clamp-3" + expandLabel="Show more" + collapseLabel="Show less" + toggleClassName="mt-0.5 text-xs font-medium text-accent-text hover:underline" > {/* This row owns the clamp, so MessageText must not add its own — two nested clamps is what made "Show more" shrink the message. */} -
- {overflows && ( - - )} + {message.steeredSessionId != null && (
diff --git a/surface/web/src/components/MessageText.tsx b/surface/web/src/components/MessageText.tsx index da4c7081..823418cc 100644 --- a/surface/web/src/components/MessageText.tsx +++ b/surface/web/src/components/MessageText.tsx @@ -18,6 +18,7 @@ import { } from '../lib/entryLinks'; import { resolveUnfurls } from '../lib/unfurls'; import { api } from '../api'; +import { ClampedBlock } from './ClampedBlock'; import { EntryInlineChip, EntryQuoteCard } from './EntryQuoteCard'; import { FilePathChip } from './FilePathChip'; import { InternalLinkCard } from './InternalLinkCard'; @@ -776,7 +777,6 @@ export function MessageText({ const shouldCollapse = collapsible && (bodyText.length > COLLAPSE_CHAR_THRESHOLD || bodyText.split(/\r\n|\r|\n/).length > COLLAPSE_LINE_THRESHOLD); - const [expanded, setExpanded] = useState(!shouldCollapse); const content: ReactNode = ( -
`) would + // otherwise anchor above the clamp, escape the clip, and inflate the + // scroll height into blank space (#544). + collapsedClassName="relative max-h-80 overflow-hidden [mask-image:linear-gradient(to_bottom,black_70%,transparent)]" + expandLabel="Show more" + collapseLabel="Show less" + toggleClassName="mt-1 text-xs font-medium text-accent-text hover:underline" > {content} -
- {shouldCollapse && ( - - )} + = {}): ChatMessage { createdAt: '2026-07-05T12:00:00.000Z', replyCount: 1, lastReplyId: 0, + sessionExecutionId: null, status: 'confirmed', ...overrides, }; @@ -58,6 +59,7 @@ function agentReply(overrides: Partial = {}): ChatMessage { author: agent, sessionId: 's-1', sessionEventType: 'replied', + sessionExecutionId: 'exe-1', ...overrides, }); } @@ -108,6 +110,7 @@ function workedTurn(): SessionItem[] { id: 't-1', name: 'Bash', input: { command: 'pnpm test' }, + executionId: 'exe-1', result: { content: 'ok', is_error: false }, ts: '2026-07-05T12:00:01.000Z', sourceEventIds: [2], @@ -117,6 +120,7 @@ function workedTurn(): SessionItem[] { id: 't-2', name: 'Read', input: { file_path: 'migrations/081.sql' }, + executionId: 'exe-1', result: { content: 'ok', is_error: false }, ts: '2026-07-05T12:00:02.000Z', sourceEventIds: [3], @@ -125,6 +129,7 @@ function workedTurn(): SessionItem[] { type: 'text', id: 'x-1', text: 'The migration is safe to ship.', + executionId: 'exe-1', ts: '2026-07-05T12:00:03.000Z', sourceEventIds: [4], }, @@ -134,28 +139,58 @@ function workedTurn(): SessionItem[] { /** Two steered turns, each: steer echo → work → the answer that closed it. */ function twoSteeredTurns(): SessionItem[] { return [ - { type: 'user_message', id: 'u-1', text: 'first steer', ts: '2026-07-05T12:00:00.000Z', sourceEventIds: [1] }, + { + type: 'user_message', + id: 'u-1', + text: 'first steer', + executionId: 'exe-1', + ts: '2026-07-05T12:00:00.000Z', + sourceEventIds: [1], + }, { type: 'tool_call', id: 't-1', name: 'Bash', input: { command: 'pnpm test' }, + executionId: 'exe-1', result: { content: 'ok', is_error: false }, ts: '2026-07-05T12:00:01.000Z', sourceEventIds: [2], }, - { type: 'text', id: 'x-1', text: 'First answer.', ts: '2026-07-05T12:00:02.000Z', sourceEventIds: [3] }, - { type: 'user_message', id: 'u-2', text: 'second steer', ts: '2026-07-05T12:00:03.000Z', sourceEventIds: [4] }, + { + type: 'text', + id: 'x-1', + text: 'First answer.', + executionId: 'exe-1', + ts: '2026-07-05T12:00:02.000Z', + sourceEventIds: [3], + }, + { + type: 'user_message', + id: 'u-2', + text: 'second steer', + executionId: 'exe-2', + ts: '2026-07-05T12:00:03.000Z', + sourceEventIds: [4], + }, { type: 'tool_call', id: 't-2', name: 'Read', input: { file_path: 'a.ts' }, + executionId: 'exe-2', result: { content: 'ok', is_error: false }, ts: '2026-07-05T12:00:04.000Z', sourceEventIds: [5], }, - { type: 'text', id: 'x-2', text: 'Second answer.', ts: '2026-07-05T12:00:05.000Z', sourceEventIds: [6] }, + { + type: 'text', + id: 'x-2', + text: 'Second answer.', + executionId: 'exe-2', + ts: '2026-07-05T12:00:05.000Z', + sourceEventIds: [6], + }, ]; } @@ -222,9 +257,9 @@ describe('ThreadPanel work folds', () => { renderPanel( [ steer({ id: 43, text: 'first steer' }), - agentReply({ id: 44, text: 'First answer.' }), + agentReply({ id: 44, text: 'First answer.', sessionExecutionId: 'exe-1' }), steer({ id: 45, text: 'second steer' }), - agentReply({ id: 46, text: 'Second answer.' }), + agentReply({ id: 46, text: 'Second answer.', sessionExecutionId: 'exe-2' }), ], { 's-1': session() }, ); diff --git a/surface/web/src/components/threadSpine.test.ts b/surface/web/src/components/threadSpine.test.ts index bc77094a..eae2b402 100644 --- a/surface/web/src/components/threadSpine.test.ts +++ b/surface/web/src/components/threadSpine.test.ts @@ -13,7 +13,7 @@ function fold(overrides: Partial & { key: string }): FoldedTurnRo return { kind: 'fold', turn: 0, - replyOrdinal: null, + executionId: null, items: [], toolNames: ['Bash'], startIndex: 0, @@ -40,12 +40,14 @@ function msg(id: number, overrides: Partial = {}): ChatMessage { createdAt: '2026-07-05T12:00:00.000Z', replyCount: 0, lastReplyId: 0, + sessionExecutionId: null, status: 'confirmed', ...overrides, }; } -const reply = (id: number) => msg(id, { sessionId: SESSION, sessionEventType: 'replied' }); +const reply = (id: number, executionId: string | null = null) => + msg(id, { sessionId: SESSION, sessionEventType: 'replied', sessionExecutionId: executionId }); const steer = (id: number) => msg(id, { steeredSessionId: SESSION }); function timeline(messages: ChatMessage[]): TimelineItem[] { @@ -62,61 +64,53 @@ const nestedOn = (result: SpineRow[], id: number) => result.find((row) => row.kind === 'message' && row.message.id === id) as Extract; describe('buildSpineRows', () => { - it('nests each turn’s fold in the reply it produced', () => { + it('nests a fold in the reply carrying its execution id', () => { const result = rows( - [steer(1), reply(2), steer(3), reply(4)], - [fold({ key: 'a', replyOrdinal: 0, triggerOrdinal: 0 }), fold({ key: 'b', replyOrdinal: 1, triggerOrdinal: 1 })], + [reply(2, 'exe-b'), reply(4, 'exe-a')], + [fold({ key: 'a', executionId: 'exe-a' }), fold({ key: 'b', executionId: 'exe-b' })], ); - expect(nestedOn(result, 2).fold?.key).toBe('a'); - expect(nestedOn(result, 4).fold?.key).toBe('b'); + expect(nestedOn(result, 2).fold?.key).toBe('b'); + expect(nestedOn(result, 4).fold?.key).toBe('a'); // Nested means NOT also standalone. expect(result.filter((row) => row.kind === 'fold')).toHaveLength(0); }); - it('renders a fold exactly once even when its steer precedes its reply', () => { - // Regression: the trigger pass hoisted fold `b` into a standalone row before - // reply 4 nested it, so the same work rendered twice. - const result = rows( - [steer(1), reply(2), steer(3), reply(4)], - [fold({ key: 'a', replyOrdinal: 0, triggerOrdinal: 0 }), fold({ key: 'b', replyOrdinal: 1, triggerOrdinal: 1 })], - ); - - expect(foldKeysOf(result)).toEqual(['a', 'b']); - }); - - it('keeps work with no reply of its own as a standalone row', () => { - const result = rows([steer(1)], [fold({ key: 'orphan', replyOrdinal: null, triggerOrdinal: 0 })]); + it('does not nest a reply with no execution id and keeps its fold standalone', () => { + const result = rows([reply(2)], [fold({ key: 'legacy', executionId: 'exe-legacy', triggerOrdinal: 0 })]); - expect(result.filter((row) => row.kind === 'fold').map((row) => row.key)).toEqual(['orphan']); + expect(nestedOn(result, 2).fold).toBeUndefined(); + expect(result.filter((row) => row.kind === 'fold').map((row) => row.key)).toEqual(['legacy']); }); - it('does not nest a turn the thread never heard an answer for', () => { - // The stream saw three answered turns; the thread only carries two replies - // (a failed execution posts no `session.replied`). The third fold must not - // borrow someone else's answer — it keeps its own row. + it('nests the last fold when several folds share one execution id', () => { const result = rows( - [reply(2), reply(4)], - [fold({ key: 'a', replyOrdinal: 0 }), fold({ key: 'b', replyOrdinal: 1 }), fold({ key: 'c', replyOrdinal: 2 })], + [steer(1), reply(2, 'exe-shared')], + [ + fold({ key: 'early', executionId: 'exe-shared', triggerOrdinal: 0 }), + fold({ key: 'final', executionId: 'exe-shared', triggerOrdinal: 1 }), + ], ); - expect(nestedOn(result, 2).fold?.key).toBe('a'); - expect(nestedOn(result, 4).fold?.key).toBe('b'); - expect(result.filter((row) => row.kind === 'fold').map((row) => row.key)).toEqual(['c']); - expect(foldKeysOf(result)).toHaveLength(3); + expect(nestedOn(result, 2).fold?.key).toBe('final'); + expect(result.filter((row) => row.kind === 'fold').map((row) => row.key)).toEqual(['early']); + expect(foldKeysOf(result)).toEqual(['early', 'final']); }); it('never drops a fold, whatever the shape', () => { const workFolds = [ - fold({ key: 'a', replyOrdinal: 0, triggerOrdinal: null }), - fold({ key: 'b', replyOrdinal: 1, triggerOrdinal: 1 }), - fold({ key: 'c', replyOrdinal: null, triggerOrdinal: 2 }), - fold({ key: 'd', replyOrdinal: null, triggerOrdinal: 9 }), + fold({ key: 'matched', executionId: 'exe-a', triggerOrdinal: null }), + fold({ key: 'shared-early', executionId: 'exe-b', triggerOrdinal: 1 }), + fold({ key: 'shared-final', executionId: 'exe-b', triggerOrdinal: 2 }), + fold({ key: 'legacy', executionId: null, triggerOrdinal: 2 }), + fold({ key: 'unanswered', executionId: 'exe-c', triggerOrdinal: 9 }), ]; - const result = rows([reply(2), steer(3), reply(4), steer(5)], workFolds); + const result = rows([reply(2, 'exe-a'), steer(3), reply(4, 'exe-b'), steer(5)], workFolds); - expect(new Set(foldKeysOf(result))).toEqual(new Set(['a', 'b', 'c', 'd'])); - expect(foldKeysOf(result)).toHaveLength(4); + expect(new Set(foldKeysOf(result))).toEqual( + new Set(['matched', 'shared-early', 'shared-final', 'legacy', 'unanswered']), + ); + expect(foldKeysOf(result)).toHaveLength(5); }); it('leaves a thread with no attached session untouched', () => { diff --git a/surface/web/src/components/threadSpine.ts b/surface/web/src/components/threadSpine.ts index 51e72dc6..b74951bf 100644 --- a/surface/web/src/components/threadSpine.ts +++ b/surface/web/src/components/threadSpine.ts @@ -1,10 +1,8 @@ // Placing a session's work folds into a thread's message spine. // -// Two clocks meet here, and they are not the same clock: the SSE stream counts -// turns (segments between `user_message` echoes), while the thread counts -// `session.replied` events (one per execution that finished with an answer). A -// fold is assigned to the reply it produced when those line up, and otherwise -// keeps a row of its own rather than attaching to someone else's answer. +// The SSE transcript and thread event stream meet here through the execution id +// shared by a work fold and the `session.replied` event it produced. Unmatched +// work keeps a row of its own rather than attaching to someone else's answer. import { isLiveFold, type FoldedTurnRow } from '@atrium/centaur-client'; import type { ChatMessage, TimelineItem } from '@atrium/surface-client'; @@ -32,37 +30,38 @@ function isAgentReply(message: ChatMessage, attachedSessionId: string | null): b return attachedSessionId != null && message.sessionId === attachedSessionId && message.sessionEventType === 'replied'; } -/** - * Maps each reply-producing fold to that reply's zero-based position. Folds - * beyond the thread's reply count are left unmapped: the stream knows about - * turns the thread never heard an answer for (a failed execution posts no - * `session.replied`), and guessing would nest one turn's work under another - * turn's answer. - */ -function foldsByReplyOrdinal(workFolds: readonly FoldedTurnRow[], agentReplyCount: number): Map { - const byOrdinal = new Map(); +function foldsByExecutionId( + workFolds: readonly FoldedTurnRow[], + replyExecutionIds: ReadonlySet, +): Map { + const byExecutionId = new Map(); for (const fold of workFolds) { - if (fold.replyOrdinal == null || fold.replyOrdinal >= agentReplyCount) continue; - if (!byOrdinal.has(fold.replyOrdinal)) byOrdinal.set(fold.replyOrdinal, fold); + if (fold.executionId === null || !replyExecutionIds.has(fold.executionId)) continue; + // A steer can split one running execution into several folds. Its final + // answer belongs to the last fold; earlier folds remain standalone. + byExecutionId.set(fold.executionId, fold); } - return byOrdinal; + return byExecutionId; } export function buildSpineRows({ items, workFolds, attachedSessionId, sessionLive }: SpineInput): SpineRow[] { const rows: SpineRow[] = []; - let replyOrdinal = 0; let triggerOrdinal = 0; - const agentReplyCount = items.filter( - (item) => item.kind !== 'day' && item.message != null && isAgentReply(item.message, attachedSessionId), - ).length; - const byReplyOrdinal = foldsByReplyOrdinal(workFolds, agentReplyCount); + const replyExecutionIds = new Set(); + for (const item of items) { + if (item.kind === 'day' || !item.message || !isAgentReply(item.message, attachedSessionId)) continue; + if (item.message.sessionExecutionId !== null && item.message.sessionExecutionId !== undefined) { + replyExecutionIds.add(item.message.sessionExecutionId); + } + } + const byExecutionId = foldsByExecutionId(workFolds, replyExecutionIds); // Spoken for before any pass runs: a fold that belongs to a reply must never // ALSO be pushed as its own row. Seeding the set (rather than guarding one // pass) is what makes that hold for every pass below — the trigger pass used // to hoist a later reply's fold into a standalone row, rendering it twice. - const usedFolds = new Set([...byReplyOrdinal.values()].map((fold) => fold.key)); + const usedFolds = new Set([...byExecutionId.values()].map((fold) => fold.key)); const pushFold = (fold: FoldedTurnRow) => { rows.push({ kind: 'fold', key: fold.key, fold, live: isLiveFold(fold, workFolds, sessionLive) }); usedFolds.add(fold.key); @@ -79,9 +78,8 @@ export function buildSpineRows({ items, workFolds, attachedSessionId, sessionLiv if (item.kind === 'day' || !item.message) continue; const message = item.message; let fold: FoldedTurnRow | undefined; - if (isAgentReply(message, attachedSessionId)) { - fold = byReplyOrdinal.get(replyOrdinal); - replyOrdinal += 1; + if (isAgentReply(message, attachedSessionId) && message.sessionExecutionId != null) { + fold = byExecutionId.get(message.sessionExecutionId); } const aside = attachedSessionId != null && diff --git a/surface/web/test/polish.test.tsx b/surface/web/test/polish.test.tsx index 9cf264c9..777a6648 100644 --- a/surface/web/test/polish.test.tsx +++ b/surface/web/test/polish.test.tsx @@ -21,6 +21,7 @@ import { clearUserDirectoryForTests, primeUserDirectory } from '../src/userDirec afterEach(() => { cleanup(); clearUserDirectoryForTests(); + vi.restoreAllMocks(); }); const me = { id: 'u-me', handle: 'me', displayName: 'Me' }; @@ -77,6 +78,8 @@ describe('MessageText formatting', () => { }); it('skips unsafe inline html and collapses long markdown', () => { + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(400); + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(320); render(
{ type: 'user_message', id: 'original-prompt', text: 'Repeat this', + executionId: null, ts: '2026-07-13T11:59:59.000Z', sourceEventIds: [1], }; diff --git a/surface/web/test/sessionQuestionTranscript.test.tsx b/surface/web/test/sessionQuestionTranscript.test.tsx index dbcf33d0..c4101d68 100644 --- a/surface/web/test/sessionQuestionTranscript.test.tsx +++ b/surface/web/test/sessionQuestionTranscript.test.tsx @@ -76,6 +76,7 @@ function questionItem(overrides: Partial = {}): QuestionItem { status: 'pending', sourceEventIds: [10], ...overrides, + executionId: overrides.executionId ?? null, }; } diff --git a/surface/web/test/spineSessionPane.test.tsx b/surface/web/test/spineSessionPane.test.tsx index c91dca22..5c455275 100644 --- a/surface/web/test/spineSessionPane.test.tsx +++ b/surface/web/test/spineSessionPane.test.tsx @@ -77,6 +77,7 @@ function completedStream(): SessionState { type: 'user_message', id: 'ask', text: 'Please inspect the build.', + executionId: 'exe-1', ts: '2026-07-14T12:00:00.000Z', sourceEventIds: [1], }, @@ -85,6 +86,7 @@ function completedStream(): SessionState { id: 'tool', name: 'Bash', input: { command: 'pnpm test' }, + executionId: 'exe-1', result: { content: 'passed', is_error: false }, ts: '2026-07-14T12:00:01.000Z', sourceEventIds: [2], @@ -93,6 +95,7 @@ function completedStream(): SessionState { type: 'text', id: 'answer', text: 'The build passes.', + executionId: 'exe-1', ts: '2026-07-14T12:00:04.000Z', sourceEventIds: [3], }, diff --git a/surface/web/test/spineThread.test.tsx b/surface/web/test/spineThread.test.tsx index cbe05e70..11001ba7 100644 --- a/surface/web/test/spineThread.test.tsx +++ b/surface/web/test/spineThread.test.tsx @@ -40,6 +40,7 @@ function message(overrides: Partial = {}): ChatMessage { createdAt: '2026-07-14T12:00:00.000Z', replyCount: 2, lastReplyId: 44, + sessionExecutionId: null, status: 'confirmed', ...overrides, }; @@ -92,6 +93,7 @@ function sessionStream(): SessionState { type: 'user_message', id: 'ask', text: 'Please inspect the build.', + executionId: 'exe-1', ts: '2026-07-14T12:00:00.000Z', sourceEventIds: [1], }, @@ -100,6 +102,7 @@ function sessionStream(): SessionState { id: 'tool', name: 'Bash', input: { command: 'pnpm test' }, + executionId: 'exe-1', result: { content: 'passed', is_error: false }, ts: '2026-07-14T12:00:01.000Z', sourceEventIds: [2], @@ -108,6 +111,7 @@ function sessionStream(): SessionState { type: 'text', id: 'answer', text: 'The build passes.', + executionId: 'exe-1', ts: '2026-07-14T12:00:04.000Z', sourceEventIds: [3], }, @@ -135,6 +139,7 @@ function renderPanel({ attached = true, onOpenSession = vi.fn() } = {}) { author: agent, sessionId: 's-1', sessionEventType: 'replied', + sessionExecutionId: 'exe-1', createdAt: '2026-07-14T12:00:04.000Z', }), message({ diff --git a/surface/web/test/spineWork.test.tsx b/surface/web/test/spineWork.test.tsx index aedb1462..4ea62ed3 100644 --- a/surface/web/test/spineWork.test.tsx +++ b/surface/web/test/spineWork.test.tsx @@ -17,6 +17,7 @@ function tool(overrides: Partial = {}): ToolCallItem { result: { content: Array.from({ length: 20 }, (_, index) => `line ${index}`).join('\n'), is_error: false }, sourceEventIds: [1], ...overrides, + executionId: overrides.executionId ?? null, }; } @@ -25,7 +26,7 @@ function fold(item = tool()): FoldedTurnRow { kind: 'fold', key: 'turn-0', turn: 0, - replyOrdinal: 0, + executionId: item.executionId, items: [item], toolNames: [item.name], startIndex: 1,