diff --git a/docs/design/web-shell-history-pagination.md b/docs/design/web-shell-history-pagination.md index 05feb76ae55..0511da3ad01 100644 --- a/docs/design/web-shell-history-pagination.md +++ b/docs/design/web-shell-history-pagination.md @@ -117,6 +117,33 @@ transcript becomes scrollable or history is exhausted. A failed or partial page leaves the current transcript intact, stops automatic retries, and surfaces the existing daemon notice path. +### Live-session retention + +The 50,000-block store limit remains a final safety cap. Web Shell also applies +a 500-block reload trigger to sessions that remain open for a long time. Once a +transcript exceeds that trigger, the agent is idle, no SSE event has arrived for +two minutes, and the reader remains at the live tail, Web Shell reloads the same +session with `historyPageSize: 100`. The old SSE subscription is closed by the +normal session-switch cleanup. The load response supplies the bounded replay +and its atomic `lastEventId`; the provider rebuilds the transcript and starts a +new SSE subscription from that watermark. + +The existing transcript remains mounted while this background load is in +flight. Once the bounded replay arrives, the provider resets and dispatches it +in one store notification, so the reader never sees an empty or loading state. + +Loading an already attached session with a page size refreshes only its UI +replay. It does not restart the agent or reload the model-facing conversation. +The bridge reads a fresh persisted page while the session EventBus watermark is +stable and returns it through the normal load envelope. If events arrive during +that read, the bridge retries and otherwise falls back to its existing replay. + +After reload, upward scrolling follows the same `beforeRecordId` and opaque +cursor pagination used by historical sessions. Scrolling upward cancels the +reload timer. Returning to the live tail starts a new two-minute quiet period. +Main and split views own independent providers, SSE subscriptions, timers, +cursors, and retained windows. + ## Consistency and failure handling - Initial history and the SSE watermark remain coupled through `session/load`. @@ -138,18 +165,11 @@ existing daemon notice path. ## Affected areas -| Layer | Change | -| ------------------------ | ------------------------------------------------------------ | -| Core transcript reader | Backward cursor and exclusive record boundary | -| ACP replay | Record UUID metadata and latest-suffix selection | -| ACP bridge / serve route | Paged-load metadata, validation, and `hasMore` propagation | -| TypeScript SDK | Restore option, backward page option, restored history state | -| WebUI provider | Isolated prepend, page state, stale-request protection | -| Web Shell | Opt-in page size and automatic top-loading behavior | - -## Open questions and follow-ups - -- A session created and kept live for its entire lifetime has no persisted - record UUID on its live EventBus frames. This change pages sessions restored - through the new load path; adding record identity to live emission is a - separate recording/emission coordination change. +| Layer | Change | +| ------------------------ | ---------------------------------------------------------- | +| Core transcript reader | Backward cursor and exclusive record boundary | +| ACP replay | Record UUID metadata and latest-suffix selection | +| ACP bridge / serve route | Paged-load metadata, validation, and `hasMore` propagation | +| TypeScript SDK | Restore page-size option and restored history state | +| WebUI provider | Isolated prepend, page state, stale-request protection | +| Web Shell | Opt-in page size and automatic top-loading behavior | diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 5ba6187e8dc..b0baa669d51 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -2919,6 +2919,316 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('refreshes an attached load from a bounded persisted page', async () => { + const handle = makeChannel({ + loadSessionImpl: () => ({ + _meta: { + 'qwen.session.loadReplay': { + v: 1, + updates: [ + { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'initial prompt' }, + }, + ], + }, + }, + }), + extMethodImpl: (method, params) => { + if (method !== SERVE_STATUS_EXT_METHODS.sessionTranscript) { + throw new Error(`unexpected extMethod ${method}`); + } + return { + v: 1, + sessionId: params['sessionId'], + events: [ + { + v: 1, + type: 'session_update', + data: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'latest prompt' }, + _meta: { 'qwen.session.recordId': 'record-latest' }, + }, + }, + ], + hasMore: true, + }; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const loaded = await bridge.loadSession({ + sessionId: 'persisted-live-refresh', + workspaceCwd: WS_A, + historyReplay: 'response', + historyPageSize: 100, + }); + + const refreshed = await bridge.loadSession({ + sessionId: loaded.sessionId, + workspaceCwd: WS_A, + clientId: loaded.clientId, + historyReplay: 'response', + historyPageSize: 100, + }); + + expect(handle.agent.loadSessionCalls).toHaveLength(1); + expect(handle.agent.extMethodCalls).toContainEqual({ + method: SERVE_STATUS_EXT_METHODS.sessionTranscript, + params: { + cwd: WS_A, + sessionId: loaded.sessionId, + direction: 'backward', + limit: 100, + }, + }); + expect(refreshed).toMatchObject({ + attached: true, + historyHasMore: true, + lastEventId: loaded.lastEventId, + compactedReplay: [ + { + type: 'session_update', + data: { + content: { type: 'text', text: 'latest prompt' }, + }, + }, + ], + liveJournal: [], + }); + + await bridge.shutdown(); + }); + + it('propagates partial and replayError from a bounded refresh', async () => { + const handle = makeChannel({ + loadSessionImpl: () => ({ + _meta: { + 'qwen.session.loadReplay': { + v: 1, + updates: [ + { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'initial prompt' }, + }, + ], + }, + }, + }), + extMethodImpl: (method, params) => { + if (method !== SERVE_STATUS_EXT_METHODS.sessionTranscript) { + throw new Error(`unexpected extMethod ${method}`); + } + return { + v: 1, + sessionId: params['sessionId'], + events: [ + { + v: 1, + type: 'session_update', + data: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'bounded page' }, + _meta: { 'qwen.session.recordId': 'record-bounded' }, + }, + }, + ], + hasMore: true, + partial: true, + replayError: 'transcript read failed', + }; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const loaded = await bridge.loadSession({ + sessionId: 'persisted-live-refresh-metadata', + workspaceCwd: WS_A, + historyReplay: 'response', + historyPageSize: 100, + }); + + const refreshed = await bridge.loadSession({ + sessionId: loaded.sessionId, + workspaceCwd: WS_A, + clientId: loaded.clientId, + historyReplay: 'response', + historyPageSize: 100, + }); + + expect(refreshed).toMatchObject({ + attached: true, + partial: true, + replayError: 'transcript read failed', + historyHasMore: true, + }); + + await bridge.shutdown(); + }); + + it('falls back to the live replay when a bounded refresh stays unstable', async () => { + let update = 0; + const handle = makeChannel({ + loadSessionImpl: () => ({ + _meta: { + 'qwen.session.loadReplay': { + v: 1, + updates: [ + { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'initial prompt' }, + }, + ], + }, + }, + }), + extMethodImpl: async (method, params) => { + if (method !== SERVE_STATUS_EXT_METHODS.sessionTranscript) { + throw new Error(`unexpected extMethod ${method}`); + } + update++; + await handle.agentConnection.sessionUpdate({ + sessionId: params['sessionId'] as string, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: `live update ${update}` }, + }, + }); + return { + v: 1, + sessionId: params['sessionId'], + events: [ + { + v: 1, + type: 'session_update', + data: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'bounded page' }, + }, + }, + ], + hasMore: true, + }; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const loaded = await bridge.loadSession({ + sessionId: 'persisted-live-refresh-race', + workspaceCwd: WS_A, + historyReplay: 'response', + historyPageSize: 100, + }); + + const refreshed = await bridge.loadSession({ + sessionId: loaded.sessionId, + workspaceCwd: WS_A, + clientId: loaded.clientId, + historyReplay: 'response', + historyPageSize: 100, + }); + + expect(handle.agent.extMethodCalls).toHaveLength(2); + expect(refreshed).not.toHaveProperty('historyHasMore'); + expect(JSON.stringify(refreshed.compactedReplay)).not.toContain( + 'bounded page', + ); + expect(JSON.stringify(refreshed)).toContain('live update 2'); + + await bridge.shutdown(); + }); + + it('falls back to the live replay when a bounded refresh read fails', async () => { + const handle = makeChannel({ + loadSessionImpl: () => ({ + _meta: { + 'qwen.session.loadReplay': { + v: 1, + updates: [ + { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'initial prompt' }, + }, + ], + }, + }, + }), + extMethodImpl: (method, _params) => { + if (method !== SERVE_STATUS_EXT_METHODS.sessionTranscript) { + throw new Error(`unexpected extMethod ${method}`); + } + throw new Error('transcript page read failed'); + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const loaded = await bridge.loadSession({ + sessionId: 'persisted-live-refresh-read-error', + workspaceCwd: WS_A, + historyReplay: 'response', + historyPageSize: 100, + }); + + const refreshed = await bridge.loadSession({ + sessionId: loaded.sessionId, + workspaceCwd: WS_A, + clientId: loaded.clientId, + historyReplay: 'response', + historyPageSize: 100, + }); + + expect(refreshed.attached).toBe(true); + expect(JSON.stringify(refreshed.compactedReplay)).toContain( + 'initial prompt', + ); + + await bridge.shutdown(); + }); + + it('rejects a bounded refresh when the session starts closing', async () => { + const transcriptPage = deferred>(); + const closeResult = deferred>(); + const handle = makeChannel({ + extMethodImpl: (method, _params) => { + if (method === SERVE_STATUS_EXT_METHODS.sessionTranscript) { + return transcriptPage.promise; + } + if (method === SERVE_CONTROL_EXT_METHODS.sessionClose) { + return closeResult.promise; + } + throw new Error(`unexpected extMethod ${method}`); + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const loaded = await bridge.loadSession({ + sessionId: 'persisted-live-refresh-closing', + workspaceCwd: WS_A, + historyReplay: 'response', + historyPageSize: 100, + }); + const refresh = bridge.loadSession({ + sessionId: loaded.sessionId, + workspaceCwd: WS_A, + clientId: loaded.clientId, + historyReplay: 'response', + historyPageSize: 100, + }); + await vi.waitFor(() => expect(handle.agent.extMethodCalls).toHaveLength(1)); + + const close = bridge.closeSession(loaded.sessionId, { + clientId: loaded.clientId, + }); + await vi.waitFor(() => expect(handle.agent.extMethodCalls).toHaveLength(2)); + transcriptPage.resolve({ + v: 1, + sessionId: loaded.sessionId, + events: [], + hasMore: false, + }); + + await expect(refresh).rejects.toBeInstanceOf(SessionNotFoundError); + closeResult.resolve({}); + await close; + await bridge.shutdown(); + }); + it('restores artifacts from response-mode load replay when no snapshot is available', async () => { const handles: ChannelHandle[] = []; const factory: ChannelFactory = async () => { diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 32f5564c741..25681f2c71d 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -124,6 +124,7 @@ import type { BridgeWorkspaceMemoryRememberRequest, BridgeWorkspaceMemoryRememberResult, BridgeSessionTranscriptPage, + BridgeSessionTranscriptPageRequest, BridgeGenerationStreamEvent, } from './bridgeTypes.js'; import type { @@ -3995,6 +3996,75 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return publicState; }; + async function requestSessionTranscriptPage( + req: BridgeSessionTranscriptPageRequest, + ): Promise { + const info = await ensureChannel(); + try { + const response = await withWorkspaceControl(info, () => + withTimeout( + Promise.race([ + info.connection.extMethod( + SERVE_STATUS_EXT_METHODS.sessionTranscript, + { ...req, cwd: boundWorkspace }, + ), + getChannelClosedReject(info), + ]), + Math.max(initTimeoutMs, SESSION_TRANSCRIPT_TIMEOUT_MS), + SERVE_STATUS_EXT_METHODS.sessionTranscript, + ), + ); + return response as unknown as BridgeSessionTranscriptPage; + } catch (err) { + if (isAcpSessionResourceNotFound(err, req.sessionId)) { + throw new SessionNotFoundError(req.sessionId); + } + throw err; + } finally { + if (hasNoChannelWork(info)) { + await startIdleTimer(info, 'session transcript'); + } + } + } + + async function refreshedReplayFieldsFor( + entry: SessionEntry, + historyPageSize: number, + ): Promise> { + for (let attempt = 0; attempt < 2; attempt++) { + try { + const lastEventId = entry.events.lastEventId; + const page = await requestSessionTranscriptPage({ + sessionId: entry.sessionId, + direction: 'backward', + limit: historyPageSize, + }); + if ( + byId.get(entry.sessionId) === entry && + !entry.promptActive && + entry.events.lastEventId === lastEventId + ) { + return { + compactedReplay: page.events, + liveJournal: [], + lastEventId, + ...(page.partial === true ? { partial: true as const } : {}), + ...(page.replayError !== undefined + ? { replayError: page.replayError } + : {}), + ...(page.hasMore ? { historyHasMore: true as const } : {}), + }; + } + } catch { + // A failed bounded read (missing/unreadable persisted transcript or a + // workspace timeout) must not tear down a healthy live session; fall + // back to the in-memory replay instead of surfacing a terminal error. + break; + } + } + return replayFieldsFor(entry, 'load'); + } + async function restoreSession( action: 'load' | 'resume', req: BridgeRestoreSessionRequest, @@ -4023,6 +4093,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { 'The session is closing; retry after close completes', ); } + const replayFields = + action === 'load' && req.historyPageSize !== undefined + ? await refreshedReplayFieldsFor(existing, req.historyPageSize) + : replayFieldsFor(existing, action); + if (byId.get(req.sessionId) !== existing || existing.closing) { + throw new SessionNotFoundError(req.sessionId); + } existing.attachCount++; const clientId = registerClient(existing, req.clientId); recordAttachRef(existing, clientId); @@ -4043,7 +4120,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // caller saw; spawn-only sessions don't carry a state payload. state: existing.restoreState ?? {}, hasActivePrompt: existing.promptActive, - ...replayFieldsFor(existing, action), + ...replayFields, }; } @@ -6484,36 +6561,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }, async getSessionTranscriptPage(req) { - const info = await ensureChannel(); - try { - const response = await withWorkspaceControl(info, () => - withTimeout( - Promise.race([ - info.connection.extMethod( - SERVE_STATUS_EXT_METHODS.sessionTranscript, - { ...req, cwd: boundWorkspace }, - ), - getChannelClosedReject(info), - ]), - Math.max(initTimeoutMs, SESSION_TRANSCRIPT_TIMEOUT_MS), - SERVE_STATUS_EXT_METHODS.sessionTranscript, - ), - ); - return response as unknown as BridgeSessionTranscriptPage; - } catch (err) { - // A missing transcript file (ENOENT without a cursor) surfaces from the - // child as a raw resourceNotFound JSON-RPC error. Translate it to - // SessionNotFoundError so the route maps it to HTTP 404 — mirroring the - // load/resume path above — instead of falling through to a 500. - if (isAcpSessionResourceNotFound(err, req.sessionId)) { - throw new SessionNotFoundError(req.sessionId); - } - throw err; - } finally { - if (hasNoChannelWork(info)) { - await startIdleTimer(info, 'session transcript'); - } - } + return requestSessionTranscriptPage(req); }, async cancelSessionTask(sessionId, taskId, taskKind) { diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 81c3930426c..696c1e77bc7 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -238,6 +238,8 @@ export interface BridgeSessionTranscriptPageRequest { sessionId: string; cursor?: string; beforeRecordId?: string; + /** Internal newest-page read used to refresh an attached session's UI. */ + direction?: 'backward'; limit?: number; } diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index c4a527d56f7..508da501257 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -7730,6 +7730,47 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('flushes the live recording before reading the latest persisted page', async () => { + const innerConfig = await setupSessionMocks(VALID_SESSION_ID); + const recording = innerConfig.getChatRecordingService(); + const readPage = vi.fn().mockResolvedValue({ + sessionId: VALID_SESSION_ID, + records: [], + hasMore: false, + startTime: 'start', + lastUpdated: 'end', + }); + vi.mocked(SessionTranscriptReader).mockImplementation( + () => + ({ + readPage, + }) as unknown as InstanceType, + ); + mockHistoryReplayPage.mockResolvedValue({ pendingToolCalls: [] }); + const { agent, agentPromise } = await bootAcpAgent(); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + const result = await agent.extMethod( + SERVE_STATUS_EXT_METHODS.sessionTranscript, + { + sessionId: VALID_SESSION_ID, + direction: 'backward', + limit: 100, + }, + ); + + expect(recording?.flush).toHaveBeenCalledOnce(); + expect(readPage).toHaveBeenCalledWith(VALID_SESSION_ID, { + direction: 'backward', + limit: 100, + maxBytes: 4 * 1024 * 1024, + }); + expect(result['hasMore']).toBe(false); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('disposes a pending transcript config superseded by newer settings', async () => { const oldSettings = makeCoreSettings('English'); const newSettings = makeCoreSettings('Japanese'); @@ -7904,6 +7945,28 @@ describe('QwenAgent MCP SSE/HTTP support', () => { limit: 1.5, }), ).rejects.toThrow('Invalid transcript limit'); + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionTranscript, { + sessionId: VALID_SESSION_ID, + direction: 'forward', + }), + ).rejects.toThrow('Invalid transcript direction'); + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionTranscript, { + sessionId: VALID_SESSION_ID, + cursor: 'cursor-1', + direction: 'backward', + }), + ).rejects.toThrow('Transcript cursor and direction are mutually exclusive'); + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionTranscript, { + sessionId: VALID_SESSION_ID, + beforeRecordId: 'record-1', + direction: 'backward', + }), + ).rejects.toThrow( + 'Transcript record boundary and direction are mutually exclusive', + ); expect(readPage).not.toHaveBeenCalled(); mockConnectionState.resolve(); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 88467d607ac..0e5421408e7 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -6830,6 +6830,25 @@ class QwenAgent implements Agent { 'Transcript cursor and record boundary are mutually exclusive', ); } + const rawDirection = params['direction']; + if (rawDirection !== undefined && rawDirection !== 'backward') { + throw RequestError.invalidParams( + undefined, + 'Invalid transcript direction', + ); + } + if (rawCursor !== undefined && rawDirection !== undefined) { + throw RequestError.invalidParams( + undefined, + 'Transcript cursor and direction are mutually exclusive', + ); + } + if (rawBeforeRecordId !== undefined && rawDirection !== undefined) { + throw RequestError.invalidParams( + undefined, + 'Transcript record boundary and direction are mutually exclusive', + ); + } const rawLimit = params['limit']; if ( rawLimit !== undefined && @@ -6846,12 +6865,22 @@ class QwenAgent implements Agent { try { const settings = loadSettingsCached(cwd); return await runWithAcpRuntimeOutputDir(settings, cwd, async () => { + if (rawDirection === 'backward') { + await this.sessions + .get(sessionId) + ?.getConfig() + .getChatRecordingService() + ?.flush(); + } const reader = new SessionTranscriptReader(cwd); const page = await reader.readPage(sessionId, { ...(typeof rawCursor === 'string' ? { cursor: rawCursor } : {}), ...(typeof rawBeforeRecordId === 'string' ? { beforeRecordId: rawBeforeRecordId } : {}), + ...(rawDirection === 'backward' + ? { direction: rawDirection } + : {}), ...(typeof rawLimit === 'number' ? { limit: rawLimit } : {}), maxBytes: SESSION_TRANSCRIPT_MAX_PAGE_BYTES, }); diff --git a/packages/core/src/services/session-transcript-reader.test.ts b/packages/core/src/services/session-transcript-reader.test.ts index f2bfcd6e3b3..e0435446d49 100644 --- a/packages/core/src/services/session-transcript-reader.test.ts +++ b/packages/core/src/services/session-transcript-reader.test.ts @@ -306,6 +306,29 @@ describe('SessionTranscriptReader', () => { expect(second.hasMore).toBe(false); }); + it('starts backward paging at the persisted tail', async () => { + await writeRecords([ + record('u1', null, 'first prompt'), + record('a1', 'u1', 'first answer'), + record('u2', 'a1', 'second prompt'), + record('a2', 'u2', 'second answer'), + ]); + + const reader = new SessionTranscriptReader(workspaceDir); + const page = await reader.readPage(sessionId, { + direction: 'backward', + limit: 2, + }); + + expect(page.records.map((item) => item.uuid)).toEqual(['u2', 'a2']); + expect(page.direction).toBe('backward'); + expect(page.hasMore).toBe(true); + expect(page.nextCursorState).toMatchObject({ + position: 2, + direction: 'backward', + }); + }); + it('keeps backward pages within a normal user turn boundary', async () => { const toolCall = record('a-tool', 'u1', 'call tool'); const toolResult = { diff --git a/packages/core/src/services/session-transcript-reader.ts b/packages/core/src/services/session-transcript-reader.ts index 2e87c93c326..14a0e3673be 100644 --- a/packages/core/src/services/session-transcript-reader.ts +++ b/packages/core/src/services/session-transcript-reader.ts @@ -85,6 +85,8 @@ export interface SessionTranscriptReadPageOptions { cursor?: string; /** Start a newest-to-oldest snapshot immediately before this active record. */ beforeRecordId?: string; + /** Start at the persisted tail and page newest-to-oldest. */ + direction?: 'backward'; limit?: number; maxBytes?: number; } @@ -976,7 +978,10 @@ export class SessionTranscriptReader { ? (this.cursorCodec?.decode(options.cursor) ?? decodeSessionTranscriptCursor(options.cursor, this.workspaceCwd)) : undefined; - if (cursor && options.beforeRecordId !== undefined) { + if ( + cursor && + (options.beforeRecordId !== undefined || options.direction !== undefined) + ) { throw new InvalidSessionTranscriptCursorError(); } if (cursor && cursor.sessionId !== sessionId) { @@ -1020,8 +1025,11 @@ export class SessionTranscriptReader { const direction = cursor?.direction ?? + options.direction ?? (options.beforeRecordId !== undefined ? 'backward' : 'forward'); - let position = cursor?.position ?? 0; + let position = + cursor?.position ?? + (direction === 'backward' ? index.activeUuids.length : 0); if (!cursor && options.beforeRecordId !== undefined) { if (options.beforeRecordId.length === 0) { throw new InvalidSessionTranscriptCursorError(); diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index ca3fabbaf53..3446b3865db 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -575,8 +575,8 @@ function createBase( * * 1. `event.serverTimestamp` — top-level, preferred when daemon adds it * 2. `event._meta.serverTimestamp` — Anthropic-style metadata convention - * 3. `event.data._meta.serverTimestamp` — sessionUpdate nested location - * 4. `event.data.update._meta.serverTimestamp|timestamp` — ACP update meta + * 3. nested `serverTimestamp` metadata + * 4. `timestamp` on direct transcript-page or nested ACP updates * * Returns undefined when none of them are present or all are non-finite. * Forward-compat: SDK reads whichever location the daemon eventually emits @@ -591,27 +591,46 @@ export function extractServerTimestamp(event: DaemonEvent): number | undefined { if (typeof ts === 'number' && Number.isFinite(ts)) return ts; } if (isRecord(event.data)) { - const dataMeta = (event.data as Record)['_meta']; + const dataMeta = event.data['_meta']; + const update = event.data['update']; + const updateMeta = isRecord(update) ? update['_meta'] : undefined; if (isRecord(dataMeta)) { const ts = dataMeta['serverTimestamp']; if (typeof ts === 'number' && Number.isFinite(ts)) return ts; } - const update = (event.data as Record)['update']; - if (isRecord(update)) { - const updateMeta = update['_meta']; - if (isRecord(updateMeta)) { - const serverTs = updateMeta['serverTimestamp']; - if (typeof serverTs === 'number' && Number.isFinite(serverTs)) { - return serverTs; - } - const ts = updateMeta['timestamp']; - if (typeof ts === 'number' && Number.isFinite(ts)) return ts; + if (isRecord(updateMeta)) { + const serverTs = updateMeta['serverTimestamp']; + if (typeof serverTs === 'number' && Number.isFinite(serverTs)) { + return serverTs; } } + const timestampCandidates = [ + isRecord(updateMeta) ? updateMeta['timestamp'] : undefined, + isRecord(update) ? update['timestamp'] : undefined, + isRecord(dataMeta) ? dataMeta['timestamp'] : undefined, + event.data['timestamp'], + ]; + for (const candidate of timestampCandidates) { + const timestamp = parseTimestamp(candidate); + if (timestamp !== undefined) return timestamp; + } } return undefined; } +function parseTimestamp(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return undefined; + // Date.parse misreads bare-integer strings ("2000" becomes year 2000 and a + // stringified epoch becomes NaN), so treat all-digit strings as epoch ms. + if (/^\d+$/.test(value)) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : undefined; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + function normalizeSessionUpdate( event: DaemonEvent, base: NormalizedEventBase, diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index e32583777e1..79723d156d5 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -3011,6 +3011,46 @@ describe('daemon UI time schema (PR-B)', () => { }); }); + it('prefers the nested ACP update timestamp over envelope fallbacks', () => { + const events = normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'session_update', + data: { + timestamp: 2_000, + _meta: { timestamp: 3_000 }, + update: { + timestamp: 4_000, + _meta: { timestamp: 1_000 }, + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'hello' }, + }, + }, + } as never); + + expect(events[0]).toMatchObject({ serverTimestamp: 1_000 }); + }); + + it.each([1_780_905_333_596, '1780905333596', '2026-06-08T07:55:33.596Z'])( + 'extracts transcript-page timestamp %s', + (timestamp) => { + const events = normalizeDaemonEvent({ + v: 1, + type: 'session_update', + data: { + timestamp, + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'hello' }, + }, + } as never); + + expect(events[0]).toMatchObject({ + type: 'user.text.delta', + serverTimestamp: 1_780_905_333_596, + }); + }, + ); + it('backfills serverTimestamp onto an existing text block', () => { let state = createDaemonTranscriptState({ now: 1 }); state = reduceDaemonTranscriptEvents( diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 04a40c22c5d..e151681e1e9 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -120,6 +120,7 @@ const { getStats: vi.fn().mockResolvedValue({}), loadArtifacts: vi.fn().mockResolvedValue({ artifacts: [] }), loadSession: vi.fn().mockResolvedValue(undefined), + reloadSession: vi.fn().mockResolvedValue(undefined), }, mockWorkspace: { capabilities: { @@ -233,6 +234,7 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ loading: false, capacityReached: false, loadMore: vi.fn(), + release: vi.fn(), }), useTranscriptStore: () => mockStore, useWorkspace: () => mockWorkspace, @@ -1013,6 +1015,7 @@ beforeEach(() => { mockSessionActions.clearSession.mockResolvedValue(undefined); mockSessionActions.releaseSession.mockResolvedValue(undefined); mockSessionActions.loadSession.mockResolvedValue(undefined); + mockSessionActions.reloadSession.mockResolvedValue(undefined); mockSessionActions.refreshCommands.mockResolvedValue(undefined); mockSessionActions.setModel.mockResolvedValue(undefined); mockSessionActions.setApprovalMode.mockResolvedValue(undefined); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 01ccbfe03a3..19da67e677f 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -40,6 +40,7 @@ import type { DaemonWorkspaceGitStatus, } from '@qwen-code/sdk/daemon'; import { GitForkIcon, XIcon } from 'lucide-react'; +import { SESSION_TRANSCRIPT_PAGINATION_FEATURE } from './constants/sessions'; import { extractPendingPermission } from './adapters/transcriptAdapter'; import { MessageList, type MessageListHandle } from './components/MessageList'; import { SubagentDetailsProvider } from './subagentDetailsContext'; @@ -641,6 +642,7 @@ interface AppProps extends WebShellProps { lockedWorkspaceCwd?: string; lockedWorkspaceCapability?: DaemonWorkspaceCapability; restartSseOnPrompt?: boolean; + historyPageSize?: number; } type SessionActionsWithCreate = { @@ -1090,6 +1092,7 @@ export function App({ onSessionChange, onSubmitBefore, restartSseOnPrompt, + historyPageSize, lockedWorkspaceCwd, lockedWorkspaceCapability, }: AppProps = {}) { @@ -1318,6 +1321,17 @@ export function App({ [lockedWorkspaceCwd, workspaces], ); const sessionActions = useActions(); + const reloadTranscript = useCallback( + async (signal: AbortSignal) => { + if (!connection.sessionId) return; + await sessionActions.reloadSession(signal); + }, + [connection.sessionId, sessionActions], + ); + const transcriptReloadSupported = + connection.capabilities?.features.includes( + SESSION_TRANSCRIPT_PAGINATION_FEATURE, + ) === true; const { notices, dismissNotice } = useSessionNotices(); const workspaceActions = useWorkspaceActions(); const dynamicWorkspaceRegistrationSupported = @@ -7416,6 +7430,7 @@ export function App({ onPaneArtifactsChange={handlePaneArtifactsChange} messageTurnOutputs={messageTurnOutputs} restartSseOnPrompt={restartSseOnPrompt} + historyPageSize={historyPageSize} /> @@ -7502,6 +7517,13 @@ export function App({ transcriptHistory.capacityReached } onLoadOlderHistory={transcriptHistory.loadMore} + transcriptBlockCount={blocks.length} + transcriptActivity={store} + onReloadTranscript={ + transcriptReloadSupported + ? reloadTranscript + : undefined + } isResponding={streamingState !== 'idle'} activeTurnStartedAt={activeTurnStartedAt} workspaceCwd={connection.workspaceCwd || ''} diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index d65def17d21..20da69f543d 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -73,6 +73,7 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ loading: false, capacityReached: false, loadMore: vi.fn(), + release: vi.fn(), }), useTranscriptStore: () => ({ dispatch: transcriptDispatch, diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index 208961711a9..5af7e059881 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -22,6 +22,7 @@ import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon'; import type { ACPToolCall } from '../adapters/types'; import { SubagentDetailsProvider } from '../subagentDetailsContext'; import { useI18n } from '../i18n'; +import { SESSION_TRANSCRIPT_PAGINATION_FEATURE } from '../constants/sessions'; import { useMessages } from '../hooks/useMessages'; import { useSessionArtifacts } from '../hooks/useSessionArtifacts'; import { extractPendingPermission } from '../adapters/transcriptAdapter'; @@ -189,6 +190,17 @@ export function ChatPane({ ]); const streamingStateRef = useRef(streamingState); streamingStateRef.current = streamingState; + const reloadTranscript = useCallback( + async (signal: AbortSignal) => { + if (!connection.sessionId) return; + await actions.reloadSession(signal); + }, + [actions, connection.sessionId], + ); + const transcriptReloadSupported = + connection.capabilities?.features.includes( + SESSION_TRANSCRIPT_PAGINATION_FEATURE, + ) === true; const editorRef = useRef(null); const { followupState, @@ -579,6 +591,11 @@ export function ChatPane({ loadingOlderHistory={transcriptHistory.loading} historyCapacityReached={transcriptHistory.capacityReached} onLoadOlderHistory={transcriptHistory.loadMore} + transcriptBlockCount={blocks.length} + transcriptActivity={store} + onReloadTranscript={ + transcriptReloadSupported ? reloadTranscript : undefined + } isResponding={isResponding} workspaceCwd={connection.workspaceCwd || ''} hideSessionTimeline diff --git a/packages/web-shell/client/components/MessageList.dom.test.tsx b/packages/web-shell/client/components/MessageList.dom.test.tsx index ceb7565223c..cfaf92627f7 100644 --- a/packages/web-shell/client/components/MessageList.dom.test.tsx +++ b/packages/web-shell/client/components/MessageList.dom.test.tsx @@ -9,6 +9,7 @@ import { type WebShellCustomization, } from '../customization'; import { I18nProvider } from '../i18n'; +import { WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS } from '../constants/sessions'; import flashStyles from './MessageLocateFlash.module.css'; import styles from './MessageList.module.css'; @@ -203,6 +204,15 @@ function mount( loadingOlderHistory?: boolean; historyCapacityReached?: boolean; onLoadOlderHistory?: () => Promise; + transcriptBlockCount?: number; + transcriptActivity?: { + getSnapshot(): { + lastEventId?: number; + blocks?: { readonly length: number }; + }; + subscribe(listener: () => void): () => void; + }; + onReloadTranscript?: (signal: AbortSignal) => Promise; isResponding?: boolean; hideFirstUserMessage?: boolean; firstTurnMetrics?: { @@ -234,6 +244,9 @@ function mount( loadingOlderHistory={opts.loadingOlderHistory} historyCapacityReached={opts.historyCapacityReached} onLoadOlderHistory={opts.onLoadOlderHistory} + transcriptBlockCount={opts.transcriptBlockCount} + transcriptActivity={opts.transcriptActivity} + onReloadTranscript={opts.onReloadTranscript} isResponding={opts.isResponding} hideFirstUserMessage={opts.hideFirstUserMessage} firstTurnMetrics={opts.firstTurnMetrics} @@ -285,9 +298,7 @@ const assistantActions = (c: HTMLElement, id: string) => .querySelector(`[data-testid="msg-${id}"]`) ?.getAttribute('data-assistant-actions'); const isCollapsed = (c: HTMLElement, id: string) => - c - .querySelector(`[data-testid="msg-${id}"]`) - ?.closest('[data-collapsed="true"]') !== null; + c.querySelector(`[data-testid="msg-${id}"]`) === null; const queryToggle = (c: HTMLElement, turnId: string) => c.querySelector(`[data-testid="toggle-${turnId}"]`) as HTMLElement | null; const toggle = (c: HTMLElement, turnId: string) => @@ -326,6 +337,83 @@ const simpleTurns = (count: number): Message[] => }).flat(); describe('MessageList — turn collapse (DOM)', () => { + it('reloads an oversized transcript after 120 quiet seconds at the tail', async () => { + vi.useFakeTimers(); + const onReloadTranscript = vi.fn().mockResolvedValue(undefined); + let lastEventId = 10; + let notifyActivity = () => undefined; + mount([userMsg('u1'), asstMsg('a1')], undefined, { + transcriptBlockCount: WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS + 1, + transcriptActivity: { + getSnapshot: () => ({ lastEventId }), + subscribe: (listener) => { + notifyActivity = listener; + return () => undefined; + }, + }, + onReloadTranscript, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + lastEventId++; + notifyActivity(); + await vi.advanceTimersByTimeAsync(60_000); + }); + expect(onReloadTranscript).not.toHaveBeenCalled(); + + await act(async () => vi.advanceTimersByTimeAsync(60_000)); + + expect(onReloadTranscript).toHaveBeenCalledOnce(); + + await act(async () => vi.advanceTimersByTimeAsync(120_000)); + expect(onReloadTranscript).toHaveBeenCalledOnce(); + + lastEventId++; + notifyActivity(); + await act(async () => vi.advanceTimersByTimeAsync(120_000)); + expect(onReloadTranscript).toHaveBeenCalledTimes(2); + }); + + it('aborts an in-flight transcript reload when the reader leaves the tail', async () => { + vi.useFakeTimers(); + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + Object.defineProperty(HTMLElement.prototype, 'scrollTop', { + configurable: true, + value: 600, + writable: true, + }); + let resolveReload = () => undefined; + let reloadSignal: AbortSignal | undefined; + const onReloadTranscript = vi.fn((signal: AbortSignal) => { + reloadSignal = signal; + return new Promise((resolve) => { + resolveReload = resolve; + }); + }); + const container = mount([userMsg('u1'), asstMsg('a1')], undefined, { + transcriptBlockCount: WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS + 1, + onReloadTranscript, + }); + + await act(async () => vi.advanceTimersByTimeAsync(120_000)); + expect(reloadSignal?.aborted).toBe(false); + + const list = container.firstElementChild as HTMLElement; + list.scrollTop = 400; + act(() => list.dispatchEvent(new Event('scroll', { bubbles: true }))); + expect(reloadSignal?.aborted).toBe(true); + + await act(async () => resolveReload()); + }); + it('hides only the first user message and overrides first-turn metrics', () => { const c = mount( [ @@ -1449,6 +1537,37 @@ describe('MessageList — turn collapse (DOM)', () => { expect(c.querySelector('button')).toBeNull(); }); + it('suppresses the loading status during automatic pagination', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 300, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + let resolveLoad!: () => void; + const onLoadOlderHistory = vi.fn( + () => new Promise((resolve) => (resolveLoad = resolve)), + ); + + const c = mount([userMsg('u1')], undefined, { + hasOlderHistory: true, + onLoadOlderHistory, + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + expect(c.querySelector('[role="status"]')).toBeNull(); + + await act(async () => { + resolveLoad(); + await Promise.resolve(); + }); + }); + it('shows when the history display limit is reached', () => { const c = mount([userMsg('u1')], undefined, { historyCapacityReached: true, @@ -1664,7 +1783,7 @@ describe('MessageList — turn collapse (DOM)', () => { asstMsg('a1'), ]); - expect(assistantActions(c, 'mid')).toBe('false'); + expect(has(c, 'mid')).toBe(false); expect(assistantActions(c, 'a1')).toBe('true'); }); diff --git a/packages/web-shell/client/components/MessageList.module.css b/packages/web-shell/client/components/MessageList.module.css index 01a8a884e3e..4a3519f0f4f 100644 --- a/packages/web-shell/client/components/MessageList.module.css +++ b/packages/web-shell/client/components/MessageList.module.css @@ -178,23 +178,6 @@ min-width: 0; } -.turnContentClip { - display: grid; - grid-template-rows: 1fr; - min-height: 0; - overflow: hidden; - transition: grid-template-rows 180ms ease; -} - -.turnContentCollapsed { - grid-template-rows: 0fr; -} - -.turnContentInner { - min-height: 0; - overflow: hidden; -} - .sessionTimelineLayer { position: sticky; top: 50vh; diff --git a/packages/web-shell/client/components/MessageList.test.ts b/packages/web-shell/client/components/MessageList.test.ts index db216d46e5a..8762b6117a7 100644 --- a/packages/web-shell/client/components/MessageList.test.ts +++ b/packages/web-shell/client/components/MessageList.test.ts @@ -909,17 +909,9 @@ function collapseItems( } function rowIds(items: DisplayItem[]): string[] { - return items.flatMap((item) => { - if (item.type === 'turn_content' && item.collapsed) return []; - return item.type === 'message' ? item.message.id : item.key; - }); -} - -function flattenedRowIds(items: DisplayItem[]): string[] { - return items.flatMap((item) => { - if (item.type === 'turn_content') return flattenedRowIds(item.items); - return item.type === 'message' ? item.message.id : item.key; - }); + return items.map((item) => + item.type === 'message' ? item.message.id : item.key, + ); } describe('applyTurnCollapse', () => { @@ -966,8 +958,7 @@ describe('applyTurnCollapse', () => { const out = collapseItems(items, { overrides: new Map([['u1', true]]), }); - expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'u1-content-0', 'a1']); - expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'a1']); + expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'a1']); expect(collapseOf(out, 0)).toEqual({ turnId: 'u1', collapsed: false, @@ -990,8 +981,7 @@ describe('applyTurnCollapse', () => { isResponding: true, overrides: new Map([['u1', true]]), }); - expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'u1-content-0']); - expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'a0', 'g1']); + expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'a0', 'g1']); }); it('tags but keeps the active turn expanded while responding', () => { @@ -1004,8 +994,7 @@ describe('applyTurnCollapse', () => { // Every row stays visible; the head carries the seam but is not collapsed. // The streamed answer is provisional (not a step), so only the tool group // counts — a step-less reply stays step-less rather than flashing "1 step". - expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'u1-content-0', 'a1']); - expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'a1']); + expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'a1']); expect(collapseOf(out, 0)?.collapsed).toBe(false); expect(collapseOf(out, 0)?.hiddenCount).toBe(1); }); @@ -1043,7 +1032,7 @@ describe('applyTurnCollapse', () => { expect(collapseOf(out, 0)?.collapsed).toBe(true); }); - it('keeps collapsed content mounted but hidden', () => { + it('unmounts collapsed content', () => { const items = groupParallelAgents([ makeUserMessage('u1'), makeMultiToolGroup('g1'), @@ -1055,12 +1044,6 @@ describe('applyTurnCollapse', () => { expect(collapseOf(out, 0)?.collapsed).toBe(true); expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'a1']); - expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'a1']); - const hidden = out[2]; - expect(hidden?.type).toBe('turn_content'); - if (hidden?.type === 'turn_content') { - expect(hidden.collapsed).toBe(true); - } }); it('keeps a step-less reply step-less while it streams', () => { @@ -1147,23 +1130,7 @@ describe('applyTurnCollapse', () => { makeMultiToolGroup('g2'), ]); const out = collapseItems(items, { isResponding: true }); - expect(rowIds(out)).toEqual([ - 'u1', - 'tc-u1', - 'a1', - 'u2', - 'tc-u2', - 'u2-content-0', - ]); - expect(flattenedRowIds(out)).toEqual([ - 'u1', - 'tc-u1', - 'g1', - 'a1', - 'u2', - 'tc-u2', - 'g2', - ]); + expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'a1', 'u2', 'tc-u2', 'g2']); expect(collapseOf(out, 0)?.collapsed).toBe(true); expect(collapseOf(out, 'u2')?.collapsed).toBe(false); }); @@ -1187,8 +1154,7 @@ describe('applyTurnCollapse', () => { ]); const out = collapseItems(items, { isResponding: true }); // Active turn stays fully expanded, yet the seam carries live metrics. - expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'u1-content-0', 'a1']); - expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'a1']); + expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'a1']); const head = collapseOf(out, 0); expect(head?.collapsed).toBe(false); expect(head?.elapsedMs).toBe(2_500); @@ -1213,8 +1179,7 @@ describe('applyTurnCollapse', () => { makeMultiToolGroup('g2'), ]); const out = collapseItems(items); - expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'u1-content-0']); - expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'g2']); + expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'g2']); expect(collapseOf(out, 0)).toEqual({ turnId: 'u1', collapsed: false, @@ -1249,8 +1214,7 @@ describe('applyTurnCollapse', () => { }, ]); const out = collapseItems(items); - expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'u1-content-0']); - expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 's1']); + expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 's1']); expect(collapseOf(out, 0)).toEqual({ turnId: 'u1', collapsed: false, @@ -1273,14 +1237,7 @@ describe('applyTurnCollapse', () => { }, ]); const out = collapseItems(items); - expect(rowIds(out)).toEqual([ - 'u1', - 'tc-u1', - 'u1-content-0', - 'a1', - 'u1-content-1', - ]); - expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'a1', 's1']); + expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'a1', 's1']); expect(collapseOf(out, 0)).toEqual({ turnId: 'u1', collapsed: false, @@ -1308,7 +1265,7 @@ describe('applyTurnCollapse', () => { const expanded = collapseItems(items, { overrides: new Map([['u1', true]]), }); - expect(rowIds(expanded)).toEqual(['u1', 'tc-u1', 'u1-content-0', 'a1']); + expect(rowIds(expanded)).toEqual(['u1', 'tc-u1', 'g1', 't1', 'a1']); }); it('passes through rows that precede the first turn', () => { @@ -1409,8 +1366,7 @@ describe('applyTurnCollapse', () => { ]); const out = collapseItems(items); // No assistant-with-content → no final answer → stays expanded. - expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'u1-content-0']); - expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'x']); + expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'x']); expect(collapseOf(out, 0)?.hiddenCount).toBe(2); }); diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index 7e37d57a3b4..2184e5cf0e7 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -9,6 +9,7 @@ import { useCallback, useMemo, useState, + useTransition, type CSSProperties, type ReactNode, type FocusEvent as ReactFocusEvent, @@ -46,8 +47,11 @@ import { toolContainsCallId } from './messages/toolFormatting'; import turnCollapseStyles from './TurnCollapseRow.module.css'; import flashStyles from './MessageLocateFlash.module.css'; import styles from './MessageList.module.css'; +import { WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS } from '../constants/sessions'; const noopTurnOutputAction = () => undefined; +const RELOAD_TRANSCRIPT_DELAY_MS = 120_000; +const TURN_LAYOUT_ANIMATION_MS = 180; interface MessageListProps { messages: Message[]; @@ -60,6 +64,15 @@ interface MessageListProps { loadingOlderHistory?: boolean; historyCapacityReached?: boolean; onLoadOlderHistory?: () => Promise; + transcriptBlockCount?: number; + transcriptActivity?: { + getSnapshot(): { + lastEventId?: number; + blocks?: { readonly length: number }; + }; + subscribe(listener: () => void): () => void; + }; + onReloadTranscript?: (signal: AbortSignal) => Promise; /** * True while the agent is still answering. The newest turn then stays * expanded and un-collapsible so streaming output is never hidden. @@ -143,13 +156,6 @@ export type DisplayItem = key: string; turnCollapse: TurnCollapseHead; } - | { - type: 'turn_content'; - key: string; - turnId: string; - collapsed: boolean; - items: DisplayItem[]; - } | { type: 'parallel_agents'; key: string; @@ -397,7 +403,6 @@ export function getDisplayItemVirtualKey(item: DisplayItem): string { ? `tc:${item.key}` : `tc:${item.key}:${liveKey}`; } - if (item.type === 'turn_content') return `turn-content:${item.key}`; return `msg:${item.key}`; } @@ -552,9 +557,6 @@ function isHideableStep(item: DisplayItem, isFinalAnswer: boolean): boolean { if (item.type === 'parallel_agents') return true; if (item.type === 'turn_outputs') return false; if (item.type === 'turn_collapse') return false; - if (item.type === 'turn_content') { - return item.items.some((child) => isHideableStep(child, isFinalAnswer)); - } switch (item.message.role) { case 'tool_group': case 'plan': @@ -1014,7 +1016,6 @@ function isExecutionWorkStep(item: DisplayItem): boolean { if (item.type === 'parallel_agents') return true; if (item.type === 'turn_outputs') return false; if (item.type === 'turn_collapse') return false; - if (item.type === 'turn_content') return item.items.some(isExecutionWorkStep); return item.message.role === 'tool_group' || item.message.role === 'plan'; } @@ -1025,14 +1026,6 @@ function isActiveToolStatus(status: ACPToolCall['status'] | string): boolean { } function activeExecutionKey(item: DisplayItem): string | null { - if (item.type === 'turn_content') { - for (let i = item.items.length - 1; i >= 0; i--) { - const key = activeExecutionKey(item.items[i]!); - if (key) return key; - } - return null; - } - if (item.type === 'turn_outputs') return null; if (item.type === 'turn_collapse') { @@ -1157,9 +1150,6 @@ function itemSubagentUsages( if (item.type === 'parallel_agents') { return item.agents.flatMap((agent) => subagentUsage(agent) ?? []); } - if (item.type === 'turn_content') { - return item.items.flatMap(itemSubagentUsages); - } if (item.type !== 'message' || item.message.role !== 'tool_group') return []; return item.message.tools.flatMap((tool) => subagentUsage(tool) ?? []); } @@ -1168,9 +1158,6 @@ function itemToolCallCount(item: DisplayItem): number { if (item.type === 'parallel_agents') return item.agents.length; if (item.type === 'turn_outputs') return 0; if (item.type === 'turn_collapse') return 0; - if (item.type === 'turn_content') { - return item.items.reduce((sum, child) => sum + itemToolCallCount(child), 0); - } return item.message.role === 'tool_group' ? item.message.tools.length : 0; } @@ -1436,18 +1423,6 @@ export function applyTurnCollapse( ? (overrides.get(turnId) as boolean) : shouldStayOpen; const collapsed = !expanded; - let turnContentGroupIndex = 0; - const pushTurnContentGroup = (groupItems: DisplayItem[]) => { - if (groupItems.length === 0) return; - result.push({ - type: 'turn_content', - key: `${turnId}-content-${turnContentGroupIndex++}`, - turnId, - collapsed, - items: groupItems, - }); - }; - // Push the user message result.push({ type: 'message', @@ -1475,7 +1450,6 @@ export function applyTurnCollapse( }); if (!collapsed) { - let turnContentItems: DisplayItem[] = []; for (let i = start + 1; i <= end; i++) { const item = items[i]!; // Attach turnCollapse to final answer for metrics display @@ -1484,27 +1458,21 @@ export function applyTurnCollapse( item.type === 'message' && item.message.role === 'assistant' ) { - pushTurnContentGroup(turnContentItems); - turnContentItems = []; result.push({ ...item, turnCollapse: turnCollapseInfo, }); } else { - turnContentItems.push(item); + result.push(item); } } - pushTurnContentGroup(turnContentItems); continue; } - // Collapsed: keep hideable steps mounted in a zero-height content group so - // the fold animation can run. Keep the final answer and non-step rows - // (errors, cancellations, command output) in their original places. On an - // active turn the "answer" is still streaming, so fold it away too rather - // than strand a provisional line. - const collapsedContentItems: DisplayItem[] = []; - const visibleCollapsedItems: DisplayItem[] = []; + // Collapsed: omit hideable rows so their DOM and layout work disappear. + // Keep the final answer and non-step rows (errors, cancellations, command + // output) in their original places. Expanded rows remain individual + // virtualizer entries instead of one oversized turn wrapper. for (let i = start + 1; i <= end; i++) { const item = items[i]; if (i === answerIdx && isActiveTurn) continue; @@ -1513,20 +1481,14 @@ export function applyTurnCollapse( item.type === 'message' && item.message.role === 'assistant' ) { - visibleCollapsedItems.push({ + result.push({ ...item, turnCollapse: turnCollapseInfo, }); continue; } - if (isHideableStep(item, i === answerIdx)) { - collapsedContentItems.push(item); - continue; - } - visibleCollapsedItems.push(item); + if (!isHideableStep(item, i === answerIdx)) result.push(item); } - pushTurnContentGroup(collapsedContentItems); - result.push(...visibleCollapsedItems); } return result; @@ -1559,11 +1521,6 @@ export function findDisplayItemIndex( item.agents.some((agent) => toolContainsCallId(agent, callId)) ) { return i; - } else if ( - item.type === 'turn_content' && - findDisplayItemIndex(item.items, messageId, callId) >= 0 - ) { - return i; } else if (item.type === 'turn_outputs') { continue; } @@ -1590,11 +1547,6 @@ function displayItemMatchesLocateTarget( !!callId && item.agents.some((agent) => toolContainsCallId(agent, callId)) ); } - if (item.type === 'turn_content') { - return item.items.some((child) => - displayItemMatchesLocateTarget(child, target), - ); - } if (item.type === 'turn_outputs') return false; return false; } @@ -1826,33 +1778,11 @@ const TurnCollapseRow = memo(function TurnCollapseRow({ function getChatRowClassName(item: DisplayItem): string | undefined { if (item.type === 'turn_collapse') return styles.turnStatusRow; if (item.type === 'turn_outputs') return styles.turnContentRow; - if (item.type === 'turn_content') { - return styles.turnContentRow; - } if (item.type !== 'message') return undefined; if (item.turnCollapse) return styles.turnAnswerRow; return undefined; } -const TurnContent = memo(function TurnContent({ - collapsed, - children, -}: { - collapsed: boolean; - children: ReactNode; -}) { - const className = joinClassNames( - styles.turnContentClip, - collapsed ? styles.turnContentCollapsed : undefined, - ); - - return ( -
-
{children}
-
- ); -}); - const SESSION_TIMELINE_KIND_LABEL: Record = { thought: 'thinking', commentary: 'assistant update', @@ -2255,6 +2185,9 @@ export const MessageList = memo( loadingOlderHistory = false, historyCapacityReached = false, onLoadOlderHistory, + transcriptBlockCount = 0, + transcriptActivity, + onReloadTranscript, isResponding = false, activeTurnStartedAt, welcomeHeader, @@ -2389,6 +2322,11 @@ export const MessageList = memo( const [collapseOverrides, setCollapseOverrides] = useState< ReadonlyMap >(() => new Map()); + const [turnLayoutPending, startTurnLayoutTransition] = useTransition(); + const turnLayoutTransitionStarted = useRef(false); + const turnLayoutRowTops = useRef(new Map()); + const turnLayoutAnimationTimer = useRef(undefined); + const turnLayoutAnimations = useRef([]); const shouldFollow = useRef(true); const followPausedByUserRef = useRef(false); const userScrollIntentUntil = useRef(0); @@ -2415,11 +2353,25 @@ export const MessageList = memo( const prevHasTailContent = useRef(false); const pendingFollowRecheck = useRef(false); const pendingFollowRecheckFrame = useRef(undefined); - const pendingFollowRecheckTimer = useRef(undefined); const pendingOverflowFrame = useRef(undefined); catchingUpRef.current = catchingUp; const containerRef = useRef(null); const olderHistoryRetryBlocked = useRef(false); + const reloadTranscriptTimer = useRef(undefined); + const reloadTranscriptAbort = useRef( + undefined, + ); + const transcriptReloadBaseline = useRef< + | { + lastEventId?: number; + blockCount: number; + } + | undefined + >(undefined); + const transcriptBlockCountRef = useRef(transcriptBlockCount); + const isRespondingRef = useRef(isResponding); + transcriptBlockCountRef.current = transcriptBlockCount; + isRespondingRef.current = isResponding; const lastUnderfillAutoLoad = useRef<{ loader: typeof onLoadOlderHistory; totalVirtualSize: number; @@ -2428,6 +2380,10 @@ export const MessageList = memo( scrollHeight: number; scrollTop: number; } | null>(null); + const [ + suppressOlderHistoryLoadingStatus, + setSuppressOlderHistoryLoadingStatus, + ] = useState(false); useLayoutEffect(() => { if (!olderHistoryAnchor) return; @@ -2617,8 +2573,10 @@ export const MessageList = memo( const headerOffset = hasHeader ? 1 : 0; const tailContentIndex = headerOffset + visibleItems.length; const totalCount = tailContentIndex + (hasTailContent ? 1 : 0); + const uncollapsedTotalCount = + headerOffset + displayItems.length + (hasTailContent ? 1 : 0); const useVirtualScroll = shouldUseVirtualScroll( - totalCount, + uncollapsedTotalCount, virtualScrollThreshold, ); const getScrollElement = useCallback((): HTMLElement | null => { @@ -2640,26 +2598,108 @@ export const MessageList = memo( userScrollIntentUntil.current = Date.now() + 1000; }, []); + const cancelTranscriptReloadTimer = useCallback(() => { + if (reloadTranscriptTimer.current !== undefined) { + window.clearTimeout(reloadTranscriptTimer.current); + reloadTranscriptTimer.current = undefined; + } + }, []); + + const cancelTranscriptReload = useCallback(() => { + cancelTranscriptReloadTimer(); + reloadTranscriptAbort.current?.abort(); + reloadTranscriptAbort.current = undefined; + }, [cancelTranscriptReloadTimer]); + + const scheduleTranscriptReload = useCallback(() => { + cancelTranscriptReloadTimer(); + const baseline = transcriptReloadBaseline.current; + if (baseline) { + const lastEventId = transcriptActivity?.getSnapshot().lastEventId; + if ( + lastEventId === baseline.lastEventId && + transcriptBlockCountRef.current <= baseline.blockCount + ) { + return; + } + transcriptReloadBaseline.current = undefined; + } + if ( + !onReloadTranscript || + reloadTranscriptAbort.current !== undefined || + followPausedByUserRef.current || + isRespondingRef.current || + transcriptBlockCountRef.current <= WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS + ) { + return; + } + reloadTranscriptTimer.current = window.setTimeout(() => { + reloadTranscriptTimer.current = undefined; + const el = containerRef.current; + if (!el) return; + const distanceFromBottom = + el.scrollHeight - el.scrollTop - el.clientHeight; + if (distanceFromBottom >= FOLLOW_BOTTOM_THRESHOLD_PX) return; + const controller = new AbortController(); + reloadTranscriptAbort.current = controller; + void onReloadTranscript(controller.signal) + .then(() => { + if (controller.signal.aborted) return; + const snapshot = transcriptActivity?.getSnapshot(); + transcriptReloadBaseline.current = { + ...(snapshot?.lastEventId !== undefined + ? { lastEventId: snapshot.lastEventId } + : {}), + blockCount: + snapshot?.blocks?.length ?? transcriptBlockCountRef.current, + }; + }) + .catch((error: unknown) => { + if (!(error instanceof Error && error.name === 'AbortError')) { + console.warn('[MessageList] transcript reload failed:', error); + } + }) + .finally(() => { + if (reloadTranscriptAbort.current === controller) { + reloadTranscriptAbort.current = undefined; + } + }); + }, RELOAD_TRANSCRIPT_DELAY_MS); + }, [cancelTranscriptReloadTimer, onReloadTranscript, transcriptActivity]); + + useEffect(() => { + transcriptReloadBaseline.current = undefined; + }, [transcriptActivity]); + + useEffect(() => { + if (!transcriptActivity) return cancelTranscriptReload; + let lastEventId = transcriptActivity.getSnapshot().lastEventId; + const unsubscribe = transcriptActivity.subscribe(() => { + const nextLastEventId = transcriptActivity.getSnapshot().lastEventId; + if (nextLastEventId === lastEventId) return; + lastEventId = nextLastEventId; + scheduleTranscriptReload(); + }); + return () => { + unsubscribe(); + cancelTranscriptReload(); + }; + }, [transcriptActivity, scheduleTranscriptReload, cancelTranscriptReload]); + + useEffect(() => { + scheduleTranscriptReload(); + }, [isResponding, scheduleTranscriptReload, transcriptBlockCount]); + const scheduleFollowRecheck = useCallback(() => { pendingFollowRecheck.current = true; if (pendingFollowRecheckFrame.current !== undefined) { window.cancelAnimationFrame(pendingFollowRecheckFrame.current); } - if (pendingFollowRecheckTimer.current !== undefined) { - window.clearTimeout(pendingFollowRecheckTimer.current); - } - pendingFollowRecheckFrame.current = window.requestAnimationFrame( - recheckFollowFromScrollGeometry, - ); - // Turn content uses a 180ms grid transition. The real scrollHeight can - // cross the overflow threshold only after the animation advances, so do a - // final geometry read once the expansion has settled. - pendingFollowRecheckTimer.current = window.setTimeout(() => { + pendingFollowRecheckFrame.current = window.requestAnimationFrame(() => { pendingFollowRecheck.current = false; pendingFollowRecheckFrame.current = undefined; - pendingFollowRecheckTimer.current = undefined; recheckFollowFromScrollGeometry(); - }, 220); + }); }, [recheckFollowFromScrollGeometry]); useEffect( @@ -2667,12 +2707,10 @@ export const MessageList = memo( if (pendingFollowRecheckFrame.current !== undefined) { window.cancelAnimationFrame(pendingFollowRecheckFrame.current); } - if (pendingFollowRecheckTimer.current !== undefined) { - window.clearTimeout(pendingFollowRecheckTimer.current); - } if (pendingOverflowFrame.current !== undefined) { window.cancelAnimationFrame(pendingOverflowFrame.current); } + cancelTranscriptReload(); if (transcriptBottomScrollFrame.current !== undefined) { window.cancelAnimationFrame(transcriptBottomScrollFrame.current); } @@ -2682,7 +2720,7 @@ export const MessageList = memo( ); } }, - [], + [cancelTranscriptReload], ); const handleToggleCollapse = useCallback( @@ -2699,15 +2737,97 @@ export const MessageList = memo( setShouldFollow(false); } scheduleFollowRecheck(); - setCollapseOverrides((prev) => { - const next = new Map(prev); - next.set(turnId, nextExpanded); - return next; + if (turnLayoutAnimationTimer.current !== undefined) { + window.clearTimeout(turnLayoutAnimationTimer.current); + } + turnLayoutRowTops.current.clear(); + containerRef.current + ?.querySelectorAll('[data-message-row-key]') + .forEach((row) => { + const key = row.dataset.messageRowKey; + if (key) { + turnLayoutRowTops.current.set( + key, + row.getBoundingClientRect().top, + ); + } + }); + turnLayoutTransitionStarted.current = true; + startTurnLayoutTransition(() => { + setCollapseOverrides((prev) => { + const next = new Map(prev); + next.set(turnId, nextExpanded); + return next; + }); }); }, [scheduleFollowRecheck, setShouldFollow], ); + useLayoutEffect(() => { + if (turnLayoutPending || !turnLayoutTransitionStarted.current) return; + turnLayoutTransitionStarted.current = false; + for (const animation of turnLayoutAnimations.current) { + animation.cancel(); + } + turnLayoutAnimations.current = []; + const reduceMotion = + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (!reduceMotion) { + containerRef.current + ?.querySelectorAll('[data-message-row-key]') + .forEach((row) => { + if (typeof row.animate !== 'function') return; + const key = row.dataset.messageRowKey; + const previousTop = key + ? turnLayoutRowTops.current.get(key) + : undefined; + if (previousTop === undefined) { + turnLayoutAnimations.current.push( + row.animate([{ opacity: 0 }, { opacity: 1 }], { + duration: TURN_LAYOUT_ANIMATION_MS, + easing: 'ease-out', + }), + ); + return; + } + const delta = previousTop - row.getBoundingClientRect().top; + if (Math.abs(delta) < 1) return; + turnLayoutAnimations.current.push( + row.animate( + [{ translate: `0 ${delta}px` }, { translate: '0 0' }], + { + duration: TURN_LAYOUT_ANIMATION_MS, + easing: 'ease-out', + }, + ), + ); + }); + } + turnLayoutRowTops.current.clear(); + turnLayoutAnimationTimer.current = window.setTimeout( + () => { + turnLayoutAnimationTimer.current = undefined; + scheduleFollowRecheck(); + }, + reduceMotion ? 0 : TURN_LAYOUT_ANIMATION_MS, + ); + }, [scheduleFollowRecheck, turnLayoutPending]); + + useEffect( + () => () => { + if (turnLayoutAnimationTimer.current !== undefined) { + window.clearTimeout(turnLayoutAnimationTimer.current); + } + for (const animation of turnLayoutAnimations.current) { + animation.cancel(); + } + turnLayoutAnimations.current = []; + }, + [], + ); + const handleDisclosureClickCapture = useCallback( (event: ReactMouseEvent) => { const target = event.target; @@ -2789,12 +2909,6 @@ export const MessageList = memo( if (hasTailContent && index === tailContentIndex) return ESTIMATE_TAIL; const item = visibleItems[index - headerOffset]; if (item?.type === 'turn_collapse') return ESTIMATE_TURN_COLLAPSE; - if (item?.type === 'turn_content') { - return Math.max( - ESTIMATE_MESSAGE, - item.items.length * ESTIMATE_MESSAGE, - ); - } return ESTIMATE_MESSAGE; }, overscan: 20, @@ -3004,17 +3118,6 @@ export const MessageList = memo( callId, ); if (visibleIndex >= 0) { - const visibleItem = visibleItems[visibleIndex]; - if (visibleItem?.type === 'turn_content' && visibleItem.collapsed) { - pendingScrollRef.current = { messageId, callId }; - setCollapseOverrides((prev) => { - if (prev.get(visibleItem.turnId) === true) return prev; - const next = new Map(prev); - next.set(visibleItem.turnId, true); - return next; - }); - return true; - } pendingScrollRef.current = null; performScrollToRow(visibleIndex + headerOffset, { messageId, @@ -3074,6 +3177,7 @@ export const MessageList = memo( } olderHistoryRetryBlocked.current = false; olderHistoryLoadInFlight.current = true; + setSuppressOlderHistoryLoadingStatus(!allowRetry); const previousHeight = el.scrollHeight; const previousTop = el.scrollTop; followPausedByUserRef.current = true; @@ -3086,6 +3190,8 @@ export const MessageList = memo( } catch { olderHistoryRetryBlocked.current = true; olderHistoryLoadInFlight.current = false; + } finally { + setSuppressOlderHistoryLoadingStatus(false); } }, [loadingOlderHistory, onLoadOlderHistory], @@ -3121,9 +3227,11 @@ export const MessageList = memo( followPausedByUserRef.current = false; setShouldFollow(true); } else if (hasUserScrollIntent) { + cancelTranscriptReload(); followPausedByUserRef.current = true; setShouldFollow(false); } else if (!followPausedByUserRef.current) { + cancelTranscriptReload(); setShouldFollow(false); } return; @@ -3134,6 +3242,9 @@ export const MessageList = memo( if (distanceFromBottom < FOLLOW_BOTTOM_THRESHOLD_PX) { followPausedByUserRef.current = false; setShouldFollow(true); + scheduleTranscriptReload(); + } else { + cancelTranscriptReload(); } }, [ getScrollElement, @@ -3141,6 +3252,8 @@ export const MessageList = memo( loadOlderHistory, scheduleScrollOverflowReport, scheduleSessionTimelineRangeUpdate, + scheduleTranscriptReload, + cancelTranscriptReload, setShouldFollow, ]); @@ -3533,21 +3646,6 @@ export const MessageList = memo( ); } - if (displayItem.type === 'turn_content') { - return ( - - {displayItem.items.map((child) => ( -
- {renderDisplayItem(child, false)} -
- ))} -
- ); - } - const finalAssistantTurnId = displayItem.message.role === 'assistant' ? finalAssistantTurnIdByAssistantId.get(displayItem.message.id) @@ -3701,11 +3799,13 @@ export const MessageList = memo( {showLoadingSkeleton && ( )} - {loadingOlderHistory && !showLoadingSkeleton && ( -
- {t('history.loadingEarlier')} -
- )} + {loadingOlderHistory && + !showLoadingSkeleton && + !suppressOlderHistoryLoadingStatus && ( +
+ {t('history.loadingEarlier')} +
+ )} {historyCapacityReached && !showLoadingSkeleton && (
{t('history.capacityReached')} @@ -3736,6 +3836,7 @@ export const MessageList = memo( visibleItems[virtualRow.index - headerOffset], ), )} + data-message-row-key={String(getItemKey(virtualRow.index))} data-web-shell-message-row style={{ position: 'absolute', @@ -3758,6 +3859,7 @@ export const MessageList = memo( key={key} data-index={index} className={getRowClassName(item)} + data-message-row-key={String(key)} data-web-shell-message-row > {renderVirtualItem(index)} diff --git a/packages/web-shell/client/components/SplitView.tsx b/packages/web-shell/client/components/SplitView.tsx index ddd4f0e6a97..c62333675dc 100644 --- a/packages/web-shell/client/components/SplitView.tsx +++ b/packages/web-shell/client/components/SplitView.tsx @@ -71,6 +71,8 @@ export interface SplitViewProps { workspaceCwd?: string; /** Restart each pane's SSE event stream after an accepted prompt. */ restartSseOnPrompt?: boolean; + /** Persisted transcript records requested per page by each pane. */ + historyPageSize?: number; } /** @@ -93,6 +95,7 @@ export function SplitView({ includeOtherWorkspaces = true, workspaceCwd, restartSseOnPrompt, + historyPageSize = WEB_SHELL_HISTORY_PAGE_SIZE, }: SplitViewProps) { const { t } = useI18n(); const connection = useConnection(); @@ -478,7 +481,7 @@ export function SplitView({ // tab's panes) for the same session, so the attachments don't // collide on one client identity. clientId={`split-pane:${instanceId}:${sessionId}`} - historyPageSize={WEB_SHELL_HISTORY_PAGE_SIZE} + historyPageSize={historyPageSize} subagentTranscriptMode="summary" maxBlocks={WEB_SHELL_MAX_TRANSCRIPT_BLOCKS} suppressOwnUserEcho diff --git a/packages/web-shell/client/components/WebShellTranscript.dom.test.tsx b/packages/web-shell/client/components/WebShellTranscript.dom.test.tsx index 5ae9c05130a..a10af04eef1 100644 --- a/packages/web-shell/client/components/WebShellTranscript.dom.test.tsx +++ b/packages/web-shell/client/components/WebShellTranscript.dom.test.tsx @@ -109,6 +109,12 @@ describe('WebShellTranscript DOM integration', () => { ); expect(container.textContent).toContain('Inspect the project'); + expect(container.textContent).not.toContain('Thinking through it'); + const thinkingToggle = container.querySelector( + 'button[title="Expand thinking"]', + ); + expect(thinkingToggle).not.toBeNull(); + act(() => thinkingToggle?.click()); expect(container.textContent).toContain('Thinking through it'); expect(container.textContent).toContain('Read package file'); expect(container.textContent).toContain('Explore the codebase'); @@ -346,11 +352,8 @@ describe('WebShellTranscript DOM integration', () => { ); const toggle = container.querySelector('[data-testid="toggle-u1"]'); const row = toggle?.closest('[role="button"]'); - const reasoning = Array.from(container.querySelectorAll('*')).find( - (element) => element.textContent === 'Hidden reasoning', - ); expect(row?.getAttribute('aria-expanded')).toBe('false'); - expect(reasoning?.closest('[data-collapsed="true"]')).not.toBeNull(); + expect(container.textContent).not.toContain('Hidden reasoning'); act(() => { row?.dispatchEvent( @@ -358,7 +361,13 @@ describe('WebShellTranscript DOM integration', () => { ); }); expect(row?.getAttribute('aria-expanded')).toBe('true'); - expect(reasoning?.closest('[data-collapsed="true"]')).toBeNull(); + expect(container.textContent).not.toContain('Hidden reasoning'); + const thinkingToggle = container.querySelector( + 'button[title="Expand thinking"]', + ); + expect(thinkingToggle).not.toBeNull(); + act(() => thinkingToggle?.click()); + expect(container.textContent).toContain('Hidden reasoning'); }); it('suppresses session and goal events while preserving their text', () => { diff --git a/packages/web-shell/client/components/WorkspaceSessionProvider.tsx b/packages/web-shell/client/components/WorkspaceSessionProvider.tsx index 4648ee00b59..8a5c3ad6625 100644 --- a/packages/web-shell/client/components/WorkspaceSessionProvider.tsx +++ b/packages/web-shell/client/components/WorkspaceSessionProvider.tsx @@ -22,6 +22,7 @@ interface WorkspaceSessionProviderProps { lockWorkspaceCwd?: string; clientId?: string; restartSseOnPrompt?: boolean; + historyPageSize?: number; webShellProps: WebShellProps; } @@ -32,6 +33,7 @@ export function WorkspaceSessionProvider({ lockWorkspaceCwd, clientId, restartSseOnPrompt, + historyPageSize = WEB_SHELL_HISTORY_PAGE_SIZE, webShellProps, }: WorkspaceSessionProviderProps) { const workspace = useWorkspace(); @@ -231,7 +233,7 @@ export function WorkspaceSessionProvider({ sessionId={effectiveSessionId} workspaceCwd={targetWorkspace?.cwd} clientId={clientId} - historyPageSize={WEB_SHELL_HISTORY_PAGE_SIZE} + historyPageSize={historyPageSize} subagentTranscriptMode="summary" maxBlocks={WEB_SHELL_MAX_TRANSCRIPT_BLOCKS} suppressOwnUserEcho @@ -239,6 +241,7 @@ export function WorkspaceSessionProvider({ > { act(() => root.unmount()); container.remove(); } + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -50,6 +51,7 @@ function renderCompletedThinking( const container = document.createElement('div'); document.body.appendChild(container); const root = createRoot(container); + mounted.push({ root, container }); const tree = (isStreaming: boolean) => ( root.render(tree(true))); vi.setSystemTime(durationMs); act(() => root.render(tree(false))); - mounted.push({ root, container }); return container; } @@ -140,10 +141,16 @@ describe('AssistantMessage thinking logic', () => { ); expect(container.textContent).toContain('Thinking 2s'); + expect(container.textContent).not.toContain('private chain of thought'); const toggle = container.querySelector('button'); act(() => toggle?.parentElement?.click()); expect(toggle?.getAttribute('aria-expanded')).toBe('true'); + expect(container.textContent).toContain('private chain of thought'); + + act(() => toggle?.parentElement?.click()); + expect(toggle?.getAttribute('aria-expanded')).toBe('false'); + expect(container.textContent).not.toContain('private chain of thought'); }); it('only translates completed thinking and reuses the in-memory result', async () => { @@ -381,6 +388,52 @@ describe('AssistantMessage thinking logic', () => { }); }); +describe('AssistantMessage streaming markdown', () => { + it('limits intermediate renders and flushes final content immediately', () => { + vi.useFakeTimers(); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + const tree = (content: string, isStreaming: boolean) => ( + + + + ); + + act(() => root.render(tree('first', true))); + act(() => root.render(tree('first second', true))); + expect(container.textContent).toContain('first'); + expect(container.textContent).not.toContain('second'); + + act(() => vi.advanceTimersByTime(80)); + expect(container.textContent).toContain('first second'); + + act(() => root.render(tree('first second final', false))); + expect(container.textContent).toContain('first second final'); + }); + + it('shows non-monotonic streaming content immediately', () => { + vi.useFakeTimers(); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + const tree = (content: string, isStreaming: boolean) => ( + + + + ); + + act(() => root.render(tree('old response text', true))); + expect(container.textContent).toContain('old response text'); + + act(() => root.render(tree('new unrelated text', true))); + expect(container.textContent).toContain('new unrelated text'); + expect(container.textContent).not.toContain('old response text'); + }); +}); + describe('AssistantMessage markdown tables', () => { const tableMarkdown = [ '| Team | Score |', diff --git a/packages/web-shell/client/components/messages/AssistantMessage.tsx b/packages/web-shell/client/components/messages/AssistantMessage.tsx index a8312f071d4..8f681ce620c 100644 --- a/packages/web-shell/client/components/messages/AssistantMessage.tsx +++ b/packages/web-shell/client/components/messages/AssistantMessage.tsx @@ -32,6 +32,41 @@ interface AssistantMessageProps { customFooterInfo?: WebShellAssistantTurnFooterRenderInfo; } +const STREAMING_MARKDOWN_UPDATE_MS = 80; + +function useStreamingMarkdownContent(content: string, isStreaming?: boolean) { + const [streamingContent, setStreamingContent] = useState(content); + const latestContentRef = useRef(content); + const timerRef = useRef | undefined>(undefined); + latestContentRef.current = content; + + useEffect(() => { + if (!isStreaming) { + if (timerRef.current !== undefined) { + clearTimeout(timerRef.current); + timerRef.current = undefined; + } + if (streamingContent !== content) setStreamingContent(content); + return; + } + if (timerRef.current !== undefined || streamingContent === content) return; + timerRef.current = setTimeout(() => { + timerRef.current = undefined; + setStreamingContent(latestContentRef.current); + }, STREAMING_MARKDOWN_UPDATE_MS); + }, [content, isStreaming, streamingContent]); + + useEffect( + () => () => { + if (timerRef.current !== undefined) clearTimeout(timerRef.current); + }, + [], + ); + + if (!isStreaming) return content; + return content.startsWith(streamingContent) ? streamingContent : content; +} + export const AssistantMessage = memo(function AssistantMessage({ content, isStreaming, @@ -44,6 +79,7 @@ export const AssistantMessage = memo(function AssistantMessage({ }: AssistantMessageProps) { const { t } = useI18n(); const { renderAssistantTurnFooter } = useWebShellCustomization(); + const markdownContent = useStreamingMarkdownContent(content, isStreaming); const [copied, setCopied] = useState(false); const showFooter = !!content && !isStreaming && showFooterActions; const customFooter = useMemo( @@ -75,7 +111,7 @@ export const AssistantMessage = memo(function AssistantMessage({ >
@@ -524,23 +560,19 @@ export const ThinkingMessage = memo(function ThinkingMessage({ aria-hidden="true" />
-
-
-
- + {thinkingExpanded && ( +
+
+
+ +
-
+ )}
)} diff --git a/packages/web-shell/client/components/messages/Markdown.coldHighlight.test.ts b/packages/web-shell/client/components/messages/Markdown.coldHighlight.test.ts index 423d3b310ab..49e60e40cd9 100644 --- a/packages/web-shell/client/components/messages/Markdown.coldHighlight.test.ts +++ b/packages/web-shell/client/components/messages/Markdown.coldHighlight.test.ts @@ -45,7 +45,7 @@ beforeEach(() => { }); describe('CodeBlock cold-highlight path', () => { - it("drops the previous block's highlight and shows plain text while a cold language loads", async () => { + it("drops the previous block's highlight without loading a grammar while streaming", async () => { const container = document.createElement('div'); document.body.appendChild(container); const root = createRoot(container); @@ -62,10 +62,8 @@ describe('CodeBlock cold-highlight path', () => { }); expect(container.textContent).toContain('const aaa = 1;'); - // Regenerate the same slot into Python, whose grammar is held pending. The - // sync path returns null, so the effect enters the cold path. The stale - // `const aaa` highlight MUST be cleared and the new code shown as plain text - // until the (never-resolving) load completes — not the previous content. + // Regenerate the same slot into Python. Streaming deliberately stays on the + // plain-text path and does not load or tokenize the grammar. await act(async () => { root.render( createElement(Markdown, { @@ -77,10 +75,7 @@ describe('CodeBlock cold-highlight path', () => { expect(container.textContent).not.toContain('aaa'); expect(container.textContent).toContain('xyzzy = 123456'); - // Pin that we actually exercised the *cold* path (async load), not a warm - // sync highlight — otherwise a mock-setup drift (python highlighting - // synchronously) would make the assertions above pass for the wrong reason. - expect(mocks.getCodeHighlighter).toHaveBeenCalledWith('python'); + expect(mocks.getCodeHighlighter).not.toHaveBeenCalledWith('python'); await act(async () => { root.unmount(); @@ -103,7 +98,7 @@ describe('CodeBlock cold-highlight path', () => { root.render( createElement(Markdown, { content: '```python\nxyzzy = 123456\n```', - isStreaming: true, + isStreaming: false, }), ); }); diff --git a/packages/web-shell/client/components/messages/Markdown.test.ts b/packages/web-shell/client/components/messages/Markdown.test.ts index 31ed9f66103..587941fe0f8 100644 --- a/packages/web-shell/client/components/messages/Markdown.test.ts +++ b/packages/web-shell/client/components/messages/Markdown.test.ts @@ -1312,13 +1312,11 @@ describe('Markdown code highlighting while streaming', () => { container.remove(); }); - it('highlights the block as it streams, and the appended chunk too', async () => { + it('keeps a growing block plain and highlights it once settled', async () => { const container = document.createElement('div'); document.body.appendChild(container); const root = createRoot(container); - // First streamed chunk: gets highlighted (async grammar load, then the - // synchronous re-highlight). await act(async () => { root.render( createElement(Markdown, { @@ -1327,14 +1325,9 @@ describe('Markdown code highlighting while streaming', () => { }), ); }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 300)); - }); - expect(container.querySelector('.shiki')).not.toBeNull(); + expect(container.querySelector('.shiki')).toBeNull(); expect(container.textContent).toContain('const a = 1;'); - // Appended chunk (still streaming): the new line is re-highlighted - // synchronously — content never lags out of the DOM. await act(async () => { root.render( createElement(Markdown, { @@ -1343,6 +1336,17 @@ describe('Markdown code highlighting while streaming', () => { }), ); }); + expect(container.querySelector('.shiki')).toBeNull(); + expect(container.textContent).toContain('const b = 2;'); + + await act(async () => { + root.render( + createElement(Markdown, { + content: '```ts\nconst a = 1;\nconst b = 2;\n```', + isStreaming: false, + }), + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 300)); }); @@ -1401,11 +1405,8 @@ describe('Markdown code highlighting while streaming', () => { }); expect(container.textContent).toContain('const aaa'); - // Replace the content while streaming. `ts` is already warm, so the - // synchronous re-highlight produces the new block's HTML immediately; the - // stale highlight (of `const aaa`) must NOT be shown — `const zzz` is. - // (The cold-language variant of this — where the new grammar is still - // loading — is covered deterministically in Markdown.coldHighlight.test.tsx.) + // Replaced streaming content is shown as current plain text, never as the + // stale highlight from the previous settled response. await act(async () => { root.render( createElement(Markdown, { @@ -1414,11 +1415,11 @@ describe('Markdown code highlighting while streaming', () => { }), ); }); + expect(container.querySelector('.shiki')).toBeNull(); expect(container.textContent).toContain('const zzz'); expect(container.textContent).not.toContain('const aaa'); - // Positive case: once the regenerated content settles, it is actually - // highlighted (re-highlighted synchronously — not stuck on plain text). + // Once regenerated content settles, it is highlighted. await act(async () => { root.render( createElement(Markdown, { diff --git a/packages/web-shell/client/components/messages/Markdown.tsx b/packages/web-shell/client/components/messages/Markdown.tsx index 237d317e8d4..8d78e47b286 100644 --- a/packages/web-shell/client/components/messages/Markdown.tsx +++ b/packages/web-shell/client/components/messages/Markdown.tsx @@ -427,9 +427,11 @@ function CodeBlock({ appTheme === 'light' ? 'github-light-default' : 'github-dark-default'; useEffect(() => { - // Don't highlight unsupported languages or blocks too large to tokenize - // without freezing the main thread — render them as plain text. + // Stream code as plain text. Highlighting a growing fence on every chunk + // repeatedly tokenizes its entire contents and can dominate rendering for + // long responses; the settled render below highlights the final text once. if ( + isStreaming || lang === 'mermaid' || resolvedLang === 'text' || isTooLargeToHighlight(code) @@ -446,21 +448,7 @@ function CodeBlock({ return; } - // Re-highlight synchronously on every code change. With the Oniguruma - // engine a normal-sized block tokenizes in ~1–7ms, so there's no need to - // throttle or keep a stale snapshot around: `html` always matches the - // current `code`, so no streamed text is ever hidden and there's no flicker. - // `isTooLargeToHighlight` above bounds the worst-case per-chunk cost. - // - // Don't persist streaming intermediates: the growing block produces a new - // cache key every chunk and would otherwise evict other blocks from the LRU. - const persist = !isStreaming; - const warmHtml = highlightToHtmlSync( - code, - resolvedLang, - shikiTheme, - persist, - ); + const warmHtml = highlightToHtmlSync(code, resolvedLang, shikiTheme, true); if (warmHtml !== null) { setHtml(warmHtml); return; @@ -471,19 +459,13 @@ function CodeBlock({ // not-yet-loaded language on regeneration) so we render the current code as // plain text — not the prior block's stale highlight — until the load // resolves. Then re-check cancellation *before* the synchronous tokenization - // so superseded streaming snapshots that queued behind the same load don't - // each run codeToHtml. + // so a superseded settled block does not run codeToHtml. setHtml(null); let cancelled = false; getCodeHighlighter(resolvedLang) .then(() => { if (cancelled) return; - const cold = highlightToHtmlSync( - code, - resolvedLang, - shikiTheme, - persist, - ); + const cold = highlightToHtmlSync(code, resolvedLang, shikiTheme, true); if (cold !== null) setHtml(cold); }) .catch((err) => { @@ -515,9 +497,6 @@ function CodeBlock({ return ; } - // `html` is always the highlight of the *current* `code` (re-highlighted - // synchronously per chunk), so it can be rendered directly — no prefix gate - // is needed to guard against showing a stale/previous block's HTML. return (
@@ -526,7 +505,7 @@ function CodeBlock({ {copied ? t('code.copied') : t('code.copy')}
- {html !== null ? ( + {!isStreaming && html !== null ? (
diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 79bab383fc3..79d74be95c6 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -3679,7 +3679,11 @@ describe('DaemonSessionProvider', () => { ]); }); - it('renders bounded replay truncation from the loaded snapshot without resync', async () => { + it('uses bounded replay truncation to enable history pagination without rendering it', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); const session = createMockSession({ replaySnapshot: { compactedReplay: [ @@ -3702,6 +3706,7 @@ describe('DaemonSessionProvider', () => { update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'retained replay' }, + _meta: { 'qwen.session.recordId': 'record-retained' }, }, }, }, @@ -3710,12 +3715,20 @@ describe('DaemonSessionProvider', () => { }, }); sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [], + hasMore: false, + }); let blocks: readonly DaemonTranscriptBlock[] = []; let awaitingResync = false; + let history: ReturnType | undefined; function Harness() { blocks = useDaemonTranscriptBlocks(); awaitingResync = useDaemonTranscriptState().awaitingResync; + history = useDaemonTranscriptHistory(); return null; } @@ -3723,22 +3736,90 @@ describe('DaemonSessionProvider', () => { autoConnect: true, reconnectDelayMs: 1, maxReconnectDelayMs: 1, + historyPageSize: 25, }); await act(async () => { await flushPromises(); }); expect(awaitingResync).toBe(false); + expect(blocks).toEqual([ + expect.objectContaining({ + kind: 'assistant', + text: 'retained replay', + }), + ]); + expect(history?.hasMore).toBe(true); + await act(async () => history?.loadMore()); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledWith( + session.sessionId, + { + beforeRecordId: 'record-retained', + limit: 25, + clientId: session.clientId, + }, + ); + }); + + it('renders bounded replay truncation when no pagination anchor is available', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const session = createMockSession({ + replaySnapshot: { + compactedReplay: [ + { + v: 1, + type: 'history_truncated', + data: { + reason: 'replay_window_exceeded', + truncatedEvents: 4, + retainedEvents: 1, + maxBytes: 512, + fullTranscriptAvailable: true, + }, + }, + { + id: 5, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'retained replay' }, + }, + }, + }, + ], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + let blocks: readonly DaemonTranscriptBlock[] = []; + let history: ReturnType | undefined; + + function Harness() { + blocks = useDaemonTranscriptBlocks(); + history = useDaemonTranscriptHistory(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + }); + await act(async () => { + await flushPromises(); + }); + + expect(history?.hasMore).toBe(false); expect(blocks).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: 'status', text: expect.stringContaining('History truncated'), }), - expect.objectContaining({ - kind: 'assistant', - text: 'retained replay', - }), ]), ); }); @@ -6250,6 +6331,53 @@ describe('DaemonSessionProvider', () => { expect(blocks).toEqual([]); }); + it('keeps the current transcript when a same-session reload is aborted', async () => { + const replacement = createDeferred(); + const currentSession = createMockSession({ + sessionId: 'session-a', + replaySnapshot: createTextReplaySnapshot('current transcript'), + }); + sdkMocks.sessions.push(currentSession); + let actions: DaemonSessionActions | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + actions = useDaemonActions(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + await act(async () => { + await flushPromises(); + }); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async () => replacement.promise, + ); + const controller = new AbortController(); + const reload = requireActions(actions).reloadSession(controller.signal); + await act(async () => { + await flushPromises(); + }); + + controller.abort(); + const refreshedSession = createMockSession({ + sessionId: 'session-a', + replaySnapshot: createTextReplaySnapshot('replacement transcript'), + }); + replacement.resolve(refreshedSession); + await act(async () => { + await expect(reload).rejects.toMatchObject({ name: 'AbortError' }); + await flushPromises(); + }); + + expect(blocks).toMatchObject([ + { kind: 'assistant', text: 'current transcript' }, + ]); + expect(currentSession.detach).not.toHaveBeenCalled(); + expect(refreshedSession.detach).toHaveBeenCalledOnce(); + }); + it('clears transcript immediately for default session switches', async () => { const nextSession = createDeferred(); const currentSession = createMockSession({ diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index c5d4310052c..65c7649c69e 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -155,6 +155,14 @@ function getPersistedReplayRecordId(event: DaemonEvent): string | undefined { } } +function hasFullTranscriptBeforeReplay(event: DaemonEvent): boolean { + return ( + event.type === 'history_truncated' && + isRecord(event.data) && + event.data['fullTranscriptAvailable'] === true + ); +} + function prependTranscriptHistory( store: DaemonTranscriptStore, events: DaemonUiEvent[], @@ -566,6 +574,11 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { } const abort = new AbortController(); let disposed = false; + const preservingTranscriptDuringLoad = + restoreMode === 'load' && + restoreSessionId !== undefined && + restoreSessionId === sessionRef.current?.sessionId && + restoreSessionId === skipNextCleanupDetachSessionIdRef.current; // ── Batched transcript dispatch ──────────────────────────────── // The live SSE loop dispatches transcript events through this batcher @@ -718,15 +731,17 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { } } if (!session) { - setConnection((current) => ({ - ...current, - status: 'connecting', - error: undefined, - errorStatus: resolveConnectionErrorStatus( - undefined, - current.errorStatus, - ), - })); + if (!preservingTranscriptDuringLoad) { + setConnection((current) => ({ + ...current, + status: 'connecting', + error: undefined, + errorStatus: resolveConnectionErrorStatus( + undefined, + current.errorStatus, + ), + })); + } const getWorkspaceCapabilities = workspaceGetCapabilitiesRef.current; const caps = @@ -894,7 +909,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ? clientIdRef.current : getStableClientId(undefined, targetSessionId); loadingRequestedSession = Boolean(restoreSessionId); - if (targetSessionId) { + if (targetSessionId && !preservingTranscriptDuringLoad) { setConnection((current) => ({ ...current, sessionId: targetSessionId, @@ -904,6 +919,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { loadingTranscript: true, })); } + const attemptedLoad = + pendingSessionLoadRef.current?.sessionId === targetSessionId + ? pendingSessionLoadRef.current + : undefined; const nextSession = restoreSessionId ? await restoreMethod( client, @@ -965,6 +984,47 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ); return; } + // A tail refresh may finish after the reader leaves the bottom or + // after its action times out. Undo that new attachment and keep the + // old session rather than committing a now-unwanted snapshot. + if ( + preservingTranscriptDuringLoad && + attemptedLoad?.sessionId === nextSession.sessionId && + (attemptedLoad.signal?.aborted || + pendingSessionLoadRef.current !== attemptedLoad) + ) { + const previousSession = sessionRef.current; + if (nextSession !== previousSession) { + await nextSession.detach().catch((error: unknown) => { + console.warn( + '[DaemonSessionProvider] detach cancelled reload failed:', + error, + ); + }); + } + if (pendingSessionLoadRef.current === attemptedLoad) { + pendingSessionLoadRef.current = undefined; + clearTimeout(attemptedLoad.timeout); + attemptedLoad.reject( + new DOMException('Session load cancelled', 'AbortError'), + ); + } + if ( + skipNextCleanupDetachSessionIdRef.current === + nextSession.sessionId + ) { + skipNextCleanupDetachSessionIdRef.current = undefined; + } + loadingRequestedSession = false; + if (previousSession?.sessionId === nextSession.sessionId) { + session = previousSession; + reconnectSessionId = previousSession.sessionId; + reconnectAttempt = 0; + skipMetadataRefresh = true; + continue; + } + return; + } const previousSessionId = lastSessionIdRef.current; if (previousSessionId !== nextSession.sessionId) { clearNotices(); @@ -1072,12 +1132,15 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const firstPersistedRecordId = replayEvents .map(getPersistedReplayRecordId) .find((recordId): recordId is string => recordId !== undefined); + const replayHistoryWasTruncated = replayEvents.some( + hasFullTranscriptBeforeReplay, + ); const historyHasMore = Array.isArray(capabilities?.features) && capabilities.features.includes( SESSION_TRANSCRIPT_PAGINATION_FEATURE, ) && - activeSession.historyHasMore && + (activeSession.historyHasMore || replayHistoryWasTruncated) && firstPersistedRecordId !== undefined; transcriptHistoryRef.current = { sessionId: activeSession.sessionId, @@ -1120,6 +1183,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { replayUiEvents, addNotice, dismissNotice, + { hideHistoryTruncation: historyHasMore }, ); allUiEvents.push( ...(subagentTranscriptModeRef.current === 'summary' @@ -1898,6 +1962,20 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { catchingUp: undefined, })); return; + } else if ( + preservingTranscriptDuringLoad && + session === undefined && + pendingLoad?.sessionId === restoreSessionId && + sessionRef.current?.sessionId === restoreSessionId + ) { + // The refresh failed before replacing the old handle. Resume its + // SSE directly instead of retrying load and registering another + // attachment after the caller's promise has already been rejected. + session = sessionRef.current; + reconnectSessionId = session.sessionId; + reconnectAttempt = 0; + skipMetadataRefresh = true; + continue; } else { // Retriable error (network failure, timeout, etc.) — preserve // the session so the next iteration skips the full load() and @@ -2608,7 +2686,14 @@ function filterDaemonUiEventsForTranscript( events: DaemonUiEvent[], addNotice: AddDaemonSessionNotice, dismissNotice: (id: string) => void, + behavior: { hideHistoryTruncation?: boolean } = {}, ): DaemonUiEvent[] { + if ( + behavior.hideHistoryTruncation && + hasFullTranscriptBeforeReplay(sourceEvent) + ) { + return []; + } if ( sourceEvent.type === 'session_snapshot' && isRecord(sourceEvent.data) && diff --git a/packages/webui/src/daemon/session/actions.test.ts b/packages/webui/src/daemon/session/actions.test.ts index d9c8f4fb880..ca1b0097697 100644 --- a/packages/webui/src/daemon/session/actions.test.ts +++ b/packages/webui/src/daemon/session/actions.test.ts @@ -303,6 +303,89 @@ describe('createDaemonSessionActions', () => { expect(pendingSessionLoadRef.current?.sessionId).toBe('session-b'); }); + it('detaches the old same-session attachment after its replacement loads', async () => { + const existingSession = createMockSession('session-a'); + const { actions, getConnection, pendingSessionLoadRef, sessionRef, store } = + createActionsHarness({ + connection: { status: 'connected', sessionId: 'session-a' }, + session: existingSession, + }); + + const loadPromise = actions.loadSession('session-a'); + + expect(existingSession.detach).not.toHaveBeenCalled(); + expect(sessionRef.current).toBe(existingSession); + expect(store.reset).not.toHaveBeenCalled(); + expect(getConnection()).toEqual({ + status: 'connected', + sessionId: 'session-a', + }); + + const pendingLoad = pendingSessionLoadRef.current; + pendingSessionLoadRef.current = undefined; + clearTimeout(pendingLoad?.timeout); + pendingLoad?.resolve(); + await loadPromise; + expect(existingSession.detach).toHaveBeenCalledOnce(); + }); + + it('keeps the old same-session attachment when its replacement fails', async () => { + const existingSession = createMockSession('session-a'); + const { actions, pendingSessionLoadRef, sessionRef } = createActionsHarness( + { + connection: { status: 'connected', sessionId: 'session-a' }, + session: existingSession, + }, + ); + + const loadPromise = actions.loadSession('session-a'); + const pendingLoad = pendingSessionLoadRef.current; + pendingSessionLoadRef.current = undefined; + clearTimeout(pendingLoad?.timeout); + pendingLoad?.reject(new Error('load failed')); + + await expect(loadPromise).rejects.toThrow('load failed'); + expect(existingSession.detach).not.toHaveBeenCalled(); + expect(sessionRef.current).toBe(existingSession); + }); + + it('does not start a session reload with an aborted signal', async () => { + const existingSession = createMockSession('session-a'); + const { actions, pendingSessionLoadRef, sessionRef, store } = + createActionsHarness({ + connection: { status: 'connected', sessionId: 'session-a' }, + session: existingSession, + }); + const controller = new AbortController(); + controller.abort(); + + await expect( + actions.reloadSession(controller.signal), + ).rejects.toMatchObject({ name: 'AbortError' }); + + expect(pendingSessionLoadRef.current).toBeUndefined(); + expect(sessionRef.current).toBe(existingSession); + expect(existingSession.detach).not.toHaveBeenCalled(); + expect(store.reset).not.toHaveBeenCalled(); + }); + + it('keeps the reload abort signal with the pending load', () => { + const controller = new AbortController(); + const { actions, pendingSessionLoadRef } = createActionsHarness({ + connection: { status: 'connected', sessionId: 'session-a' }, + session: createMockSession('session-a'), + }); + + void actions.reloadSession(controller.signal).catch(() => undefined); + + expect(pendingSessionLoadRef.current?.signal).toBe(controller.signal); + clearTimeout(pendingSessionLoadRef.current?.timeout); + pendingSessionLoadRef.current?.reject( + new DOMException('Test cleanup', 'AbortError'), + ); + pendingSessionLoadRef.current = undefined; + }); + it('keeps the active workspace when a session load omits one', () => { const setRestoreWorkspaceCwd = vi.fn(); const { actions } = createActionsHarness({ @@ -532,12 +615,13 @@ function createActionsHarness( ({ current: undefined } as { current: PendingSessionLoad | undefined; }); + const store = { + reset: vi.fn(), + appendLocalUserMessage: vi.fn(), + dispatch: vi.fn(), + }; const actions = createDaemonSessionActions({ - store: { - reset: vi.fn(), - appendLocalUserMessage: vi.fn(), - dispatch: vi.fn(), - } as never, + store: store as never, sessionRef, activePromptsRef, settledPromptsRef: { current: new Map() }, @@ -577,6 +661,7 @@ function createActionsHarness( getConnection: () => connection, pendingSessionLoadRef, sessionRef, + store, }; } diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index ce14b5b1f4d..31943d82701 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -173,6 +173,7 @@ export function createDaemonSessionActions({ function startPendingSessionLoad( sessionId: string, mode: PendingSessionLoad['mode'], + signal?: AbortSignal, ): Promise { const loadId = pendingSessionLoadIdRef.current + 1; pendingSessionLoadIdRef.current = loadId; @@ -206,6 +207,7 @@ export function createDaemonSessionActions({ timeout, resolve, reject, + ...(signal ? { signal } : {}), }; }); return loadPromise; @@ -215,9 +217,15 @@ export function createDaemonSessionActions({ sessionId: string, mode: 'load' | 'resume', workspaceCwd?: string, + signal?: AbortSignal, ): Promise { + if (signal?.aborted) { + return Promise.reject( + new DOMException('Session load cancelled', 'AbortError'), + ); + } manualSessionClearRef.current = false; - const loadPromise = startPendingSessionLoad(sessionId, mode); + const loadPromise = startPendingSessionLoad(sessionId, mode, signal); const currentSession = sessionRef.current; const currentSessionId = currentSession?.sessionId; const activePrompt = currentSessionId @@ -230,31 +238,42 @@ export function createDaemonSessionActions({ activePromptsRef.current.delete(currentSessionId); } resetCurrentSessionActivePrompt(); + const reloadingCurrentSession = + mode === 'load' && currentSessionId === sessionId; if (currentSession) { - void currentSession.detach().catch((error: unknown) => { - console.warn( - '[DaemonSessionActions] detach before session switch failed:', - error, - ); - }); + const detachCurrentSession = () => + currentSession.detach().catch((error: unknown) => { + console.warn( + '[DaemonSessionActions] detach before session switch failed:', + error, + ); + }); + if (reloadingCurrentSession) { + skipNextCleanupDetachSessionIdRef.current = sessionId; + void loadPromise.then(detachCurrentSession, () => undefined); + } else { + void detachCurrentSession(); + } + } + if (!reloadingCurrentSession) sessionRef.current = undefined; + if (!reloadingCurrentSession) { + setConnection((current) => ({ + ...current, + status: 'connecting', + sessionId, + clientId: undefined, + displayName: undefined, + error: undefined, + errorStatus: undefined, + missingSession: false, + loadingTranscript: true, + catchingUp: undefined, + })); } - sessionRef.current = undefined; - setConnection((current) => ({ - ...current, - status: 'connecting', - sessionId, - clientId: undefined, - displayName: undefined, - error: undefined, - errorStatus: undefined, - missingSession: false, - loadingTranscript: true, - catchingUp: undefined, - })); setPromptStatus('idle'); settledPromptsRef.current.clear(); clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef); - store.reset(); + if (!reloadingCurrentSession) store.reset(); setRestoreMode(mode); setRestoreSessionId(sessionId); setRestoreWorkspaceCwd(workspaceCwd ?? getConnection().workspaceCwd); @@ -614,6 +633,21 @@ export function createDaemonSessionActions({ return startSessionSwitch(sessionId, 'load', options?.workspaceCwd); }, + async reloadSession(signal) { + const session = requireSessionForAction( + addNotice, + sessionRef.current, + 'Reload session failed', + 'load_session', + ); + return startSessionSwitch( + session.sessionId, + 'load', + session.workspaceCwd, + signal, + ); + }, + async resumeSession(sessionId, options) { return startSessionSwitch(sessionId, 'resume', options?.workspaceCwd); }, diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index d496210c646..4570fb8b99b 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -341,6 +341,7 @@ export interface DaemonSessionActions { sessionId: string, options?: { workspaceCwd?: string }, ): Promise; + reloadSession(signal: AbortSignal): Promise; resumeSession( sessionId: string, options?: { workspaceCwd?: string }, @@ -473,4 +474,5 @@ export interface PendingSessionLoad { timeout: ReturnType; resolve: () => void; reject: (error: unknown) => void; + signal?: AbortSignal; }