Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
35b7051
fix(ui): bottom-align short VP content so blank space is at top (#9300)
qwen-code-dev-bot Aug 17, 2026
e4ab668
fix(ui): align VP design comment and tests with bottom-aligned layout…
qwen-code-dev-bot Aug 17, 2026
53c0178
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 18, 2026
c26ef42
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 19, 2026
a5403de
fix(ui): stop re-stick from bottom-aligning top-anchored VP lists (#9…
qwen-code-dev-bot Aug 20, 2026
23988d6
fix(ui): stop scroll and shrink paths from re-engaging VP sticking (#…
qwen-code-dev-bot Aug 20, 2026
d3abb95
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 20, 2026
dd0c773
Merge remote-tracking branch 'origin/main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 20, 2026
05c073f
fix(ui): keep clamp-parked VP positions from re-engaging sticking (#9…
qwen-code-dev-bot Aug 20, 2026
5e38caf
fix(ui): install the VP clamp mark only when the clamp moved the anch…
qwen-code-dev-bot Aug 20, 2026
047021a
fix(ui): let VP auto-follow re-engage after scrolled-away fitting-ban…
qwen-code-dev-bot Aug 20, 2026
c68e20b
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 20, 2026
fa7f5c9
fix(ui): close resize edges that defeat the VP sticking drop and clam…
qwen-code-dev-bot Aug 20, 2026
9cacb01
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 21, 2026
c7e46dc
fix(ui): re-engage VP follow on any fit-boundary crossing, not just l…
qwen-code-dev-bot Aug 21, 2026
754d17b
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 21, 2026
ee6f77e
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 22, 2026
839a873
fix(ui): keep VP follow alive across dataset swaps and banner-only ov…
qwen-code-dev-bot Aug 22, 2026
6c1ada4
fix(ui): reset VP scroll state on session swap, heal park-mark follow…
qwen-code-dev-bot Aug 22, 2026
9b1afb2
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 22, 2026
cf04b3f
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 23, 2026
d530b3d
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 24, 2026
be2358f
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 24, 2026
9361c28
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 25, 2026
4960a7e
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 25, 2026
d0ce0bb
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 26, 2026
d21e8f5
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 27, 2026
a95525e
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 28, 2026
0e5a87e
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 29, 2026
efd5ca1
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 29, 2026
5599fb9
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 30, 2026
bc395c4
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Aug 31, 2026
88d4d10
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 1, 2026
3047e02
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 1, 2026
0bc876f
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 2, 2026
185e3b3
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 2, 2026
cf43c2c
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 3, 2026
98b7ddd
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 3, 2026
3904c6d
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 4, 2026
57afd05
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 5, 2026
6705926
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 6, 2026
a03f6ca
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 6, 2026
45f59b6
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 7, 2026
829564b
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 8, 2026
a1e6801
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 9, 2026
e5dd22d
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 9, 2026
31fcd02
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 10, 2026
6796923
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 10, 2026
38559cb
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 11, 2026
a6cf115
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 11, 2026
ff8da3b
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 12, 2026
f648122
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 13, 2026
633924d
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 14, 2026
c402ce2
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 15, 2026
969ff98
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 16, 2026
59d87dc
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 17, 2026
cfd844b
Merge branch 'main' into fix/vp-bottom-align-9300
qwen-code-dev-bot Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions packages/cli/src/ui/components/MainContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { useEffect } from 'react';
import type React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render } from 'ink-testing-library';
Expand All @@ -27,6 +28,7 @@ const staticItemsSpy = vi.fn();
const historyItemDisplayPropsSpy = vi.fn();
const appHeaderSpy = vi.fn();
const scrollableListPropsSpy = vi.fn();
const scrollableListMountSpy = vi.fn();
// Records every <Box> render's props so tests can assert layout props
// (e.g. the pending-region maxHeight backstop) without coupling to ink's
// Yoga internals.
Expand Down Expand Up @@ -117,6 +119,9 @@ vi.mock('./shared/ScrollableList.js', async () => {
renderItem: (info: { item: { id: number }; index: number }) => unknown;
}) => {
scrollableListPropsSpy(props);
useEffect(() => {
scrollableListMountSpy();
}, []);
// Drive renderItem once per item so historyItemDisplayPropsSpy fires —
// mirrors what the real VirtualizedList does for the visible window.
return (
Expand Down Expand Up @@ -888,6 +893,47 @@ describe('<MainContent />', () => {
expect(lastFrame()).toMatch(/VP_ITEM:1[\s\S]*VP_ITEM:2/);
});

it('remounts the VP list when the session id changes (/clear, /resume) (#9305)', () => {
// /clear and /resume each start a new session (startNewSession swaps
// the sessionId), and the whole dataset is replaced with it. The VP
// list must remount on that boundary so carried scroll state — park
// marks, sticking, the anchor — resets by construction instead of
// leaking into the new session (#9305 review R18-1). Same-session
// re-renders must NOT remount: scroll state survives ordinary
// streaming updates.
scrollableListMountSpy.mockClear();

const vpState = (sessionId: string) =>
createUIState({
useTerminalBuffer: true,
history: [{ id: 1, type: 'user', text: 'hello' }],
sessionStats: {
sessionId,
lastPromptTokenCount: 0,
} as UIState['sessionStats'],
});
const wrap = (uiState: UIState) => (
<AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider value={uiState}>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</AppContext.Provider>
);

const { rerender } = render(wrap(vpState('session-A')));
expect(scrollableListMountSpy).toHaveBeenCalledTimes(1);

rerender(wrap(vpState('session-A')));
expect(scrollableListMountSpy).toHaveBeenCalledTimes(1);

rerender(wrap(vpState('session-B')));
expect(scrollableListMountSpy).toHaveBeenCalledTimes(2);
});

// Shared fixtures for the #9420 collapse tests. A tool batch renders
// twice transiently — the committed history copy plus the live pending
// copy — and both copies carry the same scheduler-minted `batchId`.
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/ui/components/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export const MainContent = ({ footerRef }: MainContentProps) => {
staticAreaMaxItemHeight,
availableTerminalHeight,
historyRemountKey,
sessionStats,
} = uiState;

// Filter out items whose display is suppressed (e.g. /history collapse).
Expand Down Expand Up @@ -511,6 +512,12 @@ export const MainContent = ({ footerRef }: MainContentProps) => {
return (
<OverflowProvider>
<ScrollableList
// Session id is the dataset identity: /clear and /resume each
// start a new session and replace the whole history. Remount the
// list on that boundary so carried scroll state (anchor,
// sticking, park marks) resets by construction instead of
// leaking into the new dataset (#9305 review R18-1).
key={sessionStats.sessionId}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R42-1: [fails-closed] [regression] (new this round — confirmed by an executed end-to-end probe at a03f6ca63a; the underlying key-scoping gap was recorded as round-28 deferral D28-1 at Suggestion level, and is escalated here because the frame outcome is now measured rather than hand-traced) The session id is not the whole dataset identity: a same-session /resume replaces the entire history without changing sessionStats.sessionId, so the list is not remounted and the positional reAnchorClampMark, the carried anchor, sticking and the h-<id> height cache all carry into the swapped dataset — falsifying the invariant this diff states two files away at VirtualizedList.tsx:418-419, "Dataset swaps (/clear, /resume) never reach the mark because MainContent keys the list by session and remounts it."

handleResume(sessionId) has no same-id early-out (useResumeCommand.ts:101-241): it calls startNewSession(sessionId) with the selected id at :235, then clearItems(); loadHistory(uiHistoryItems) at :239-241 in the same synchronous block. When the user picks the session they are already in — a path AppContainer.tsx:1858-1860 explicitly handles, "same-id resume (no sessionId change → no effect re-arm)" — createDefaultStats(sessionId) produces the same string, React sees an unchanged key, and no remount happens. Two facts make the carried state survive rather than fall out: item 0 is always the constant-key banner sentinel (VP_BANNER_ITEM, key 'vp-banner', MainContent.tsx:110-127), so a mark at index 0 validates against any dataset, and history keys are h-${item.id} while the rebuilt history re-mints ids from 1, so the height cache carries too and the mark now sits on a different message. Measured outcome: the resumed session opens frozen on the pre-resume banner rows with zero resumed items visible, and the first streamed reply of the resumed session never reaches the screen — while a different-id resume of the same data (which does remount) shows the resumed tail correctly, and the merge base follows throughout.

Witness:

PR 0-live-conversation frame=["b10".."b14","m1".."m5"] scrollTop=10 scrollHeight=20 idx=5
PR 1-clearItems-park frame=["b5".."b14"] scrollTop=5 scrollHeight=15 idx=0 (mark {0,5}, allowFollow false)
PR 2-same-id-resume-swap(12) frame=["b5".."b14"] scrollTop=5 scrollHeight=26 idx=0 seesAnyResumedItem=0
PR 3-resumed-reply(m32) frame=["b5".."b14"] scrollTop=5 scrollHeight=27 idx=0 seesReply(m32)=FALSE
PR MOUNT 3-remounted-same-data frame=["m23".."m32"] seesReply(m32)=TRUE <- what a different-id resume does
BASE 2-same-id-resume-swap(12) frame=["m22".."m31"] scrollTop=16 idx=11
BASE 3-resumed-reply(m32) frame=["m23".."m32"] scrollTop=17 idx=12 seesReply=TRUE

The PR MOUNT arm is the discriminator: the same resumed dataset renders correctly when the list is remounted, so the freeze is the missing remount and not the dataset. One limit on this evidence, stated rather than hidden: the probe drives VirtualizedList directly, modelling the swap as one batched dataset replacement with the banner held at index 0 — which is what clearItems(); loadHistory() in one synchronous block produces — but it does not drive the real DialogManagerhandleResume path (DialogManager.tsx:534), so the frame outcome is measured and the product wiring is code-read.

Key on the dataset-replacement signal /resume already emits rather than on session identity alone — remount?.() (refreshStatic) bumps historyRemountKey at the end of both the resume and branch flows — or invalidate reAnchorClampMark inside VirtualizedList when dataset identity changes instead of relying on the host to remount. Whichever is chosen, the comment at VirtualizedList.tsx:418-419 and the three test comments that assert the opposite wiring (VirtualizedList.test.tsx:301, :1033-1034, :1187-1188) need correcting in the same change, since they are what tells the next reader that this path is impossible.

The fix must not violate the VP path's deliberate refusal of historyRemountKey — MainContent.tsx:255-262 states it ignores those bumps on purpose ("chunked-replay reset for VP users so a Ctrl+O / model-change bump"), so folding that key into the VP list key also remounts on every Ctrl+O and model switch, discarding the measured-height cache and the user's scroll position there; if that cost is unwanted, invalidate the mark in VirtualizedList instead.

Please extend the new MainContent.test.tsx test remounts the VP list when the session id changes (/clear, /resume) (#9305) with the same-id swap it currently omits: rerender with an unchanged sessionId but a replaced history, asserting scrollableListMountSpy goes to 2 (or, for the in-component fix, that a mark carried at index 0 no longer validates against the swapped dataset). With the key as shipped the spy stays at 1 and that assertion is red.

中文说明

R42-1:[fails-closed] [regression](本轮新发现——在 a03f6ca63a 上由已执行的端到端探针确认;其底层的「键作用域」缺口曾以第 28 轮延后项 D28-1 记录为 Suggestion 级,此处升级是因为帧结果现在由实测得出而非手工推演)会话 id 并不构成完整的数据集身份:同会话的 /resume 会替换整段历史却不改变 sessionStats.sessionId,因此列表不会重挂载,位置型的 reAnchorClampMark、承载的锚点、贴底状态与 h-<id> 高度缓存都会带入被替换的数据集——这与本 diff 在另一文件中声明的不变量相矛盾,见 VirtualizedList.tsx:418-419:「Dataset swaps (/clear, /resume) never reach the mark because MainContent keys the list by session and remounts it.」

故障场景:handleResume(sessionId) 没有同 id 的提前返回(useResumeCommand.ts:101-241):它在 :235 以被选中的 id 调用 startNewSession(sessionId),随后在 :239-241 于同一同步块中执行 clearItems(); loadHistory(uiHistoryItems)。当用户选择自己当前所在的会话时——AppContainer.tsx:1858-1860 明确处理了该路径("same-id resume (no sessionId change → no effect re-arm)")——createDefaultStats(sessionId) 产生相同字符串,React 看到键未变,于是不发生重挂载。有两个事实使被承载的状态存活下来而非失效:索引 0 永远是常量键的 banner 哨兵(VP_BANNER_ITEM,键 'vp-banner',MainContent.tsx:110-127),因此位于索引 0 的标记对任何数据集都校验通过;而历史条目的键是 h-${item.id},重建的历史又从 1 开始重新生成 id,因此高度缓存同样被带入,且该标记现在落在另一条消息上。实测结果:恢复后的会话打开时冻结在恢复前的 banner 行上,可见的恢复条目数为,且恢复后会话的第一条流式回复永远不会出现在屏幕上——而对相同数据执行一次不同 id 的恢复(会重挂载)则正确显示恢复后的末尾,merge base 全程正常跟随。

证据见上方英文部分的探针输出:PR MOUNT 臂是判别依据——同一份恢复数据在列表被重挂载时渲染正确,因此冻结的原因是缺失的重挂载,而不是数据本身。对该证据的一点限制,明确说明而非隐藏:探针直接驱动 VirtualizedList,把替换建模为一次批处理的数据集整体替换并保持 banner 位于索引 0——这正是同一同步块中 clearItems(); loadHistory() 所产生的形态——但它没有驱动真实的 DialogManagerhandleResume 路径(DialogManager.tsx:534),所以帧结果是实测的,而产品接线是代码阅读得出的。

建议修复:以 /resume 本身已经发出的「数据集被替换」信号为键,而不仅以会话身份为键——remount?.()refreshStatic)会在 resume 与 branch 两个流程末尾提升 historyRemountKey——或者在 VirtualizedList 内部于数据集身份变化时使 reAnchorClampMark 失效,而不是依赖宿主重挂载。无论选择哪种,都需在同一次改动中修正 VirtualizedList.tsx:418-419 的注释以及三处断言相反接线的测试注释(VirtualizedList.test.tsx:301、:1033-1034、:1187-1188),因为正是它们告诉后来的读者这条路径不可能发生。

修复约束:不得违反 VP 路径对 historyRemountKey 的刻意拒绝——MainContent.tsx:255-262 说明 VP 路径是有意忽略这些提升的("chunked-replay reset for VP users so a Ctrl+O / model-change bump"),因此把该键并入 VP 列表键会导致每次 Ctrl+O 与模型切换也重挂载,在那里丢弃已测量的高度缓存与用户的滚动位置;若不愿承担该代价,应改为在 VirtualizedList 内部使标记失效。

修复验证:请扩展 MainContent.test.tsx 中新增的 remounts the VP list when the session id changes (/clear, /resume) (#9305) 用例,补上它当前遗漏的同 id 替换:在 sessionId 不变但 history 被替换的情况下重渲染,断言 scrollableListMountSpy 变为 2(若采用组件内修复,则断言位于索引 0 的承载标记不再对替换后的数据集校验通过)。按当前提交的键,该 spy 会停在 1,断言为红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R42-1: [fails-closed] [regression] (still stands — re-confirmed this round by code identity, and the core-config fact re-read at this commit) The session id is not the whole dataset identity: a same-session /resume replaces the entire history without changing sessionStats.sessionId, so the list is not remounted and the positional reAnchorClampMark, the carried anchor, sticking and the h-<id> height cache all carry into the swapped dataset — falsifying the invariant this diff states two files away at VirtualizedList.tsx:419-420, "Dataset swaps (/clear, /resume) never reach the mark because MainContent keys the list by session and remounts it."

handleResume(sessionId) has no same-id early-out (useResumeCommand.ts:101-241): it calls startNewSession(sessionId) with the selected id at :235, then clearItems(); loadHistory(uiHistoryItems) at :239-241 in the same synchronous block. When the user picks the session they are already in — a path AppContainer.tsx:1858-1860 explicitly handles, "same-id resume (no sessionId change → no effect re-arm)" — createDefaultStats(sessionId) produces the same string, React sees an unchanged key, and no remount happens. Two facts make the carried state survive rather than fall out: item 0 is always the constant-key banner sentinel (VP_BANNER_ITEM, key 'vp-banner', MainContent.tsx:110-127), so a mark at index 0 validates against any dataset; and history keys are h-${item.id} while the rebuilt history re-mints ids from 1, so the height cache carries too and the mark now sits on a different message. Measured outcome: the resumed session opens frozen on the pre-resume banner rows with zero resumed items visible, and the first streamed reply of the resumed session never reaches the screen — while a different-id resume of the same data (which does remount) shows the resumed tail correctly, and the merge base follows throughout.

Witness (round 42's executed end-to-end probe):

PR 0-live-conversation frame=["b10".."b14","m1".."m5"] scrollTop=10 scrollHeight=20 idx=5
PR 1-clearItems-park frame=["b5".."b14"] scrollTop=5 scrollHeight=15 idx=0 (mark {0,5}, allowFollow false)
PR 2-same-id-resume-swap(12) frame=["b5".."b14"] scrollTop=5 scrollHeight=26 idx=0 seesAnyResumedItem=0
PR 3-resumed-reply(m32) frame=["b5".."b14"] scrollTop=5 scrollHeight=27 idx=0 seesReply(m32)=FALSE
PR MOUNT 3-remounted-same-data frame=["m23".."m32"] seesReply(m32)=TRUE <- what a different-id resume does
BASE 2-same-id-resume-swap(12) frame=["m22".."m31"] scrollTop=16 idx=11
BASE 3-resumed-reply(m32) frame=["m23".."m32"] scrollTop=17 idx=12 seesReply=TRUE

The PR MOUNT arm is the discriminator: the same resumed dataset renders correctly when the list is remounted, so the freeze is the missing remount and not the dataset. One limit on this evidence, stated rather than hidden: the probe drives VirtualizedList directly, modelling the swap as one batched dataset replacement with the banner held at index 0 — which is what clearItems(); loadHistory() in one synchronous block produces — but it does not drive the real DialogManagerhandleResume path (DialogManager.tsx:534), so the frame outcome is measured and the product wiring is code-read. Re-verified at this commit: const nextSessionId = sessionId ?? randomUUID(); with "Resuming the session the user is already in keeps the same id" and const isSessionTransition = nextSessionId !== previousSessionId; (packages/core/src/config/config.ts:4470-4474).

Key on the dataset-replacement signal /resume already emits rather than on session identity alone — remount?.() (refreshStatic) bumps historyRemountKey at the end of both the resume and branch flows — or invalidate reAnchorClampMark inside VirtualizedList when dataset identity changes instead of relying on the host to remount. Whichever is chosen, the comment at VirtualizedList.tsx:419-420 and the three test comments that assert the opposite wiring (VirtualizedList.test.tsx:301, :1033-1034, :1187-1188) need correcting in the same change, since they are what tells the next reader that this path is impossible.

The fix must not violate the VP path's deliberate refusal of historyRemountKey — MainContent.tsx:255-262 states it ignores those bumps on purpose ("chunked-replay reset for VP users so a Ctrl+O / model-change bump"), so folding that key into the VP list key also remounts on every Ctrl+O and model switch, discarding the measured-height cache and the user's scroll position there; if that cost is unwanted, invalidate the mark in VirtualizedList instead. Any rewording must also keep same-id resume classified as a non-transition (config.ts:4474).

Please extend the new MainContent.test.tsx test remounts the VP list when the session id changes (/clear, /resume) (#9305) with the same-id swap it currently omits: rerender with an unchanged sessionId but a replaced history, asserting scrollableListMountSpy goes to 2 (or, for the in-component fix, that a mark carried at index 0 no longer validates against the swapped dataset). With the key as shipped the spy stays at 1 and that assertion is red.

中文说明

R42-1:[fails-closed] [regression](仍然存在——本轮以代码同一性重新确认,并在该提交上重读了 core 配置事实)会话 id 并不构成完整的数据集身份:同会话的 /resume 会替换整段历史却不改变 sessionStats.sessionId,因此列表不会重挂载,位置型的 reAnchorClampMark、承载的锚点、贴底状态与 h-<id> 高度缓存都会带入被替换的数据集——这与本 diff 在另一文件中声明的不变量相矛盾,见 VirtualizedList.tsx:419-420。

故障场景:handleResume(sessionId) 没有同 id 的提前返回(useResumeCommand.ts:101-241):它在 :235 以被选中的 id 调用 startNewSession(sessionId),随后在 :239-241 于同一同步块中执行 clearItems(); loadHistory(uiHistoryItems)。当用户选择自己当前所在的会话时——AppContainer.tsx:1858-1860 明确处理了该路径——createDefaultStats(sessionId) 产生相同字符串,React 看到键未变,于是不发生重挂载。有两个事实使被承载的状态存活下来而非失效:索引 0 永远是常量键的 banner 哨兵(VP_BANNER_ITEM,键 'vp-banner'),因此位于索引 0 的标记对任何数据集都校验通过;而历史条目的键是 h-${item.id},重建的历史又从 1 开始重新生成 id,因此高度缓存同样被带入,且该标记现在落在另一条消息上。实测结果:恢复后的会话打开时冻结在恢复前的 banner 行上,可见的恢复条目数为,且恢复后会话的第一条流式回复永远不会出现在屏幕上——而对相同数据执行一次不同 id 的恢复(会重挂载)则正确显示恢复后的末尾,merge base 全程正常跟随。

证据见上方英文部分的探针输出:PR MOUNT 臂是判别依据。对该证据的一点限制,明确说明而非隐藏:探针直接驱动 VirtualizedList,没有驱动真实的 DialogManagerhandleResume 路径,所以帧结果是实测的,而产品接线是代码阅读得出的。本轮在该提交上重新确认:const nextSessionId = sessionId ?? randomUUID(); 与「Resuming the session the user is already in keeps the same id」(packages/core/src/config/config.ts:4470-4474)。

建议修复:以 /resume 本身已经发出的「数据集被替换」信号为键,而不仅以会话身份为键;或者在 VirtualizedList 内部于数据集身份变化时使 reAnchorClampMark 失效。无论选择哪种,都需在同一次改动中修正 VirtualizedList.tsx:419-420 的注释以及三处断言相反接线的测试注释(VirtualizedList.test.tsx:301、:1033-1034、:1187-1188)。

修复约束:不得违反 VP 路径对 historyRemountKey 的刻意拒绝——MainContent.tsx:255-262;任何改写也必须保持同 id 恢复被归类为非生命周期转换(config.ts:4474)。

修复验证:请扩展 MainContent.test.tsx 中新增的 remounts the VP list when the session id changes (/clear, /resume) (#9305) 用例,补上它当前遗漏的同 id 替换:在 sessionId 不变但 history 被替换的情况下重渲染,断言 scrollableListMountSpy 变为 2。按当前提交的键,该 spy 会停在 1,断言为红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R42-1: [fails-closed] [regression] (still stands — the four PR files are byte-identical between the commit round 43 reviewed and this head, and all four in-place paths were re-verified at this commit) The session id is not the whole dataset identity. Four live paths replace the entire VP history without changing sessionStats.sessionId, so the list is not remounted and the positional reAnchorClampMark, the carried anchor, sticking and the h-<id> height cache all carry into the swapped dataset — falsifying the invariant this diff states two files away at VirtualizedList.tsx:418-419, "Dataset swaps (/clear, /resume) never reach the mark because MainContent keys the list by session and remounts it."

On those paths the viewport lands on an arbitrary index of the rebuilt history instead of the session's live bottom, and the in-component clamp and swapEntry logic — written on the premise that swaps remount — is the only defence. The same commit documents both sides of that contradiction: VirtualizedList.test.tsx:301, :1034 and :1188 assert "/clear does not remount the list (no key on ScrollableList in MainContent)" and "ScrollableList carries no key", while :1268 and VirtualizedList.tsx:418-419 assert the reverse. All 53 tests pass either way, because they exercise VirtualizedList in isolation and never read MainContent, so the contradiction is invisible at test time. The next maintainer treats the in-place-swap arms as production-dead on the strength of a comment that is wrong for four paths, and removes or stops maintaining them.

Witness — all four paths verified at this commit:

Ctrl+L               AppContainer.tsx:3494-3502 handleClearScreen - clearItems() + remountStaticHistory(),
                     whose own comment says "Ctrl-L wipes the emitted INFO item without a session switch";
                     remountStaticHistory (:1559-1561) only bumps historyRemountKey, used as a React key
                     ONLY on the legacy <Static> branch (MainContent.tsx:577), never on the VP ScrollableList
same-id /resume      useResumeCommand.ts:235 startNewSession(sessionId) with the CURRENT id, then
                     :239-241 clearItems(); loadHistory(uiHistoryItems) - corroborated by
                     AppContainer.tsx:1858-1861 "same-id resume (no sessionId change -> no effect re-arm)"
/restore             restoreCommand.ts:148 loadHistory(toolCallData.history), no session change
/history expand-now  historyCommand.ts:83-84 loadHistory(updated); refreshStatic()

Severity note, recorded rather than acted on: this round's arm A/B found that remounting does NOT restore follow (arm A in-place and arm B remount are both dead). That measurement is confounded rather than exculpatory — arm B lands on a banner-only dataset, which is R40-1's entrance (b), so the run measures R40-1 instead of isolating this entry's harm. Settling whether the Critical rests on the key gap alone needs a shape where remounting does change the outcome, which could not be constructed this round.

State the invariant once, correctly, and name the paths the key does not cover: session id is the dataset identity for /clear, /branch and cross-session /resume, while /restore, /history expand-now, Ctrl+L and same-session /resume replace the history without a session change and rely on the list's own re-anchor and clamp-mark logic. Then correct the three test comments at VirtualizedList.test.tsx:301, :1034 and :1188, and soften VirtualizedList.tsx:418-419 from "never reach the mark" to "remount for a new session id; a same-id resume still swaps in place, which the arms below defend". Covering the remaining in-place wipes needs a dataset-identity signal rather than a session id.

The fix must not switch the key to historyRemountKey: remountStaticHistory (AppContainer.tsx:1559-1560) is also called on every terminal-width shrink in VP mode (AppContainer.tsx:1596-1605, twice — immediately and again via setTimeout(remountStaticHistory, 650)) and by refreshStatic on model or renderMode change, so keying the VP list on it would drop the scroll anchor and the measured-height cache on every narrowing, every Ctrl+O and every model switch.

Fix witness: extend the new remounts the VP list when the session id changes (/clear, /resume) (#9305) case in MainContent.test.tsx with a rerender that empties history while sessionStats.sessionId stays 'session-A', asserting scrollableListMountSpy fires again only if the key is meant to cover in-place wipes; today it does not fire, which is the gap.

中文说明

[Critical] R42-1:[fails-closed] [regression](依然成立 —— 本轮 head 与第 43 轮所审查的 commit 之间四个文件逐字节相同,且四条就地替换路径均已在本次 commit 上重新核实)session id 并不构成完整的数据集身份。有四条实际存在的路径会在 sessionStats.sessionId 不变的情况下替换整个 VP 历史,因此列表不会 remount,位置式的 reAnchorClampMark、携带的 anchor、sticking 以及 h-<id> 高度缓存都会带进被替换后的数据集 —— 这直接推翻了本 diff 在两个文件之外 VirtualizedList.tsx:418-419 所声明的不变量:"Dataset swaps (/clear, /resume) never reach the mark because MainContent keys the list by session and remounts it."

在这些路径上,视口会落在重建后历史的任意索引处,而不是该会话的实时底部;而组件内部的 clamp 与 swapEntry 逻辑 —— 是在「替换即 remount」这一前提下编写的 —— 成为唯一防线。同一次 commit 把这一矛盾的两侧都写进了注释:VirtualizedList.test.tsx:301:1034:1188 声称 "/clear does not remount the list (no key on ScrollableList in MainContent)" 与 "ScrollableList carries no key",而 :1268VirtualizedList.tsx:418-419 声称相反。两种说法下 53 个测试都全绿,因为它们只在隔离环境中驱动 VirtualizedList、从不读取 MainContent,所以该矛盾在测试期不可见。后续维护者会据此认为就地替换分支在生产中不可达(而该注释对四条路径都是错的),从而删除或停止维护它们。

证据 —— 四条路径均已在本次 commit 上核实:见上方英文部分(Ctrl+L 的 handleClearScreen、同 id 的 /resume/restore/history expand-now,各自的文件与行号均在其中)。

关于严重级别,此处仅记录、不据此改动结论:本轮的 A/B 双分支实测发现「remount 并不能恢复跟随」(就地分支 A 与 remount 分支 B 都是死的)。该测量存在混淆因素而非免责证据 —— 分支 B 落在「仅剩 banner」的数据集上,而这正是 R40-1 的入口 (b),因此该实验测量到的是 R40-1,而未能隔离出本条目自身的危害。要判定该 Critical 是否仅由 key 覆盖不足造成,需要一个「remount 确实会改变结果」的形态,本轮无法构造。

修复方向:把该不变量正确陈述一次,并点名 key 覆盖不到的路径 —— session id 是 /clear/branch 与跨会话 /resume 的数据集身份;而 /restore/history expand-now、Ctrl+L 与同会话 /resume 在不改变 session 的情况下替换历史,依赖列表自身的 re-anchor 与 clamp-mark 逻辑。随后修正 VirtualizedList.test.tsx:301:1034:1188 三处测试注释,并把 VirtualizedList.tsx:418-419 的 "never reach the mark" 改为「新 session id 会 remount;同 id 的 resume 仍为就地替换,由下方各分支防御」。要覆盖剩余的就地清空,需要的是数据集身份信号,而不是 session id。

修复不得把 key 换成 historyRemountKeyremountStaticHistoryAppContainer.tsx:1559-1560)在 VP 模式下还会在每次终端宽度缩小时被调用(AppContainer.tsx:1596-1605,调用两次 —— 立即一次、再经 setTimeout(remountStaticHistory, 650) 一次),也会被 refreshStatic 在 model 或 renderMode 变化时调用;因此用它作为 VP 列表的 key 会在每次缩窄、每次 Ctrl+O、每次切换模型时丢掉滚动 anchor 与已测量的高度缓存。

修复验收测试:在 MainContent.test.tsx 新增的 remounts the VP list when the session id changes (/clear, /resume) (#9305) 用例基础上,追加一次「sessionStats.sessionId 保持 'session-A'history 被清空」的 rerender;仅当 key 本应覆盖就地清空时,才断言 scrollableListMountSpy 再次触发 —— 今天它不会触发,这正是缺口所在。

— qwen3.8-max via Qwen Code /review (v0.23.0)

ref={scrollRef}
hasFocus={viewportInteractive}
data={allVirtualItems}
Expand Down
Loading
Loading