fix(web-shell): keep pagination-completed turns expanded (#10086)

* fix(web-shell): keep pagination-completed turns expanded

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): harden pagination keep-open against streaming cache and compact ids

* fix(web-shell): restore anchored scroll and per-load split-turn detection (#10086)

* fix(web-shell): reset pagination state across superseded loads and session switches (#10086)

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): skip pinned recap in split-turn head-change check (#10086)

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: 钉萁 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-08-27 02:19:46 +00:00
1 parent 1ca8cc5fdb
commit 3ca906ba7f
8 files changed
+1992 -9

No files matched your search

+1
View File
@@ -13054,6 +13054,7 @@ export function App({
const messageListContent = (
<LiveMessageList
ref={messageListRef}
sessionKey={connection.sessionId}
baselineBlocks={blocks}
baselineSummary={blockChangeSummary}
baselineMessages={messages}
@@ -9,6 +9,7 @@ import {
type WebShellCustomization,
} from '../customization';
import type { Message } from '../adapters/types';
import { summaryRunId } from './summaryRunId';
vi.mock('../WebShellContexts', async () => {
const { createContext } = await import('react');
@@ -243,7 +244,7 @@ describe('MessageItem tool group spacing', () => {
const synthetic = render(
<I18nProvider language="en">
<CompactModeContext.Provider value={true}>
{item(toolMsg('summary-agent-1'))}
{item(toolMsg(summaryRunId('agent-1')))}
</CompactModeContext.Provider>
</I18nProvider>,
);
@@ -18,6 +18,7 @@ import {
} from './messages/AssistantMessage';
import { SystemMessage } from './messages/SystemMessage';
import { ToolGroup } from './messages/ToolGroup';
import { isSummaryRunId } from './summaryRunId';
import { PlanMessage } from './messages/PlanMessage';
import { BtwMessage } from './messages/BtwMessage';
import { UserShellMessage } from './messages/UserShellMessage';
@@ -123,7 +124,7 @@ export const MessageItem = memo(function MessageItem({
<ToolGroup
tools={message.tools}
thoughts={message.thoughts}
compactSummary={compactMode && message.id.startsWith('summary-')}
compactSummary={compactMode && isSummaryRunId(message.id)}
pendingApproval={pendingApproval}
workspaceCwd={workspaceCwd}
isLocateFlashing={isLocateFlashing}
File diff suppressed because it is too large. Load diff
@@ -1133,6 +1133,7 @@ function collapseItems(
waitForUnmatchedAgentCompletions: boolean;
terminalBackgroundShellTaskIds: ReadonlySet<string>;
automaticallyExpandedAgentKeys: ReadonlySet<string>;
paginatedExpanded: ReadonlySet<string>;
enabled: boolean;
}> = {},
): DisplayItem[] {
@@ -1145,6 +1146,7 @@ function collapseItems(
opts.waitForUnmatchedAgentCompletions ?? true,
terminalBackgroundShellTaskIds: opts.terminalBackgroundShellTaskIds,
automaticallyExpandedAgentKeys: opts.automaticallyExpandedAgentKeys,
paginatedExpanded: opts.paginatedExpanded,
enabled: opts.enabled ?? true,
});
}
@@ -1220,6 +1222,50 @@ describe('applyTurnCollapse', () => {
});
});
it('keeps a turn expanded when its tail was shown before pagination completed its head', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeAssistantMessage('a1'),
makeUserMessage('u2'),
makeMultiToolGroup('g2'),
makeAssistantMessage('a2'),
]);
const out = collapseItems(items, {
paginatedExpanded: new Set(['u1']),
});
// The pagination-completed turn stays open (its steps are visible); the
// following complete turn still collapses as usual.
expect(rowIds(out)).toEqual([
'u1',
'tc-u1',
'g1',
'a1',
'u2',
'tc-u2',
'a2',
]);
expect(collapseOf(out, 0)).toMatchObject({
collapsed: false,
hiddenCount: 1,
});
expect(collapseOf(out, 4)).toMatchObject({ collapsed: true });
});
it('lets an explicit user toggle override the pagination keep-open', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeAssistantMessage('a1'),
]);
const out = collapseItems(items, {
paginatedExpanded: new Set(['u1']),
overrides: new Map([['u1', false]]),
});
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'a1']);
expect(collapseOf(out, 0)).toMatchObject({ collapsed: true });
});
it('keeps every row but still tags the head when the turn is expanded', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
@@ -42,6 +42,7 @@ import { formatContextTokens } from '../utils/formatTokenCount';
import { useWebShellPortalRoot } from '../portalRoot';
import { useTranscriptRenderMode } from '../transcriptRenderMode';
import { MessageItem } from './MessageItem';
import { summaryRunFirstMemberId, summaryRunId } from './summaryRunId';
import type { SessionContentGenerator } from './messages/AssistantMessage';
import { MessageTimestamp } from './MessageTimestamp';
import {
@@ -88,6 +89,13 @@ interface MessageListProps {
historyCapacityReached?: boolean;
historyPaginationError?: boolean;
onLoadOlderHistory?: (options?: { force?: boolean }) => Promise<void>;
/**
* Identity of the session whose transcript `messages` show. Block ids are
* per-session ordinals, so changing it resets every session-scoped UI state
* (collapse overrides, pagination keep-open, pending page snapshots, the
* scroll anchor) — a direct session switch never renders empty messages.
*/
sessionKey?: string;
transcriptBlockCount?: number;
transcriptActivity?: {
getSnapshot(): {
@@ -293,7 +301,7 @@ export interface SessionTimelineRange {
function isAgentOnlyToolGroup(msg: Message): boolean {
return (
msg.role === 'tool_group' &&
!msg.id.startsWith('summary-') &&
summaryRunFirstMemberId(msg.id) === undefined &&
msg.tools.length === 1 &&
isSubAgentToolCall(msg.tools[0])
);
@@ -302,7 +310,7 @@ function isAgentOnlyToolGroup(msg: Message): boolean {
function isBackgroundAgentOnlyToolGroup(msg: Message): boolean {
return (
msg.role === 'tool_group' &&
!msg.id.startsWith('summary-') &&
summaryRunFirstMemberId(msg.id) === undefined &&
msg.tools.length === 1 &&
isBackgroundSubAgentToolCall(msg.tools[0])
);
@@ -411,7 +419,7 @@ function mergeCompactToolGroups(
// Synthetic id so the aggregated group never collides with an original
// message key: React then remounts instead of carrying the expanded
// summary state into non-compact mode.
id: `summary-${run[0]!.id}`,
id: summaryRunId(run[0]!.id),
role: 'tool_group',
tools,
...(thoughts.length > 0 ? { thoughts } : {}),
@@ -430,7 +438,7 @@ function updateCompactStreamingThinkingTail(
const group = messages[messages.length - 1];
if (
group?.role !== 'tool_group' ||
!group.id.startsWith('summary-') ||
summaryRunFirstMemberId(group.id) === undefined ||
!group.thoughts?.length
) {
return undefined;
@@ -448,7 +456,9 @@ function updateCompactStreamingThinkingTail(
result[result.length - 1] = {
...group,
thoughts,
...(group.id === `summary-${tail.id}` ? { timestamp: tail.timestamp } : {}),
...(group.id === summaryRunId(tail.id)
? { timestamp: tail.timestamp }
: {}),
};
return result;
}
@@ -665,6 +675,14 @@ export interface ApplyTurnCollapseOptions {
*/
pendingApprovalCallId?: string | null;
includeSubagentToolUsageInMetrics?: boolean;
/**
* Turns kept expanded despite being complete. Two sources: pagination
* split-turn detection (the tail was on screen before pagination completed
* the user prompt), and the anchor fallback (a turn collapsed while a
* history anchor sat on it). Entries persist until the user toggles the
* turn or the screen is cleared; an explicit user toggle always wins.
*/
paginatedExpanded?: ReadonlySet<string>;
/** Master switch; when false the items pass through untouched. */
enabled: boolean;
}
@@ -982,6 +1000,44 @@ function isTurnStartMessage(message: Message): boolean {
return message.role === 'user' || message.role === 'user_shell';
}
// Find the turn head (user message id) owning the message referenced by a
// `msg:` row key, when the message is still present.
function turnIdOfMessageRow(
messages: Message[],
rowKey: string,
): string | undefined {
if (!rowKey.startsWith('msg:')) return undefined;
let messageId = rowKey.slice('msg:'.length);
// Compact-aggregate row keys embed the run's first member id. Resolve
// through it so the key still maps to its turn when looked up in the raw
// message list, including after a page extending the run re-keyed the
// aggregate.
messageId = summaryRunFirstMemberId(messageId) ?? messageId;
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message?.id !== messageId) continue;
for (let j = i; j >= 0; j--) {
const candidate = messages[j];
if (candidate && isTurnStartMessage(candidate)) return candidate.id;
}
return undefined;
}
return undefined;
}
// True when the given turn currently renders as a collapsed turn_collapse row.
function isTurnCollapsedInVisibleItems(
items: DisplayItem[],
turnId: string,
): boolean {
for (const item of items) {
if (item.type === 'turn_collapse' && item.turnCollapse.turnId === turnId) {
return item.turnCollapse.collapsed;
}
}
return false;
}
function timelineDetailSnippetForMessage(
message: Message,
t?: (key: string, vars?: Record<string, string | number>) => string,
@@ -1798,6 +1854,7 @@ export function applyTurnCollapse(
automaticallyExpandedAgentKeys,
pendingApprovalCallId,
includeSubagentToolUsageInMetrics = true,
paginatedExpanded,
enabled,
}: ApplyTurnCollapseOptions,
): DisplayItem[] {
@@ -1953,7 +2010,8 @@ export function applyTurnCollapse(
// turn is incomplete; otherwise it collapses once a newer turn starts. A
// step-less turn (e.g. a plain "hi" reply) has nothing to fold, so it stays
// expanded and shows a chevron-less metrics line. An explicit user toggle
// always wins.
// always wins; a turn whose tail was shown before pagination finished its
// head stays open so that content never vanishes mid-read.
const shouldStayOpen =
isActiveTurn ||
hasActiveAgent ||
@@ -1967,7 +2025,9 @@ export function applyTurnCollapse(
? true
: overrides.has(turnId)
? (overrides.get(turnId) as boolean)
: shouldStayOpen;
: paginatedExpanded?.has(turnId)
? true
: shouldStayOpen;
const collapsed = !expanded;
// Push the user message
result.push({
@@ -2727,6 +2787,7 @@ export const MessageList = memo(
forwardRef<MessageListHandle, MessageListProps>(function MessageList(
{
messages,
sessionKey,
terminalBackgroundShellTaskIds,
pendingApproval,
onShowContextDetail,
@@ -3262,6 +3323,20 @@ export const MessageList = memo(
const [collapseOverrides, setCollapseOverrides] = useState<
ReadonlyMap<string, boolean>
>(() => new Map());
// Turns kept expanded despite being complete. Two sources: pagination
// split-turn detection (tail was on screen before the user prompt
// arrived), and the anchor fallback (a turn collapsed while a history
// anchor sat on it). Entries persist until the user toggles the turn or
// the screen is cleared.
const [paginatedExpandedTurns, setPaginatedExpandedTurns] = useState<
ReadonlySet<string>
>(() => new Set());
// Pending split-turn detection snapshots keyed by load generation: a load
// can resolve SDK-side before its page commits to the transcript, letting
// a next load enqueue its own snapshot before the earlier page lands.
const pendingPaginationTurnCompares = useRef(
new Map<number, { ids: Set<string>; resolved: boolean }>(),
);
const [turnLayoutPending, startTurnLayoutTransition] = useTransition();
const turnLayoutTransitionStarted = useRef(false);
const turnLayoutRowTops = useRef(new Map<string, number>());
@@ -3337,11 +3412,34 @@ export const MessageList = memo(
useLayoutEffect(() => {
mergedMessageCountRef.current = mergedMessages.length;
}, [mergedMessages.length]);
const rawMessagesRef = useRef(messages);
useLayoutEffect(() => {
rawMessagesRef.current = messages;
}, [messages]);
const [
suppressOlderHistoryLoadingStatus,
setSuppressOlderHistoryLoadingStatus,
] = useState(false);
// A direct session switch never renders empty messages (the provider
// batches the store reset and the replay into one notification), so the
// /clear reset never runs for it. Block ids are per-session ordinals, so
// every session-scoped state must drop when the displayed session
// changes: stale keep-open and snapshot entries would collide with the
// new session's reused ids, and an old anchor would restore the previous
// session's scroll metrics into the new transcript.
const [trackedSessionKey, setTrackedSessionKey] = useState(sessionKey);
if (trackedSessionKey !== sessionKey) {
setTrackedSessionKey(sessionKey);
pendingPaginationTurnCompares.current.clear();
olderHistoryLoadGeneration.current += 1;
olderHistoryLoadInFlight.current = false;
olderHistoryRetryBlocked.current = false;
setOlderHistoryAnchor(null);
setCollapseOverrides((prev) => (prev.size ? new Map() : prev));
setPaginatedExpandedTurns((prev) => (prev.size ? new Set() : prev));
}
useEffect(() => {
if (!hasOlderHistory) {
olderHistoryRetryBlocked.current = false;
@@ -3408,6 +3506,7 @@ export const MessageList = memo(
turnFileChanges,
turnArtifacts,
turnScheduledTasks,
paginatedExpandedTurns,
] as const;
const cached = visibleItemsCache.current;
const currentTail = displayItems[displayItems.length - 1];
@@ -3459,6 +3558,7 @@ export const MessageList = memo(
automaticallyExpandedAgentKeys,
pendingApprovalCallId: pendingApproval?.toolCallId ?? null,
includeSubagentToolUsageInMetrics,
paginatedExpanded: paginatedExpandedTurns,
enabled: collapseEnabled,
});
let metricsApplied = false;
@@ -3539,6 +3639,7 @@ export const MessageList = memo(
turnFileChanges,
turnArtifacts,
turnScheduledTasks,
paginatedExpandedTurns,
]);
const virtualizerItems =
visibleItemsCache.current?.sourceMessages === messages
@@ -3848,6 +3949,15 @@ export const MessageList = memo(
});
turnLayoutTransitionStarted.current = true;
startTurnLayoutTransition(() => {
// An explicit toggle is the user's own decision: it wins over the
// pagination keep-open and returns the turn to normal collapse
// semantics.
setPaginatedExpandedTurns((prev) => {
if (!prev.has(turnId)) return prev;
const next = new Set(prev);
next.delete(turnId);
return next;
});
setCollapseOverrides((prev) => {
const next = new Map(prev);
next.set(turnId, nextExpanded);
@@ -4069,6 +4179,40 @@ export const MessageList = memo(
olderHistoryAnchor.rowKey &&
!hasVisibleRowKey(olderHistoryAnchor.rowKey)
) {
// The anchor row can vanish because its turn collapsed while the page
// was loading (a turn completed by pagination). Re-expand that turn so
// the anchor can be restored instead of dropping the reader's position.
const anchorTurnId = turnIdOfMessageRow(
rawMessagesRef.current,
olderHistoryAnchor.rowKey,
);
if (
anchorTurnId !== undefined &&
isTurnCollapsedInVisibleItems(visibleItems, anchorTurnId)
) {
// Re-expanding only helps when it can actually take effect: an
// explicit user collapse override wins over the keep-open, and a
// turn already marked keep-open yet still hidden cannot be helped.
// Fall through and drop the anchor in those cases, or pagination
// state stays pinned in-flight forever (every later load bails at
// the in-flight guard).
if (
collapseOverrides.get(anchorTurnId) !== false &&
!paginatedExpandedTurns.has(anchorTurnId)
) {
setPaginatedExpandedTurns((prev) =>
prev.has(anchorTurnId) ? prev : new Set(prev).add(anchorTurnId),
);
// A page extending the turn's aggregated run re-keys the captured
// row, so anchor on the turn's user row instead: the re-expanded
// turn always renders it, and the restore path then adjusts the
// scroll position rather than dropping the anchor.
setOlderHistoryAnchor((anchor) =>
anchor ? { ...anchor, rowKey: `msg:${anchorTurnId}` } : anchor,
);
return;
}
}
if (
olderHistoryAnchor.generation === olderHistoryLoadGeneration.current
) {
@@ -4163,6 +4307,8 @@ export const MessageList = memo(
mergedMessages.length,
olderHistoryAnchor,
visibleItems,
collapseOverrides,
paginatedExpandedTurns,
]);
const virtualItems = virtualizer.getVirtualItems();
const totalVirtualSize = virtualizer.getTotalSize();
@@ -4517,7 +4663,20 @@ export const MessageList = memo(
: {})),
});
try {
// Remember which messages were on screen before the page arrives so
// a turn the daemon split across pages (tail shown first, head
// completing later) can be detected and kept expanded.
pendingPaginationTurnCompares.current.set(generation, {
ids: new Set(rawMessagesRef.current.map((message) => message.id)),
resolved: false,
});
await onLoadOlderHistory(force ? { force: true } : undefined);
// The snapshot belongs to this load regardless of anchor state: a
// superseding anchor drop bumps the generation mid-flight and would
// otherwise orphan the entry unresolved at the FIFO head, wedging
// every later split-turn detection.
const compare = pendingPaginationTurnCompares.current.get(generation);
if (compare) compare.resolved = true;
if (generation === olderHistoryLoadGeneration.current) {
setOlderHistoryAnchor((anchor) =>
anchor?.generation === generation
@@ -4526,6 +4685,9 @@ export const MessageList = memo(
);
}
} catch {
// A failed load commits no page, so its snapshot is always safe to
// drop, including when the load was superseded mid-flight.
pendingPaginationTurnCompares.current.delete(generation);
if (generation === olderHistoryLoadGeneration.current) {
olderHistoryRetryBlocked.current = true;
olderHistoryLoadInFlight.current = false;
@@ -4553,6 +4715,86 @@ export const MessageList = memo(
void loadOlderHistory(true, true);
}, [loadOlderHistory]);
// After a pagination page commits, mark turns whose user prompt just
// arrived while their tail was already on screen (the daemon split the
// turn across pages). `applyTurnCollapse` keeps those expanded so the
// content the user is reading never silently collapses.
useLayoutEffect(() => {
const pending = pendingPaginationTurnCompares.current;
if (pending.size === 0) return;
// Skip locally-injected messages (recap ids `local-*`): /recap re-pins
// them at index 0 on every render and pagination cannot move them, so
// they never reflect a page landing and would pin the head-change check
// forever, wedging split-turn detection for the rest of the session.
const first = messages.find(
(message) => !message?.id.startsWith('local-'),
);
if (!first) return;
const oldest = pending.entries().next().value;
if (!oldest) return;
const [generation, compare] = oldest;
// A page prepends older messages at the head; live messages that land
// while the fetch is in flight append at the tail. Wait for the head to
// change so a mid-flight live update cannot consume the snapshot before
// the page commits (which would silently skip the split-turn detection).
// Compare raw message ids, not the compact-mode aggregate ids: a page
// that extends an aggregated run re-keys the synthetic summary id, but
// the run members keep their daemon ids.
if (compare.ids.has(first.id)) return;
const newTurnIds: string[] = [];
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (
!message ||
!isTurnStartMessage(message) ||
compare.ids.has(message.id)
) {
continue;
}
let tailShown = false;
for (let j = i + 1; j < messages.length; j++) {
const next = messages[j];
if (isTurnStartMessage(next)) break;
if (compare.ids.has(next.id)) {
tailShown = true;
break;
}
}
if (tailShown) newTurnIds.push(message.id);
}
if (newTurnIds.length === 0) {
// A changed head while the load is resolved is the page landing, so
// consume the snapshot even when it completed no split turn —
// otherwise every later transcript update rescans it for the rest of
// the session. While the load is still in flight a head change is
// not the page (a transcript reload or session/branch switch);
// discarding the snapshot there would let the page commit that
// follows silently skip detection — the split turn collapses again,
// the bug this code exists to fix.
if (!compare.resolved) return;
} else {
setPaginatedExpandedTurns((prev) => {
let next: Set<string> | null = null;
for (const id of newTurnIds) {
if (!prev.has(id)) {
next ??= new Set(prev);
next.add(id);
}
}
return next ?? prev;
});
}
pending.delete(generation);
// The page that just committed is visible now: fold its ids into any
// newer pending snapshot so that load's detection still recognizes the
// tail a mid-turn page boundary showed.
for (const remaining of pending.values()) {
for (const message of messages) {
if (message) remaining.ids.add(message.id);
}
}
}, [messages]);
useEffect(() => {
const pendingGeneration = pendingOlderHistoryTopLoad.current;
if (
@@ -4821,7 +5063,12 @@ export const MessageList = memo(
pendingBottomFollowAfterCooldown.current = false;
setShouldFollow(true);
pendingScrollRef.current = null;
// Drop the in-flight pagination snapshots too: without this a stale
// pre-clear snapshot survives into the next session and can mislabel
// a complete turn as keep-open (block ids are per-session ordinals).
pendingPaginationTurnCompares.current.clear();
setCollapseOverrides((prev) => (prev.size ? new Map() : prev));
setPaginatedExpandedTurns((prev) => (prev.size ? new Set() : prev));
}
}, [messages.length, setShouldFollow]);
@@ -427,9 +427,11 @@
.lineName {
flex-shrink: 0;
font-size: 13px;
}
.lineArg {
font-size: 13px;
min-width: 0;
flex: 1;
overflow: hidden;
@@ -0,0 +1,15 @@
/**
* Synthetic ids for compact-mode aggregated tool runs.
* `mergeCompactToolGroups` builds them via `summaryRunId`; render paths detect
* them through the shared predicate so the prefix scheme lives in one place.
*/
export const SUMMARY_RUN_ID_PREFIX = 'summary-';
export const summaryRunId = (firstMemberId: string): string =>
`${SUMMARY_RUN_ID_PREFIX}${firstMemberId}`;
export const isSummaryRunId = (id: string): boolean =>
id.startsWith(SUMMARY_RUN_ID_PREFIX);
export const summaryRunFirstMemberId = (id: string): string | undefined =>
isSummaryRunId(id) ? id.slice(SUMMARY_RUN_ID_PREFIX.length) : undefined;