mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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 <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:
parent
2126474c28
commit
686a1371c3
3 changed files with 174 additions and 128 deletions
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue