diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index c24789af63c..ea548e0d545 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -315,6 +315,12 @@ export interface WebShellProps { onConnectionChange?: (status: string) => void; /** Called when prompt status changes (idle/waiting/responding). */ onStreamingStateChange?: (state: DaemonStreamingState) => void; + /** + * Called whenever transcript blocks change. Receives the full blocks array + * from useTranscriptBlocks(). Fires on every streaming delta during active + * generation, so consumers should debounce or throttle expensive work. + */ + onTranscriptChange?: (blocks: readonly DaemonTranscriptBlock[]) => void; /** Called when a critical error occurs (auth failure, session gone, etc). */ onError?: (error: Error) => void; /** Called when `/bug` is invoked. Receives system info. If omitted, web-shell opens the report URL itself. */ @@ -703,6 +709,7 @@ export function App({ collapseCompletedTurns = true, virtualScrollThreshold, markdown, + onTranscriptChange, onToast, composerRef, composerInput, @@ -1615,6 +1622,10 @@ export function App({ onConnectionChange?.(connection.status); }, [connection.status, onConnectionChange]); + useEffect(() => { + onTranscriptChange?.(blocks); + }, [blocks, onTranscriptChange]); + useEffect(() => { if (connection.error) { const error = new Error(connection.error); diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index f4eb3b93825..6b292e7151d 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -22,6 +22,7 @@ import { useDaemonConnection, useDaemonSessionNotices, useDaemonPendingPermissions, + useDaemonPromptStatus, useDaemonStreamingState, useDaemonTranscriptBlocks, useDaemonTranscriptState, @@ -1682,6 +1683,84 @@ describe('DaemonSessionProvider', () => { ]); }); + it('keeps replayed non-turn events from marking a prompt as waiting', async () => { + const session = createMockSession({ + replaySnapshot: { + compactedReplay: [ + { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'initial replay' }, + }, + }, + }, + ], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + let promptStatus: ReturnType = 'idle'; + + function Harness() { + promptStatus = useDaemonPromptStatus(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + await act(async () => { + await flushPromises(); + }); + + expect(promptStatus).toBe('idle'); + }); + + it('marks replayed user turns without terminal events as waiting', async () => { + const session = createMockSession({ + replaySnapshot: { + compactedReplay: [ + { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'replayed prompt' }, + }, + }, + }, + ], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + let promptStatus: ReturnType = 'idle'; + + function Harness() { + promptStatus = useDaemonPromptStatus(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + await act(async () => { + await flushPromises(); + }); + + expect(promptStatus).toBe('waiting'); + }); + it('finishes replayed assistant streaming when replay ends with turn_error', async () => { const session = createMockSession({ replaySnapshot: { diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 6342ad44578..79407de9c90 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -633,7 +633,7 @@ export function DaemonSessionProvider({ { requireBoundPromptId: true }, ); } - // If replay has events but no terminal signal + // If replay has a user message but no terminal signal // (turn_complete/turn_error/prompt_cancelled), the turn was // likely still in progress — seed promptStatus so the loading // indicator shows immediately instead of flickering. @@ -643,7 +643,8 @@ export function DaemonSessionProvider({ e.type === 'turn_error' || e.type === 'prompt_cancelled', ); - if (replayEvents.length > 0 && !hasTurnTerminalEvent) { + const hasReplayUserMessage = replayEvents.some(isUserMessageEvent); + if (hasReplayUserMessage && !hasTurnTerminalEvent) { setPromptStatus((s) => (s === 'idle' ? 'waiting' : s)); } setConnection((c) => ({ ...c, catchingUp: undefined })); @@ -1216,6 +1217,15 @@ function isPromptLifecycleTurnEvent(event: DaemonEvent): boolean { return event.type === 'turn_complete'; } +function isUserMessageEvent(event: DaemonEvent): boolean { + if (event.type !== 'session_update') return false; + const data = event.data as + | { update?: { sessionUpdate?: unknown } } + | null + | undefined; + return data?.update?.sessionUpdate === 'user_message_chunk'; +} + function normalizeAndFilterEvent( event: DaemonEvent, clientId: string | undefined,