From 686a1371c301d69123c3e6505ba209d561016ebc Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 3 Jul 2026 05:33:34 +0800 Subject: [PATCH] fix(web-shell): cut mobile session-switch jank (memoized timeline signature, replay-first dispatch) (#6183) * fix(web-shell): cut mobile session-switch jank (P0) - MessageList: wrap in memo and gate the O(transcript) session-timeline signature/entries computation behind rail visibility (container >= 1160px, never true on mobile), so scroll frames and unrelated App renders no longer rebuild a transcript-sized string - DaemonSessionProvider: dispatch the replay snapshot before the providers/commands/context fetches so the transcript paints one metadata round-trip earlier on session switch; keep catchingUp cleared once the replay is injected Refs #6181 Co-authored-by: Qwen-Coder * test(webui): cover replay resume catchingUp state * test(webui): assert catchingUp replay state sequence * fix(web-shell): restore timeline observer bootstrap --------- Co-authored-by: Qwen-Coder Co-authored-by: qwen-code-dev-bot --- .../client/components/MessageList.tsx | 40 +-- .../session/DaemonSessionProvider.test.tsx | 33 +++ .../daemon/session/DaemonSessionProvider.tsx | 229 +++++++++--------- 3 files changed, 174 insertions(+), 128 deletions(-) diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index 660a9bc6da..ab74bd4dca 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -1650,8 +1650,10 @@ function joinClassNames( return result || undefined; } -export const MessageList = forwardRef( - function MessageList( +const EMPTY_SESSION_TIMELINE_ENTRIES: SessionTimelineEntry[] = []; + +export const MessageList = memo( + forwardRef(function MessageList( { messages, pendingApproval, @@ -1686,19 +1688,25 @@ export const MessageList = forwardRef( () => groupParallelAgents(mergedMessages), [mergedMessages], ); - const sessionTimelineSignature = - getSessionTimelineSignature(mergedMessages); + const [isSessionTimelineVisible, setIsSessionTimelineVisible] = + useState(false); const sessionTimelineCache = useRef<{ signature: string; entries: SessionTimelineEntry[]; } | null>(null); - if (sessionTimelineCache.current?.signature !== sessionTimelineSignature) { - sessionTimelineCache.current = { - signature: sessionTimelineSignature, - entries: getSessionTimelineEntries(mergedMessages), - }; - } - const sessionTimelineEntries = sessionTimelineCache.current.entries; + // Signature + entries are O(transcript text); only pay for them while the + // rail can actually show (container >= 1160px — never on mobile). + const sessionTimelineEntries = useMemo(() => { + if (!isSessionTimelineVisible) return EMPTY_SESSION_TIMELINE_ENTRIES; + const signature = getSessionTimelineSignature(mergedMessages); + if (sessionTimelineCache.current?.signature !== signature) { + sessionTimelineCache.current = { + signature, + entries: getSessionTimelineEntries(mergedMessages), + }; + } + return sessionTimelineCache.current.entries; + }, [isSessionTimelineVisible, mergedMessages]); const sessionTimelineEntryIndexById = useMemo( () => new Map( @@ -1824,13 +1832,11 @@ export const MessageList = forwardRef( [visibleItems], ); - const [isSessionTimelineVisible, setIsSessionTimelineVisible] = - useState(false); const hasEnoughSessionTimelineEntries = sessionTimelineEntries.length >= SESSION_TIMELINE_MIN_VISIBLE_ENTRIES; useLayoutEffect(() => { - if (hideSessionTimeline || !hasEnoughSessionTimelineEntries) { + if (hideSessionTimeline) { setIsSessionTimelineVisible((prev) => (prev ? false : prev)); return; } @@ -1851,7 +1857,7 @@ export const MessageList = forwardRef( const observer = new ResizeObserver(updateVisibility); observer.observe(el); return () => observer.disconnect(); - }, [hasEnoughSessionTimelineEntries, hideSessionTimeline]); + }, [hideSessionTimeline]); // ── Scroll-follow state ────────────────────────────────────────────── // @@ -2657,7 +2663,7 @@ export const MessageList = forwardRef( entries={sessionTimelineEntries} currentTurnId={currentTimelineTurnId} currentRange={sessionTimelineRange} - hidden={!isSessionTimelineVisible} + hidden={!isSessionTimelineVisible || !hasEnoughSessionTimelineEntries} onSelect={scrollToMessage} /> {useVirtualScroll ? ( @@ -2706,5 +2712,5 @@ export const MessageList = forwardRef( )} ); - }, + }), ); diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index b069470af1..83e2e7992a 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -2501,6 +2501,39 @@ describe('DaemonSessionProvider', () => { expect(last?.catchingUp).toBeFalsy(); }); + it('does not re-arm catchingUp after injecting replay for a resumed session', async () => { + const session = createMockSession({ + lastEventId: 5, + replaySnapshot: createTextReplaySnapshot('replayed transcript'), + events: createIdleEvents(), + }); + sdkMocks.sessions.push(session); + + const states: DaemonConnectionState[] = []; + let blocks: readonly DaemonTranscriptBlock[] = []; + function Harness() { + const connection = useDaemonConnection(); + states.push(connection); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + await act(async () => { + await flushPromises(); + }); + + expect(blocks).toMatchObject([ + { kind: 'assistant', text: 'replayed transcript' }, + ]); + expect(states.every((s) => !s.catchingUp)).toBe(true); + expect(states[states.length - 1]).toMatchObject({ + status: 'connected', + sessionId: 'session-1', + catchingUp: undefined, + }); + }); + it('never sets catchingUp on a fresh subscription (no Last-Event-ID)', async () => { // A first-time attach has no resume cursor → the daemon emits no // replay_complete → arming catchingUp would stick forever. The Provider diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 0c935cc698..5b94a5c44f 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -616,6 +616,117 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { hasCurrentSessionActivePromptRef.current = hasSessionActivePrompt; setPromptStatus(hasSessionActivePrompt() ? 'streaming' : 'idle'); + const pendingLoad = pendingSessionLoadRef.current; + const pendingLoadToResolve = + pendingLoad?.sessionId === activeSession.sessionId + ? pendingLoad + : undefined; + + // Feed replay snapshot (compacted history + live journal) into + // the store before starting the SSE loop. The SSE stream begins + // from lastEventId, so only post-snapshot events are delivered. + // + // This runs before the providers/commands/context fetches below: + // the snapshot is already in hand, so the transcript paints one + // metadata round-trip earlier (visible on high-latency mobile). + // + // The deferred store.reset() runs here — in the same synchronous + // block as store.dispatch() — so the queueMicrotask notification + // only fires once with the fully-populated state. + const { compactedReplay, liveJournal } = activeSession.replaySnapshot; + const replayEvents = [...compactedReplay, ...liveJournal]; + const replayInjected = + shouldInjectReplaySnapshot && replayEvents.length > 0; + if (needsStoreReset && !replayInjected) { + // Reset needed but no replay data (e.g. fresh session) — reset + // immediately since there is no dispatch to batch with. + store.reset(); + } + if (replayInjected) { + const replayOpts = { + ...eventOptionsRef.current, + suppressOwnUserEcho: false, + }; + const allUiEvents: DaemonUiEvent[] = []; + for (const replayEvent of replayEvents) { + try { + const replayUiEvents = normalizeAndFilterEvent( + replayEvent, + activeSession.clientId, + replayOpts, + setConnection, + { updateConnection: false }, + ); + allUiEvents.push( + ...filterDaemonUiEventsForTranscript( + replayEvent, + replayUiEvents, + addNotice, + ), + ); + if (replayEvent.type === 'turn_complete') { + const stopReason = + (replayEvent.data as DaemonTurnCompleteData | undefined) + ?.stopReason ?? 'end_turn'; + allUiEvents.push( + assistantDoneFromTurnEvent(replayEvent, stopReason), + ); + } else if (replayEvent.type === 'turn_error') { + allUiEvents.push( + assistantDoneFromTurnEvent(replayEvent, 'error'), + ); + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + addNotice({ + severity: 'warning', + category: 'protocol', + operation: 'normalize_event', + code: 'daemon.replay_event_malformed', + message: 'Skipped malformed replay event', + debugMessage: message, + recoverable: true, + }); + console.warn( + '[DaemonSessionProvider] skipped malformed replay event:', + error, + ); + } + } + if (needsStoreReset) { + store.reset(); + } + if (allUiEvents.length > 0) { + store.dispatch(allUiEvents); + bumpWorkspaceEventSignals(allUiEvents, setWorkspaceEventSignals); + } + for (const replayEvent of replayEvents) { + settleActivePromptFromTurnEvent( + activePromptsRef.current, + settledPromptsRef.current, + activeSession.sessionId, + replayEvent, + store, + setPromptStatus, + passiveAssistantDoneTimerRef, + { requireBoundPromptId: true }, + ); + } + setConnection((c) => ({ ...c, catchingUp: undefined })); + } + if (pendingLoadToResolve) { + pendingSessionLoadRef.current = undefined; + clearTimeout(pendingLoadToResolve.timeout); + if ( + skipNextCleanupDetachSessionIdRef.current === + activeSession.sessionId + ) { + skipNextCleanupDetachSessionIdRef.current = undefined; + } + pendingLoadToResolve.resolve(); + } + const canReuseSessionMetadata = attachedExistingSession && connectionRef.current.commands !== undefined && @@ -721,9 +832,13 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { context: context ?? current.context, capabilities: capabilities ?? current.capabilities, catchingUp: - isSameSessionReconnect || - activeSession.lastEventId != null || - undefined, + // Replay already injected above — keep the cleared flag rather + // than re-arming it (nothing before SSE would clear it again). + replayInjected + ? current.catchingUp + : isSameSessionReconnect || + activeSession.lastEventId != null || + undefined, })); if (loadWarningTexts.length > 0) { store.dispatch( @@ -733,114 +848,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { })), ); } - - const pendingLoad = pendingSessionLoadRef.current; - const pendingLoadToResolve = - pendingLoad?.sessionId === activeSession.sessionId - ? pendingLoad - : undefined; - - // Feed replay snapshot (compacted history + live journal) into - // the store before starting the SSE loop. The SSE stream begins - // from lastEventId, so only post-snapshot events are delivered. - // - // The deferred store.reset() runs here — in the same synchronous - // block as store.dispatch() — so the queueMicrotask notification - // only fires once with the fully-populated state. - const { compactedReplay, liveJournal } = activeSession.replaySnapshot; - const replayEvents = [...compactedReplay, ...liveJournal]; - if ( - needsStoreReset && - !(shouldInjectReplaySnapshot && replayEvents.length > 0) - ) { - // Reset needed but no replay data (e.g. fresh session) — reset - // immediately since there is no dispatch to batch with. - store.reset(); - } - if (shouldInjectReplaySnapshot && replayEvents.length > 0) { - const replayOpts = { - ...eventOptionsRef.current, - suppressOwnUserEcho: false, - }; - const allUiEvents: DaemonUiEvent[] = []; - for (const replayEvent of replayEvents) { - try { - const replayUiEvents = normalizeAndFilterEvent( - replayEvent, - activeSession.clientId, - replayOpts, - setConnection, - { updateConnection: false }, - ); - allUiEvents.push( - ...filterDaemonUiEventsForTranscript( - replayEvent, - replayUiEvents, - addNotice, - ), - ); - if (replayEvent.type === 'turn_complete') { - const stopReason = - (replayEvent.data as DaemonTurnCompleteData | undefined) - ?.stopReason ?? 'end_turn'; - allUiEvents.push( - assistantDoneFromTurnEvent(replayEvent, stopReason), - ); - } else if (replayEvent.type === 'turn_error') { - allUiEvents.push( - assistantDoneFromTurnEvent(replayEvent, 'error'), - ); - } - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - addNotice({ - severity: 'warning', - category: 'protocol', - operation: 'normalize_event', - code: 'daemon.replay_event_malformed', - message: 'Skipped malformed replay event', - debugMessage: message, - recoverable: true, - }); - console.warn( - '[DaemonSessionProvider] skipped malformed replay event:', - error, - ); - } - } - if (needsStoreReset) { - store.reset(); - } - if (allUiEvents.length > 0) { - store.dispatch(allUiEvents); - bumpWorkspaceEventSignals(allUiEvents, setWorkspaceEventSignals); - } - for (const replayEvent of replayEvents) { - settleActivePromptFromTurnEvent( - activePromptsRef.current, - settledPromptsRef.current, - activeSession.sessionId, - replayEvent, - store, - setPromptStatus, - passiveAssistantDoneTimerRef, - { requireBoundPromptId: true }, - ); - } - setConnection((c) => ({ ...c, catchingUp: undefined })); - } - if (pendingLoadToResolve) { - pendingSessionLoadRef.current = undefined; - clearTimeout(pendingLoadToResolve.timeout); - if ( - skipNextCleanupDetachSessionIdRef.current === - activeSession.sessionId - ) { - skipNextCleanupDetachSessionIdRef.current = undefined; - } - pendingLoadToResolve.resolve(); - } let sawEvent = false; let resyncRequested = false; const requestEpochResetReload = () => {