fix(web-shell): cut mobile session-switch jank (memoized timeline signature, replay-first dispatch) (#6183)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* 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 <qwen-coder@alibabacloud.com>

* 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 <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
This commit is contained in:
qqqys 2026-07-03 05:33:34 +08:00 committed by GitHub
parent 2126474c28
commit 686a1371c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 174 additions and 128 deletions

View file

@ -1650,8 +1650,10 @@ function joinClassNames(
return result || undefined;
}
export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
function MessageList(
const EMPTY_SESSION_TIMELINE_ENTRIES: SessionTimelineEntry[] = [];
export const MessageList = memo(
forwardRef<MessageListHandle, MessageListProps>(function MessageList(
{
messages,
pendingApproval,
@ -1686,19 +1688,25 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
() => 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<MessageListHandle, MessageListProps>(
[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<MessageListHandle, MessageListProps>(
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<MessageListHandle, MessageListProps>(
entries={sessionTimelineEntries}
currentTurnId={currentTimelineTurnId}
currentRange={sessionTimelineRange}
hidden={!isSessionTimelineVisible}
hidden={!isSessionTimelineVisible || !hasEnoughSessionTimelineEntries}
onSelect={scrollToMessage}
/>
{useVirtualScroll ? (
@ -2706,5 +2712,5 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
)}
</div>
);
},
}),
);

View file

@ -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(<Harness />, { 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

View file

@ -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 = () => {