From 7fdc4316c7a20c7b8ad74f05be4fde589fadc6a2 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 16 Jul 2026 15:01:56 -0400 Subject: [PATCH 1/4] refactor(web,server): match a turn to its reply by execution id, and clamp text through one primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to #538/#539. `session.replied` carried no turn identity, so the client matched a fold to a reply by ordinal: the Nth reply-producing fold in the stream was assumed to be the Nth reply in the thread. That holds only while the two stay 1:1, and a failed execution posts no session.replied — so the clocks could drift and nest a turn's work under someone else's answer. The terminal execution_state frame that drives completeSession already knows which execution answered, so it now says so: session.replied carries execution_id, the reducer stamps each item with the execution that produced it, and folds carry it through. Placement matches on identity, and the ordinal machinery is gone. Deliberately no fallback: replies written before this change have no execution id and do not nest — their work keeps a standalone row. One code path, at the cost of nesting on existing threads. When a steer splits a running execution into several folds, the execution's answer is the last of them; that one nests and the earlier ones stay standalone. MessageText's own max-height, so "Show more" shrank the message. That was fixed with an opt-out prop — a convention, and conventions get forgotten. ClampedBlock carries the clamp in context, so an inner block declines to clamp and the nesting is unrepresentable rather than merely discouraged. It also measures overflow instead of trusting a length heuristic, so a message long enough to trip the threshold but short enough to fit no longer offers a toggle that does nothing. Adopted by the two sites that genuinely share the shape. EntryQuoteCard and SessionCapabilitiesPopover keep their own: the first collapses a changes list behind a domain action row, and the second slices an item array rather than clamping a box. Forcing them through the primitive would bend it out of shape to no benefit. --- surface/centaur-client/src/reducer.ts | 21 +++++ .../centaur-client/src/transcriptRows.test.ts | 33 +++---- surface/centaur-client/src/transcriptRows.ts | 11 +-- surface/centaur-client/test/reducer.test.ts | 17 ++++ .../src/session-projection-triggers.test.ts | 4 +- surface/server/src/session-runs.ts | 10 ++- surface/server/test/sessions.test.ts | 7 ++ surface/shared/src/timeline.test.ts | 24 ++++- surface/shared/src/timeline.ts | 7 ++ .../web/src/components/ClampedBlock.test.tsx | 87 +++++++++++++++++++ surface/web/src/components/ClampedBlock.tsx | 69 +++++++++++++++ surface/web/src/components/MessageRow.tsx | 58 ++++--------- surface/web/src/components/MessageText.tsx | 31 +++---- .../components/ThreadPanel.workfold.test.tsx | 47 ++++++++-- .../web/src/components/threadSpine.test.ts | 70 +++++++-------- surface/web/src/components/threadSpine.ts | 50 +++++------ surface/web/test/polish.test.tsx | 3 + surface/web/test/reasoningBlock.test.tsx | 1 + surface/web/test/sessionPane.test.tsx | 1 + .../test/sessionQuestionTranscript.test.tsx | 1 + surface/web/test/spineSessionPane.test.tsx | 3 + surface/web/test/spineThread.test.tsx | 5 ++ surface/web/test/spineWork.test.tsx | 3 +- 23 files changed, 408 insertions(+), 155 deletions(-) create mode 100644 surface/web/src/components/ClampedBlock.test.tsx create mode 100644 surface/web/src/components/ClampedBlock.tsx diff --git a/surface/centaur-client/src/reducer.ts b/surface/centaur-client/src/reducer.ts index 606b0e09d..27175ff23 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/transcriptRows.test.ts b/surface/centaur-client/src/transcriptRows.test.ts index fd7dbdaf0..27fa273a6 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 2ad3dfe34..8ef4f1b45 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 05bccbb23..34bfe8623 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/server/src/session-projection-triggers.test.ts b/surface/server/src/session-projection-triggers.test.ts index 22595f492..95809a215 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-runs.ts b/surface/server/src/session-runs.ts index 589d079db..f56b2110d 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 493399e08..6ecf4f749 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 8ede7528f..a074607de 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 da5a42298..62bc7ae3f 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 000000000..fa9f7e9b0 --- /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 000000000..05cffa9ab --- /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 c995db507..9309af6ff 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 da4c70811..823418cc2 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 bc77094ae..eae2b4029 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 51e72dc6f..b74951bf7 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 9cf264c9d..777a66481 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 dbcf33d08..c4101d683 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 c91dca22c..5c455275d 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 cbe05e70e..11001ba73 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 aedb1462d..4ea62ed33 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, From 3a4930a3eb45615b9689096ae7964730f38506b3 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 16 Jul 2026 15:29:46 -0400 Subject: [PATCH 2/4] fix(server,mobile): give every SessionItem constructor the execution it came from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making `executionId` required is what surfaced these: the workspace typecheck found two constructors the change had missed, in packages the lane's scope excluded. Tests didn't catch them because tests don't typecheck. The server's session-records projection replays stored events, which carry no execution boundary — unlike the live stream, where execution_state frames stamp each item — so null is the honest value there. Mobile's fixtures just had to say so; mobile does no fold-to-reply placement, so nothing in its logic moves. --- surface/mobile/test/threadWorkFold.test.ts | 8 +++++++- surface/mobile/test/turns.test.tsx | 4 ++-- surface/server/src/session-records.ts | 3 +++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/surface/mobile/test/threadWorkFold.test.ts b/surface/mobile/test/threadWorkFold.test.ts index 11258a7ed..88f4b90d2 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 c2ad621e7..c1ecc92e3 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-records.ts b/surface/server/src/session-records.ts index a7ec6984a..5a8578711 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); From ef12d6af4363bf05b3e6a67cf6950eddf30c17be Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 16 Jul 2026 15:45:55 -0400 Subject: [PATCH 3/4] chore: retrigger CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub dropped the pull_request synchronize event for b4e07dc8 — only the pull_request_target CLA workflow fired. Reopening the PR did not re-fire it either, so this nudges a fresh SHA. From 631e36cdf7d99cff2ca38a6f15e86d64a826e717 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 16 Jul 2026 16:18:16 -0400 Subject: [PATCH 4/4] fix(centaur-client): stamp executionId on the toolDisplay test helper ToolCallItem gained a required executionId, but this hand-built literal was never updated. vitest does not typecheck, so only the build caught it. --- surface/centaur-client/src/toolDisplay.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/surface/centaur-client/src/toolDisplay.test.ts b/surface/centaur-client/src/toolDisplay.test.ts index 6319178c0..bfbe20e17 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],