Переглянути джерело

Merge pull request #1065 from deepseek-harness/codex/webui-complex-perf-case

fix(web): preserve reader intent in long conversations
Wenlu Wang 1 місяць тому
батько
коміт
3c436d781e
26 змінених файлів з 3593 додано та 98 видалено
  1. 2 2
      .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml
  2. 5 1
      .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md
  3. 5 1
      .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml
  5. 2 2
      .agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md
  6. 2 2
      .agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md
  7. 2 2
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
  8. 1 2
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
  9. 1 2
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
  10. 328 0
      apps/web/tests/chat-continuous-conversation.e2e.ts
  11. 271 0
      apps/web/tests/chat-long-interactions.e2e.ts
  12. 683 0
      apps/web/tests/chat-scroll-contract.e2e.ts
  13. 235 0
      apps/web/tests/chat-scroll-fixture.ts
  14. 1434 0
      apps/web/tests/complex-history.perf.ts
  15. 90 14
      apps/web/tests/navigation-panes.e2e.ts
  16. 11 1
      apps/web/tests/scaffold.ts
  17. 6 1
      apps/web/tsconfig.json
  18. 1 0
      knip.json
  19. 2 0
      package.json
  20. 9 9
      packages/client/ui-conversation/src/client/apply.ts
  21. 6 0
      packages/client/ui-conversation/src/client/chat/ChatView.module.css
  22. 198 28
      packages/client/ui-conversation/src/client/chat/ChatView.tsx
  23. 14 4
      packages/client/ui-conversation/src/client/contract/slots.ts
  24. 263 25
      packages/client/ui-conversation/tests/chat-view.spec.tsx
  25. 5 0
      tsconfig.host.json
  26. 15 0
      vitest.web.perf.config.ts

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md
-2026-07-29-sticky-composer-conversation-scroll.md: 7ceae95dafffdb756ef49bb5612cd4e711eb59ca
-2026-07-29-sticky-composer-conversation-scroll.zh.md: d925d82f94635b5fe67b0be119c041d003def393
+2026-07-29-sticky-composer-conversation-scroll.md: 69d46894a53b0113f3e4f0fe871bbf3f9697969b
+2026-07-29-sticky-composer-conversation-scroll.zh.md: c0d5a0640468207282316ecd2fa1f209708df7b5

+ 5 - 1
.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md

@@ -14,6 +14,8 @@ While a session exists, `ConversationRoot` always supplies a `wrapActiveBody` ow
 
 Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, chains `wheel` with `{ passive: false }`: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it `preventDefault` and apply `deltaY` to the host.
 
+Chat history prepend follows reader intent through stable rendered node/call identities rather than whole-scrollport height deltas. `ChatView` records the first visible `data-chat-anchor-key` and its top relative to the scrollport when paging starts, reselects the currently visible stable anchor after every reader scroll while the request is in flight, and compensates by that row's post-prepend rectangle delta. Reaching the bottom or appending the reader's own message cancels the paging anchor, so a late page cannot pull the view away from the newest content. Bottom follow is stored state rather than raw scroll geometry. A passive wheel listener takes its pre-input baseline from the last main-thread-delivered or programmatically written `scrollTop`, because Chromium may advance compositor geometry before delivering the event; the current non-negative floor excludes a concurrent layout clamp from reader movement. A scroll without matching wheel movement re-pins while following and only refreshes the semantic position while reading. ChatView's single `ResizeObserver` follows streaming, tool disclosure, and draft resize only while bottom ownership remains pinned, without a second per-chunk scroll write.
+
 ## Alternatives considered
 
 **Sticky header and sticky composer inside one column scrollport.** Rejected for the header: it must occupy the top as fixed layout chrome, not participate in the scrollport's sticky layer.
@@ -24,6 +26,8 @@ Session stats live on `'conversation.composer.dock'` (above `'conversation.input
 
 **Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned.
 
+**Model every browser scroll input source.** Rejected for this narrow fix: the reproduced desktop path uses wheel/trackpad input. Pointer/touch scrolling, native-scrollbar dragging, keyboard scrolling, focus navigation, and nested overflow ownership remain outside the provenance model instead of adding a general input state machine.
+
 ## Consequences
 
-Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft.
+Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve wheel/trackpad scroll decisions, including Chromium's compositor-first delivery and stream-finalization clamp/regrow. Other browser scroll inputs do not change follow ownership under this narrow provenance rule. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft.

+ 5 - 1
.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md

@@ -14,6 +14,8 @@ Status: implemented
 
 会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 链式处理 `wheel`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。
 
+Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图,而不是使用整个滚动容器的高度差。分页开始时,`ChatView` 记录第一个可见的 `data-chat-anchor-key` 及其相对滚动容器的顶部位置;请求在途期间,每次读者滚动都会重新选择当前可见的稳定锚点;页面到达后则按该行矩形的前后差值补偿。到达底部或追加读者自己的消息会取消分页锚点,因此迟到的页面不能把视图从最新内容拉走。贴底跟随采用存储状态,而不是原始滚动几何状态。passive wheel 监听器以最近一次由主线程交付或由程序写入的 `scrollTop` 作为输入前基线,因为 Chromium 可能先推进合成器几何状态,之后才交付事件;当前使用的非负下限不会将并发的布局钳制计入读者移动。没有对应滚轮/触控板输入位移的滚动,在跟随状态下会重新贴底,在阅读状态下则只刷新语义位置。`ChatView` 的单个 `ResizeObserver` 只会在贴底所有权仍保持时跟随流式输出、工具展开与草稿尺寸变化,且每个 chunk 不会触发第二次滚动写入。
+
 ## Alternatives considered
 
 **标题栏与编辑器都在同一列滚动容器内 sticky。** 标题栏否决:它必须作为固定布局 chrome 占据顶部,而不是参与滚动容器的 sticky 层。
@@ -24,6 +26,8 @@ Status: implemented
 
 **把 StatsLine 留在 ChatView 消息列下方。** 否决:落在 sticky 编辑器之外会随内容滚走,而输入区仍钉在底部。
 
+**为每一种浏览器滚动输入来源建模。** 此次窄范围修复不采用:已复现的桌面端路径使用滚轮/触控板输入。指针/触控滚动、拖动原生滚动条、键盘滚动、焦点导航与嵌套 overflow 所有权仍不纳入输入来源模型,也不为此新增通用输入状态机。
+
 ## Consequences
 
-在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。hero → active 保持同一 textarea DOM 节点(assembled slash-flow 快照)以及 InputHub 草稿。
+在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留滚轮/触控板的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段滚动位置受钳制后滚动容器重新增长的情况。在这条窄范围的输入来源规则下,其他浏览器滚动输入不会改变贴底跟随所有权。hero → active 保持同一 textarea DOM 节点(assembled slash-flow 快照)以及 InputHub 草稿。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md
-2026-07-30-web-tool-row-unified-expand-and-inspect.md: ba2f4ead8023772fad578ca0b647241ecc332905
-2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: ac4835c7429a3ff7d3042f73d26d267911533132
+2026-07-30-web-tool-row-unified-expand-and-inspect.md: 98f1595564f0bd0d22f1ca4318b4c7fe15c6900d
+2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: 8f00349a975f777cc4d556ade3a9abe9676b8848

+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md

@@ -10,14 +10,14 @@ The chat view's tool rows had drifted into per-surface interaction dialects: Too
 
 ## Decision
 
-**Every expandable tool row shares one interaction — the whole row toggles (click / Enter / Space) with an icon→chevron hover preview — and one expanded body: an IN/OUT gutter-labeled card with per-section scroll caps; a hover-revealed Inspect pill jumps to the call's trajectory record through a one-shot store handoff; the chat view preserves its scroll offset across view switches through an in-memory per-session map.**
+**Every expandable tool row shares one interaction — the whole row toggles (click / Enter / Space) with an icon→chevron hover preview — and one expanded body: an IN/OUT gutter-labeled card with per-section scroll caps; a hover-revealed Inspect pill jumps to the call's trajectory record through a one-shot store handoff; the chat view preserves its semantic reading position across view switches through an in-memory per-session map.**
 
 - `toolRowModel` now derives result material alongside args: `output` (the `resultText` flatten, moved from DetailsPanel into the contract), and `errorSummary` (the failure's first line, shown as the collapsed summary in the error color). A row with body, output, or terminal material is expandable; the row itself is the toggle (`role="button"`, `aria-expanded`), and file-path summaries stay independent links via `stopPropagation`.
 - The expanded card (figma 1249:35657) is a column of IN/OUT sections: each section is its own scrollport (max-height 150px) with a sticky gutter label, and the l2 divider spans the full card width. Think prose and the run_code CodeBlock keep their non-card bodies; context injection reuses the row with a label-less `plainBody` card.
 - `terminalFailed` reads a settled terminal card's exit status so BashRow and GenericToolCard surface a failing command as the row's red state dot — the only failure signal the collapsed row has, since the call itself settles `isError:false`.
 - TerminalBlock's banner joins the same reading model: it shares the card surface (no banner token), an l2 hairline separates it from the body, the command column caps at 150px and scrolls with sticky copy/status controls top-aligned to the first prompt row.
 - Inspect: `ToolRowOwnerProps.inspect` (absent for rows without a call identity) renders a pill in real flow under the expanded body's bottom-left, revealed by hovering anywhere on the tool call. Clicking writes `{ callId }` to the chat store's one-shot `inspect` field and switches to the trajectory view; TrajectoryTable finds the record, opens its summary, and acknowledges by clearing the field.
-- Scroll preservation: the chat view saves its offset on every scroll (null when pinned to bottom) into an apply-scope `Map<SessionId, number>` exposed as `chatScroll` on the injected props; the open-jump branch restores it on remount. Deliberately not persisted — a fresh page load keeps the open-jump-to-bottom default.
+- Scroll preservation: on every non-bottom scroll, the chat view saves `{ anchorKey, anchorTop, scrollTop }` into an apply-scope per-session map exposed as `chatScroll`; a remount first uses `scrollTop` to reach the approximate window, then corrects by the stable node/call anchor's rectangle delta so width reflow keeps the same reading row in place. Every pinned path, including Back to bottom, clears the entry synchronously before a tab or session switch. The map remains deliberately unpersisted — a fresh page load keeps the open-jump-to-bottom default.
 
 ## Alternatives considered
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md

@@ -10,14 +10,14 @@
 
 ## 决定
 
-**所有可展开工具行共享同一交互——整行即开关(点击 / Enter / 空格),图标 hover 时渐变为 chevron 预览——以及同一展开体:带 IN/OUT 侧栏标签的卡片,各分区独立滚动上限;hover 显示的 Inspect 胶囊通过 store 的一次性交接跳到该调用的 trajectory 记录;聊天视图用内存态的按会话 Map 在视图切换间保留滚动位置。**
+**所有可展开工具行共享同一交互——整行即开关(点击 / Enter / 空格),图标 hover 时渐变为 chevron 预览——以及同一展开体:带 IN/OUT 侧栏标签的卡片,各分区独立滚动上限;hover 显示的 Inspect 胶囊通过 store 的一次性交接跳到该调用的 trajectory 记录;聊天视图用内存态的按会话 Map 在视图切换间保留语义阅读位置。**
 
 - `toolRowModel` 在 args 之外同时派生结果材料:`output`(`resultText` 拍平逻辑从 DetailsPanel 移入 contract)和 `errorSummary`(失败首行,以错误色作为折叠摘要)。有 body、output 或 terminal 材料的行即可展开;行本身是开关(`role="button"`、`aria-expanded`),文件路径摘要通过 `stopPropagation` 保持独立链接。
 - 展开卡片(figma 1249:35657)是 IN/OUT 分区列:每个分区是独立滚动区(max-height 150px),侧栏标签 sticky 固定,l2 分割线横贯整卡宽度。Think 的推理文本和 run_code 的 CodeBlock 保持非卡片体;上下文注入复用此行并以无标签的 `plainBody` 卡片展开。
 - `terminalFailed` 读取已结算 terminal 卡片的退出状态,让 BashRow 和 GenericToolCard 把失败命令显示为行的红色状态点——这是折叠行唯一的失败信号,因为调用本身结算为 `isError:false`。
 - TerminalBlock 的横幅并入同一阅读模型:与卡片共用同一表面(不再用 banner token),与正文之间是 l2 细线,命令列上限 150px 内部滚动,复制/状态控件 sticky 且顶对齐第一行提示符。
 - Inspect:`ToolRowOwnerProps.inspect`(无调用身份的行不提供)在展开体左下角以真实布局位置渲染胶囊,hover 整个 tool call 任意位置显示。点击将 `{ callId }` 写入 chat store 的一次性 `inspect` 字段并切换到 trajectory 视图;TrajectoryTable 找到记录、打开其摘要,并通过清空字段确认。
-- 滚动保留:聊天视图在每次滚动时保存偏移(贴底时为 null)到 apply 作用域的 `Map<SessionId, number>`,经注入 props 的 `chatScroll` 暴露;重挂载时 open-jump 分支恢复它。刻意不持久化——新页面加载保持打开即贴底的默认行为。
+- 滚动保留:每次非贴底滚动时,聊天视图把 `{ anchorKey, anchorTop, scrollTop }` 保存到 apply 作用域的按会话 Map,并经注入 props 的 `chatScroll` 暴露;重挂载时先用 `scrollTop` 到达近似窗口,再按稳定 node/call 锚点的矩形差值校正,因此宽度重排后仍把同一阅读行保持在原位。包括「回到底部」在内的每条贴底路径都会在切换 tab 或会话前同步清除该项。Map 仍刻意不持久化——新页面加载保持打开即贴底的默认行为。
 
 ## 曾考虑的替代方案
 

+ 2 - 2
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
-2026-07-24-web-gui-browser-e2e-lane.md: f05fc7268cfb613d0af8240bbb65cb154252a620
-2026-07-24-web-gui-browser-e2e-lane.zh.md: 3fd3805053a570a32e63601d7b039db41365309c
+2026-07-24-web-gui-browser-e2e-lane.md: f8519a9622d2f7216226a695db95dbebdbf24ea1
+2026-07-24-web-gui-browser-e2e-lane.zh.md: 294f3e840e0242d9a0d9c53ac510d44d3b0d100f

Різницю між файлами не показано, бо вона завелика
+ 1 - 2
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md


Різницю між файлами не показано, бо вона завелика
+ 1 - 2
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md


+ 328 - 0
apps/web/tests/chat-continuous-conversation.e2e.ts

@@ -0,0 +1,328 @@
+// Web e2e contract for a conversation grown through the real composer rather
+// than pre-seeded history. Twelve deterministic replay turns exercise repeated
+// send/settle/render cycles, including two real bash executions and one long,
+// multi-chunk final turn. Assertions stay semantic: no host timing, heap, or
+// mounted-row cardinality is treated as a correctness contract.
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
+import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
+import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
+import {
+  launchWebScaffold,
+  watchConsole,
+  webSnapshotMode,
+  type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+const MODE = webSnapshotMode()
+const TURN_COUNT = 12
+const TOOL_TURNS = [4, 9] as const
+const STREAM_PACE_MS = 10
+
+interface TurnSpec {
+  readonly index: number
+  readonly prompt: string
+  readonly userMarker: string
+  readonly firstMarker: string
+  readonly doneMarker: string
+  readonly deltas: readonly string[]
+  readonly callId?: ReturnType<typeof CallId>
+  readonly toolResultMarker?: string
+}
+
+function suffix(index: number): string {
+  return String(index).padStart(3, '0')
+}
+
+function longFinalPrompt(userMarker: string): string {
+  return [
+    `${userMarker} Reconcile this accumulated conversation without losing earlier turn ownership.`,
+    ...Array.from(
+      { length: 36 },
+      (_, index) => `Context ${String(index + 1).padStart(2, '0')}: preserve token-${String(index)} and verify ${'payload '.repeat(12).trimEnd()}.`,
+    ),
+    'Return one continuous response and finish with the requested completion marker.',
+  ].join('\n')
+}
+
+function turnSpec(index: number): TurnSpec {
+  const id = suffix(index)
+  const userMarker = `CONTINUOUS_CHAT_USER_${id}`
+  const firstMarker = `CONTINUOUS_CHAT_FIRST_${id}`
+  const doneMarker = `CONTINUOUS_CHAT_DONE_${id}`
+  const deltaCount = index === TURN_COUNT ? 36 : 8
+  const deltas = Array.from({ length: deltaCount }, (_, chunkIndex) => {
+    if (chunkIndex === 0) return `${firstMarker} `
+    if (chunkIndex === deltaCount - 1) return `${doneMarker}.`
+    return `turn-${id}-chunk-${String(chunkIndex).padStart(2, '0')} keeps semantic ownership stable. `
+  })
+  if (!TOOL_TURNS.includes(index as (typeof TOOL_TURNS)[number])) {
+    return {
+      index,
+      prompt: index === TURN_COUNT
+        ? longFinalPrompt(userMarker)
+        : `${userMarker} Continue this same conversation through turn ${String(index)}.`,
+      userMarker,
+      firstMarker,
+      doneMarker,
+      deltas,
+    }
+  }
+  return {
+    index,
+    prompt: `${userMarker} Run the requested deterministic tool for turn ${String(index)}, then continue.`,
+    userMarker,
+    firstMarker,
+    doneMarker,
+    deltas,
+    callId: CallId(`continuous-chat-tool-${id}`),
+    toolResultMarker: `CONTINUOUS_CHAT_TOOL_RESULT_${id}`,
+  }
+}
+
+function textStream(spec: TurnSpec): StreamChunk[] {
+  const response = spec.deltas.join('')
+  return [
+    { type: 'block-start', index: 0, blockType: 'text' },
+    ...spec.deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })),
+    { type: 'block-end', index: 0, block: { type: 'text', text: response } },
+    {
+      type: 'usage',
+      usage: {
+        inputTokens: Math.ceil(spec.prompt.length / 4),
+        outputTokens: Math.ceil(response.length / 4),
+      },
+    },
+    { type: 'finish', reason: { kind: 'stop' } },
+  ]
+}
+
+function toolStream(spec: TurnSpec): StreamChunk[] {
+  if (spec.callId === undefined || spec.toolResultMarker === undefined) {
+    throw new Error(`turn ${String(spec.index)} has no tool identity`)
+  }
+  const args = JSON.stringify({
+    command: `printf '${spec.toolResultMarker}\\n'`,
+    description: spec.toolResultMarker,
+  })
+  return [
+    { type: 'block-start', index: 0, blockType: 'tool-call' },
+    {
+      type: 'tool-call-delta',
+      index: 0,
+      id: spec.callId,
+      name: 'bash',
+      argumentsDelta: args,
+    },
+    {
+      type: 'block-end',
+      index: 0,
+      block: { type: 'tool-call', id: spec.callId, name: 'bash', arguments: args },
+    },
+    { type: 'usage', usage: { inputTokens: 256, outputTokens: 24 } },
+    { type: 'finish', reason: { kind: 'tool-calls' } },
+  ]
+}
+
+function replayScript(specs: readonly TurnSpec[]): ReplayOverrideDoc {
+  return specs.flatMap((spec): ReplayEntry[] => {
+    const final: ReplayEntry = { kind: 'chunks', chunks: textStream(spec) }
+    return spec.callId === undefined
+      ? [final]
+      : [{ kind: 'chunks', chunks: toolStream(spec) }, final]
+  })
+}
+
+function userText(event: Extract<SessionEvent, { type: 'user/message' }>): string {
+  return event.data.content
+    .filter(block => block.type === 'text')
+    .map(block => block.text)
+    .join('')
+}
+
+function assistantText(event: Extract<SessionEvent, { type: 'assistant/message' }>): string {
+  return event.data.message.content
+    .filter(block => block.type === 'text')
+    .map(block => block.text)
+    .join('')
+}
+
+function toolResultText(event: Extract<SessionEvent, { type: 'tool/result' }>): string {
+  return event.data.message.content[0].content
+    .filter(block => block.type === 'text')
+    .map(block => block.text)
+    .join('')
+}
+
+describe('web e2e: continuous conversation grown through the composer', () => {
+  let browser: Browser
+  let page: Page
+  let replayDir: string
+  let scaffold: WebScaffold
+  let tripwire: ReturnType<typeof watchConsole>
+  const consoleWarnings: string[] = []
+  const sessionEvents: SessionEvent[] = []
+  const specs = Array.from({ length: TURN_COUNT }, (_, offset) => turnSpec(offset + 1))
+
+  beforeAll(async () => {
+    replayDir = await mkdtemp(join(tmpdir(), 'dsh-continuous-chat-replay-'))
+    const replayOverride = join(replayDir, 'replay.override.json')
+    await writeFile(replayOverride, JSON.stringify(replayScript(specs)))
+    scaffold = await launchWebScaffold({
+      replayFixture: join(replayDir, 'override-only.jsonl'),
+      replayOverride,
+      replayContextWindow: 10_000_000,
+      paceMs: STREAM_PACE_MS,
+    })
+    scaffold.ctx.on('session/event', (_session, event: SessionEvent) => {
+      sessionEvents.push(event)
+    })
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser, 900)
+    tripwire = watchConsole(page)
+    page.on('console', (message) => {
+      if (message.type() === 'warning') consoleWarnings.push(message.text())
+    })
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await connectFreshWorkspace(page, scaffold.workspaceCwd, 'continuous-chat-e2e')
+  }, 120_000)
+
+  afterAll(async () => {
+    const failures: unknown[] = []
+    await browser?.close().catch((error: unknown) => failures.push(error))
+    await scaffold?.close().catch((error: unknown) => failures.push(error))
+    if (replayDir !== undefined) {
+      await rm(replayDir, { recursive: true, force: true })
+        .catch((error: unknown) => failures.push(error))
+    }
+    if (failures.length === 1) throw failures[0]
+    if (failures.length > 1) throw new AggregateError(failures, 'continuous Chat e2e cleanup failed')
+  })
+
+  it.skipIf(MODE === 'record')('keeps twelve generated turns and tool rows bound to one live session', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-continuous-conversation'))
+    const composer = page.locator('textarea:enabled').last()
+    await composer.waitFor({ timeout: 15_000 })
+    let sessionId: SessionId | undefined
+
+    for (const spec of specs) {
+      const eventStart = sessionEvents.length
+      expect(await composer.inputValue()).toBe('')
+      expect(await composer.isEnabled()).toBe(true)
+      await composer.fill(spec.prompt)
+      expect(await composer.inputValue()).toBe(spec.prompt)
+
+      const settled = scaffold.whenTurnSettled(60_000)
+      await page.getByRole('button', { name: 'Send message', exact: true }).click()
+      await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
+      const echoedUser = sessionEvents.slice(eventStart).find(
+        (event): event is SessionEvent<'user/message'> => (
+          event.type === 'user/message'
+          && event.data.source.kind === 'user'
+          && userText(event).includes(spec.userMarker)
+        ),
+      )
+      if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`)
+      const userRow = page.locator(`[data-chat-anchor-key="node:${String(echoedUser.seq)}"]`)
+      await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1)
+      expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user')
+      expect(await userRow.textContent()).toContain(spec.userMarker)
+      await page.getByText(spec.firstMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
+      const settledSessionId = await settled
+      if (sessionId === undefined) {
+        sessionId = settledSessionId
+      } else {
+        expect(settledSessionId).toBe(sessionId)
+      }
+
+      await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
+      await page.getByText(spec.doneMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
+      await expect.poll(() => composer.inputValue(), { timeout: 10_000 }).toBe('')
+      await expect.poll(() => composer.isEnabled(), { timeout: 10_000 }).toBe(true)
+
+      const turnEvents = sessionEvents.slice(eventStart)
+      const turnStarts = turnEvents.filter((event): event is SessionEvent<'turn/start'> => (
+        event.type === 'turn/start'
+      ))
+      const users = turnEvents.filter((event): event is SessionEvent<'user/message'> => (
+        event.type === 'user/message' && event.data.source.kind === 'user'
+      ))
+      const assistants = turnEvents.filter((event): event is SessionEvent<'assistant/message'> => (
+        event.type === 'assistant/message'
+      ))
+      const finalAssistants = assistants.filter(event => assistantText(event).includes(spec.doneMarker))
+      const turnEnds = turnEvents.filter((event): event is SessionEvent<'turn/end'> => (
+        event.type === 'turn/end'
+      ))
+      const chunks = turnEvents.filter(event => event.type === 'assistant/chunk')
+
+      expect(turnStarts).toHaveLength(1)
+      expect(turnStarts[0]?.data.turn).toBe(spec.index)
+      expect(users).toHaveLength(1)
+      expect(users[0]?.seq).toBe(echoedUser.seq)
+      expect(userText(users[0]!)).toBe(spec.prompt)
+      expect(finalAssistants).toHaveLength(1)
+      expect(assistants).toHaveLength(spec.callId === undefined ? 1 : 2)
+      expect(turnEnds).toHaveLength(1)
+      expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } })
+      expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9))
+
+      const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(finalAssistants[0]!.seq)}"]`)
+      await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1)
+      expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
+      expect(await assistantRow.textContent()).toContain(spec.doneMarker)
+
+      const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call')
+      const results = turnEvents.filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
+      if (spec.callId === undefined || spec.toolResultMarker === undefined) {
+        expect(calls).toHaveLength(0)
+        expect(results).toHaveLength(0)
+        continue
+      }
+
+      expect(calls).toHaveLength(1)
+      expect(results).toHaveLength(1)
+      expect(calls[0]?.data).toMatchObject({
+        turn: spec.index,
+        callId: spec.callId,
+        name: 'bash',
+      })
+      expect(results[0]?.data.turn).toBe(spec.index)
+      expect(results[0]?.data.message.source.callId).toBe(spec.callId)
+      expect(results[0]?.data.message.content[0].isError).toBe(false)
+      expect(toolResultText(results[0]!)).toBe(`${spec.toolResultMarker}\n`)
+
+      const toolRow = page.locator(`[data-chat-call-id="${spec.callId}"]`)
+      await expect.poll(() => toolRow.count(), { timeout: 10_000 }).toBe(1)
+      expect(await toolRow.textContent()).toContain(spec.toolResultMarker)
+      const disclosure = toolRow.locator('[data-sample="bash"]')
+      expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
+      await disclosure.click()
+      await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
+      // The collapsed summary deliberately repeats the result marker; the
+      // last exact match is the expanded terminal output owned by this call.
+      await toolRow.getByText(spec.toolResultMarker, { exact: true }).last().waitFor({ timeout: 10_000 })
+      await disclosure.click()
+      await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('false')
+    }
+
+    if (sessionId === undefined) throw new Error('continuous conversation completed no turn')
+    expect(scaffold.ctx.agents.get(sessionId)?.session.events.filter(event => (
+      event.type === 'turn/end' && event.data.reason.kind === 'completed'
+    ))).toHaveLength(TURN_COUNT)
+    expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000)
+    expect(sessionEvents.filter(event => (
+      event.type === 'assistant/chunk' && event.data.turn === TURN_COUNT
+    )).length).toBeGreaterThan(30)
+    expect(consoleWarnings).toEqual([])
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  }, 180_000)
+})

+ 271 - 0
apps/web/tests/chat-long-interactions.e2e.ts

@@ -0,0 +1,271 @@
+// Long-history Chat behavior contract for a future virtualized renderer. Wheel
+// input only navigates to the semantic target; assertions pin content identity
+// and interaction routing rather than scroll geometry or mounted row counts.
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import type { StreamChunk } from '@deepseek-ai/dsh-llm'
+import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
+import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
+import { createChatScrollFixture } from './chat-scroll-fixture.ts'
+import {
+  launchWebScaffold,
+  seedSession,
+  watchConsole,
+  webSnapshotMode,
+  type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+const MODE = webSnapshotMode()
+const SESSION_ID = 'chat-long-interactions-e2e'
+const FIXTURE_TURNS = 88
+const TOOL_TURN = FIXTURE_TURNS
+const BRANCH_TURN = 80
+const TARGET_CALL_1 = 'chat-scroll-088-1'
+const TARGET_CALL_2 = 'chat-scroll-088-2'
+const CONTINUE_PROMPT = 'CHAT_INTERACTION_CONTINUE Continue from this exact branch point.'
+const CONTINUE_FIRST = 'CHAT_INTERACTION_CONTINUE_FIRST'
+const CONTINUE_DONE = 'CHAT_INTERACTION_CONTINUE_DONE'
+const FIXTURE = createChatScrollFixture({
+  markerPrefix: 'INTERACTION',
+  title: 'CHAT_INTERACTION long semantic identity session',
+  turns: FIXTURE_TURNS,
+})
+
+function continuationChunks(): StreamChunk[] {
+  const response = `${CONTINUE_FIRST} The fork retained the intended prefix. ${CONTINUE_DONE}.`
+  return [
+    { type: 'block-start', index: 0, blockType: 'text' },
+    { type: 'text-delta', index: 0, text: `${CONTINUE_FIRST} ` },
+    { type: 'text-delta', index: 0, text: `The fork retained the intended prefix. ${CONTINUE_DONE}.` },
+    { type: 'block-end', index: 0, block: { type: 'text', text: response } },
+    { type: 'usage', usage: { inputTokens: 512, outputTokens: 32 } },
+    { type: 'finish', reason: { kind: 'stop' } },
+  ]
+}
+
+function replayEntry(chunks: StreamChunk[]): ReplayEntry {
+  return { kind: 'chunks', chunks }
+}
+
+function carries(event: SessionEvent, marker: string): boolean {
+  return JSON.stringify(event).includes(marker)
+}
+
+function textContent(content: readonly unknown[]): string {
+  return content.flatMap((block) => {
+    if (typeof block !== 'object' || block === null) return []
+    const candidate = block as { type?: unknown; text?: unknown }
+    return candidate.type === 'text' && typeof candidate.text === 'string'
+      ? [candidate.text]
+      : []
+  }).join('')
+}
+
+async function nextPaint(page: Page): Promise<void> {
+  await page.evaluate(async () => {
+    await document.fonts.ready
+    await new Promise<void>(resolve => requestAnimationFrame(() => {
+      requestAnimationFrame(() => { resolve() })
+    }))
+  })
+}
+
+async function openSeed(page: Page): Promise<void> {
+  await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
+  const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
+  await search.fill(FIXTURE.markers.user(1))
+  const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
+  await results.first().waitFor({ timeout: 60_000 })
+  const resultCount = await results.count()
+  if (resultCount !== 1) throw new Error(`expected one seeded search result, received ${String(resultCount)}`)
+  await results.click()
+  await results.click()
+  await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false })
+    .last().waitFor({ timeout: 30_000 })
+  await nextPaint(page)
+}
+
+async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
+  const scrollport = page.locator('[data-conversation-scroll]')
+  const box = await scrollport.boundingBox()
+  if (box === null) throw new Error('conversation scrollport has no layout box')
+  await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3))
+  for (let attempt = 0; attempt < 20; attempt += 1) {
+    if (await page.locator(selector).count() > 0) return
+    await page.mouse.wheel(0, deltaY)
+    await nextPaint(page)
+  }
+  throw new Error(`semantic Chat target did not mount: ${selector}`)
+}
+
+function requiredEvent<T extends SessionEvent['type']>(
+  events: readonly SessionEvent[],
+  type: T,
+  marker: string,
+): Extract<SessionEvent, { type: T }> {
+  const event = events.find((candidate): candidate is Extract<SessionEvent, { type: T }> => (
+    candidate.type === type && carries(candidate, marker)
+  ))
+  if (event === undefined) throw new Error(`${type} carrying ${marker} is absent`)
+  return event
+}
+
+describe('web e2e: long Chat interaction contract', () => {
+  let browser: Browser
+  let page: Page
+  let replayDir: string
+  let scaffold: WebScaffold
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-interaction-replay-'))
+    const replayOverride = join(replayDir, 'replay.override.json')
+    const replay: ReplayOverrideDoc = [replayEntry(continuationChunks())]
+    await writeFile(replayOverride, JSON.stringify(replay))
+    scaffold = await launchWebScaffold({
+      replayFixture: join(replayDir, 'override-only.jsonl'),
+      replayOverride,
+      replayContextWindow: 10_000_000,
+      paceMs: 18,
+    })
+    await seedSession(scaffold, FIXTURE.log, SESSION_ID)
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser, 900)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await openSeed(page)
+  }, 120_000)
+
+  afterAll(async () => {
+    const failures: unknown[] = []
+    await browser?.close().catch((error: unknown) => failures.push(error))
+    await scaffold?.close().catch((error: unknown) => failures.push(error))
+    if (replayDir !== undefined) {
+      await rm(replayDir, { recursive: true, force: true })
+        .catch((error: unknown) => failures.push(error))
+    }
+    if (failures.length === 1) throw failures[0]
+    if (failures.length > 1) throw new AggregateError(failures, 'long Chat interaction cleanup failed')
+  })
+
+  it.skipIf(MODE === 'record')('keeps heterogeneous rows and their actions bound to exact semantic identities', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-long-interactions'))
+    const source = scaffold.ctx.agents.get(SessionId(SESSION_ID))
+    if (source === undefined) throw new Error('seeded long-history agent is not attached')
+
+    const toolUserMarker = FIXTURE.markers.user(TOOL_TURN)
+    const toolAssistantMarker = FIXTURE.markers.assistant(TOOL_TURN)
+    const toolMarker1 = FIXTURE.markers.tool(TOOL_TURN, 1)
+    const toolMarker2 = FIXTURE.markers.tool(TOOL_TURN, 2)
+    const toolUserEvent = requiredEvent(source.session.events, 'user/message', toolUserMarker)
+    const toolAssistantEvent = requiredEvent(source.session.events, 'assistant/message', toolAssistantMarker)
+    const branchUserMarker = FIXTURE.markers.user(BRANCH_TURN)
+    const branchAssistantMarker = FIXTURE.markers.assistant(BRANCH_TURN)
+    const branchUserEvent = requiredEvent(source.session.events, 'user/message', branchUserMarker)
+    const branchAssistantEvent = requiredEvent(source.session.events, 'assistant/message', branchAssistantMarker)
+    const boundary = source.session.events.find((event): event is SessionEvent<'turn/end'> => (
+      event.type === 'turn/end' && event.data.turn === BRANCH_TURN
+    ))
+    if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no completed boundary`)
+    const expectedUserText = textContent(branchUserEvent.data.content)
+
+    await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100)
+    const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`)
+    const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`)
+    const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`)
+    const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`)
+
+    await expect.poll(() => toolUserRow.count(), { timeout: 10_000 }).toBe(1)
+    await expect.poll(() => toolAssistantRow.count(), { timeout: 10_000 }).toBe(1)
+    expect(await call1.count()).toBe(1)
+    expect(await call2.count()).toBe(1)
+    expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user')
+    expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
+    expect(await toolUserRow.textContent()).toContain(toolUserMarker)
+    expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker)
+    expect(await call1.textContent()).toContain(toolMarker1)
+    expect(await call2.textContent()).toContain(toolMarker2)
+
+    const expectedOrder = [
+      `node:${String(toolUserEvent.seq)}`,
+      `call:${TARGET_CALL_1}`,
+      `call:${TARGET_CALL_2}`,
+      `node:${String(toolAssistantEvent.seq)}`,
+    ]
+    const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => (
+      rows.map(row => (row as HTMLElement).dataset.chatAnchorKey)
+        .filter((key): key is string => key !== undefined && keys.includes(key))
+    ), expectedOrder)
+    expect(actualOrder).toEqual(expectedOrder)
+    const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => (
+      element.closest<HTMLElement>('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null
+    ))))
+    expect(groupKeys[0]).not.toBeNull()
+    expect(groupKeys[1]).toBe(groupKeys[0])
+
+    const summary1 = call1.locator('[data-sample="bash"]')
+    const summary2 = call2.locator('[data-sample="bash"]')
+    expect(await summary1.getAttribute('aria-expanded')).toBe('false')
+    expect(await summary2.getAttribute('aria-expanded')).toBe('false')
+    await summary2.focus()
+    await summary2.press('Enter')
+    await expect.poll(() => summary2.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
+    expect(await summary1.getAttribute('aria-expanded')).toBe('false')
+    await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 })
+
+    await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100)
+    const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`)
+    const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`)
+    expect(await userRow.textContent()).toContain(branchUserMarker)
+    expect(await assistantRow.textContent()).toContain(branchAssistantMarker)
+    await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
+    await userRow.hover()
+    await userRow.getByRole('button', { name: 'Copy', exact: true }).click()
+    await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 })
+      .toBe(expectedUserText)
+
+    await assistantRow.hover()
+    await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
+    await expect.poll(
+      () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)),
+      { timeout: 15_000 },
+    ).toBeDefined()
+    const child = scaffold.ctx.agents.list()
+      .find(agent => agent.session.header.parentSession === SessionId(SESSION_ID))
+    if (child === undefined) throw new Error('message branch did not create a child session')
+    expect(child.session.header.seedLength).toBe(boundary.seq + 1)
+    expect(child.session.events.some(event => carries(event, branchAssistantMarker))).toBe(true)
+    expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false)
+    expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false)
+
+    const currentCrumb = page.getByRole('navigation', { name: 'Session hierarchy' })
+      .getByRole('button').last()
+    await expect.poll(() => currentCrumb.textContent(), { timeout: 15_000 })
+      .toBe(`${FIXTURE.title} (1)`)
+    await page.getByText(branchAssistantMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
+    const settled = scaffold.whenTurnSettled(60_000)
+    const composer = page.locator('textarea:enabled').last()
+    await composer.fill(CONTINUE_PROMPT)
+    await page.getByRole('button', { name: 'Send message', exact: true }).click()
+    await expect.poll(() => page.getByText(CONTINUE_PROMPT, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
+    expect(await settled).toBe(child.session.id)
+    await page.getByText(CONTINUE_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
+    await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
+    expect(await composer.inputValue()).toBe('')
+    expect(await composer.isEnabled()).toBe(true)
+    expect(source.session.events.some(event => carries(event, CONTINUE_PROMPT))).toBe(false)
+    expect(child.session.events.filter(event => carries(event, CONTINUE_PROMPT))).toHaveLength(1)
+    const lastTurnEnd = child.session.events.findLast((event): event is SessionEvent<'turn/end'> => (
+      event.type === 'turn/end'
+    ))
+    expect(lastTurnEnd?.data.reason).toEqual({ kind: 'completed' })
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  }, 180_000)
+})

+ 683 - 0
apps/web/tests/chat-scroll-contract.e2e.ts

@@ -0,0 +1,683 @@
+// Browser geometry contracts for a long Chat transcript. These scenarios are
+// deliberately virtualizer-neutral: they assert semantic-row position,
+// bottom ownership, interaction state, and the real outer scroll host rather
+// than DOM cardinality or implementation-specific spacer markup.
+import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import type { StreamChunk } from '@deepseek-ai/dsh-llm'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts'
+import {
+  launchWebScaffold,
+  seedSession,
+  watchConsole,
+  webSnapshotMode,
+  type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+const MODE = webSnapshotMode()
+const HISTORY_SESSION_ID = 'chat-scroll-history-e2e'
+const TOOL_SESSION_ID = 'chat-scroll-tool-e2e'
+const RESTORE_SESSION_A_ID = 'chat-scroll-restore-a-e2e'
+const RESTORE_SESSION_B_ID = 'chat-scroll-restore-b-e2e'
+const REPLAY_CONTEXT_WINDOW = 10_000_000
+const STREAM_PACE_MS = 24
+const GEOMETRY_TOLERANCE = 2
+const LIVE_TEXT_PROMPT = 'CHAT_SCROLL_LIVE_USER Continue this long conversation while I inspect older history.'
+const LIVE_TEXT_FIRST = 'CHAT_SCROLL_LIVE_FIRST'
+const LIVE_TEXT_DONE = 'CHAT_SCROLL_LIVE_DONE'
+const LIVE_TOOL_PROMPT = 'CHAT_SCROLL_TOOL_USER Run the requested diagnostic and then summarize it.'
+const LIVE_TOOL_CALL_ID = CallId('chat-scroll-live-tool-call')
+const LIVE_TOOL_RESULT = 'CHAT_SCROLL_LIVE_TOOL_RESULT'
+const LIVE_TOOL_FIRST = 'CHAT_SCROLL_TOOL_STREAM_FIRST'
+const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE'
+const TOOL_READY_FILE = '.chat-scroll-tool-ready'
+const TOOL_RELEASE_FILE = '.chat-scroll-tool-release'
+
+const HISTORY_FIXTURE = createChatScrollFixture({
+  markerPrefix: 'HISTORY',
+  title: 'CHAT_SCROLL_HISTORY long paging session',
+})
+const TOOL_FIXTURE = createChatScrollFixture({
+  markerPrefix: 'TOOL',
+  title: 'CHAT_SCROLL_TOOL live tool session',
+})
+const RESTORE_FIXTURE_A = createChatScrollFixture({
+  markerPrefix: 'RESTORE_A',
+  title: 'CHAT_SCROLL_RESTORE_A long session',
+})
+const RESTORE_FIXTURE_B = createChatScrollFixture({
+  markerPrefix: 'RESTORE_B',
+  title: 'CHAT_SCROLL_RESTORE_B comparison session',
+  turns: 32,
+})
+
+interface ScrollGeometry {
+  readonly distanceFromBottom: number
+  readonly scrollTop: number
+}
+
+interface FlowAnchor {
+  readonly key: string
+  readonly top: number
+}
+
+interface ScrollWorld {
+  readonly events: SessionEvent[]
+  readonly page: Page
+  readonly replayDir?: string
+  readonly scaffold: WebScaffold
+  readonly tripwire: ReturnType<typeof watchConsole>
+}
+
+interface ScrollWorldOptions {
+  readonly failureShot: string
+  readonly replay?: ReplayOverrideDoc
+  readonly seeds: readonly { fixture: ChatScrollFixture; id: string }[]
+}
+
+function textStream(first: string, done: string, deltaCount: number): StreamChunk[] {
+  const deltas = Array.from({ length: deltaCount }, (_, index) => {
+    if (index === 0) return `${first} `
+    if (index === deltaCount - 1) return `${done}.`
+    return `stream-chunk-${String(index).padStart(3, '0')} ${'incremental response '.repeat(3)}`
+  })
+  const response = deltas.join('')
+  return [
+    { type: 'block-start', index: 0, blockType: 'text' },
+    ...deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })),
+    { type: 'block-end', index: 0, block: { type: 'text', text: response } },
+    {
+      type: 'usage',
+      usage: { inputTokens: 512, outputTokens: Math.ceil(response.length / 4) },
+    },
+    { type: 'finish', reason: { kind: 'stop' } },
+  ]
+}
+
+function toolStream(): StreamChunk[] {
+  const command = [
+    `: > ${TOOL_READY_FILE}`,
+    `while [ ! -f ${TOOL_RELEASE_FILE} ]; do sleep 0.02; done`,
+    'line=1',
+    `while [ "$line" -le 64 ]; do printf '${LIVE_TOOL_RESULT} line %02d\\n' "$line"; line=$((line + 1)); done`,
+  ].join('; ')
+  const args = JSON.stringify({ command, description: LIVE_TOOL_RESULT })
+  return [
+    { type: 'block-start', index: 0, blockType: 'tool-call' },
+    {
+      type: 'tool-call-delta',
+      index: 0,
+      id: LIVE_TOOL_CALL_ID,
+      name: 'bash',
+      argumentsDelta: args,
+    },
+    {
+      type: 'block-end',
+      index: 0,
+      block: { type: 'tool-call', id: LIVE_TOOL_CALL_ID, name: 'bash', arguments: args },
+    },
+    { type: 'usage', usage: { inputTokens: 256, outputTokens: 48 } },
+    { type: 'finish', reason: { kind: 'tool-calls' } },
+  ]
+}
+
+function replayEntry(chunks: StreamChunk[]): ReplayEntry {
+  return { kind: 'chunks', chunks }
+}
+
+async function launchScrollWorld(options: ScrollWorldOptions): Promise<ScrollWorld> {
+  let replayDir: string | undefined
+  let scaffold: WebScaffold | undefined
+  let page: Page | undefined
+  try {
+    if (options.replay !== undefined) {
+      replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-scroll-replay-'))
+      const replayOverride = join(replayDir, 'replay.override.json')
+      await writeFile(replayOverride, JSON.stringify(options.replay))
+      scaffold = await launchWebScaffold({
+        replayFixture: join(replayDir, 'override-only.jsonl'),
+        replayOverride,
+        paceMs: STREAM_PACE_MS,
+        replayContextWindow: REPLAY_CONTEXT_WINDOW,
+      })
+    } else {
+      scaffold = await launchWebScaffold({})
+    }
+    for (const seed of options.seeds) await seedSession(scaffold, seed.fixture.log, seed.id)
+    const events: SessionEvent[] = []
+    scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
+    page = await newEnglishPage(browser, 900)
+    const tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    // Session-list bootstrap can replace the controlled search state. Wait
+    // for the seeded baseline before openSeed starts the lazy content query.
+    await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
+    return {
+      events,
+      page,
+      scaffold,
+      tripwire,
+      ...(replayDir === undefined ? {} : { replayDir }),
+    }
+  } catch (error) {
+    const failures: unknown[] = [error]
+    if (page !== undefined) await page.context().close().catch((cleanupError: unknown) => failures.push(cleanupError))
+    if (scaffold !== undefined) await scaffold.close().catch((cleanupError: unknown) => failures.push(cleanupError))
+    if (replayDir !== undefined) {
+      await rm(replayDir, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
+    }
+    if (failures.length === 1) throw error
+    throw new AggregateError(failures, 'chat-scroll browser world setup failed and cleanup was incomplete')
+  }
+}
+
+async function closeScrollWorld(world: ScrollWorld): Promise<void> {
+  const failures: unknown[] = []
+  // newEnglishPage/browser.newPage owns an isolated context. Close the whole
+  // context so its SSE connection and cache cannot leak into the next world
+  // in this file's shared Chromium process.
+  await world.page.context().close().catch((error: unknown) => failures.push(error))
+  await world.scaffold.close().catch((error: unknown) => failures.push(error))
+  if (world.replayDir !== undefined) {
+    await rm(world.replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
+  }
+  if (failures.length === 1) throw failures[0]
+  if (failures.length > 1) throw new AggregateError(failures, 'chat-scroll browser world cleanup failed')
+}
+
+async function withScrollWorld(
+  options: ScrollWorldOptions,
+  run: (world: ScrollWorld) => Promise<void>,
+): Promise<void> {
+  const world = await launchScrollWorld(options)
+  let runFailure: unknown
+  try {
+    await run(world)
+  } catch (error) {
+    runFailure = error
+    try {
+      await saveFailureShot(world.page, options.failureShot)
+    } catch {
+      // Best-effort evidence must never prevent cleanup of the owned world.
+    }
+  }
+  let cleanupFailure: unknown
+  try {
+    await closeScrollWorld(world)
+  } catch (error) {
+    cleanupFailure = error
+  }
+  if (runFailure !== undefined && cleanupFailure !== undefined) {
+    throw new AggregateError([runFailure, cleanupFailure], 'chat-scroll scenario and cleanup both failed')
+  }
+  if (runFailure !== undefined) throw runFailure
+  if (cleanupFailure !== undefined) throw cleanupFailure
+}
+
+async function nextPaint(page: Page): Promise<void> {
+  await page.evaluate(async () => {
+    await document.fonts.ready
+    await new Promise<void>(resolve => requestAnimationFrame(() => {
+      requestAnimationFrame(() => { resolve() })
+    }))
+  })
+}
+
+function scrollGeometry(page: Page): Promise<ScrollGeometry> {
+  return page.locator('[data-conversation-scroll]').evaluate(host => ({
+    distanceFromBottom: host.scrollHeight - host.clientHeight - host.scrollTop,
+    scrollTop: host.scrollTop,
+  }))
+}
+
+async function conversationTurns(page: Page): Promise<number> {
+  const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last()
+  await stats.waitFor({ timeout: 15_000 })
+  const value = await stats.textContent()
+  const match = value?.match(/^(\d+) turns · \d+ steps$/)
+  if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`)
+  return Number(match[1])
+}
+
+async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise<void> {
+  const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
+  // Cold summaries initially show the temporary workspace basename, so the
+  // persisted first-message marker is the stable user-facing identity. The
+  // query itself triggers lazy content-index reconciliation; no transient
+  // empty-state paint is used as a barrier.
+  await search.fill(fixture.markers.user(1))
+  const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
+  await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
+  await results.click()
+  await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
+  if (tailMarker !== undefined) {
+    await page.getByText(tailMarker, { exact: false }).last().waitFor({ timeout: 30_000 })
+  }
+  await nextPaint(page)
+}
+
+async function wheelTranscript(page: Page, deltaY: number): Promise<void> {
+  const box = await page.locator('[data-conversation-scroll]').boundingBox()
+  if (box === null) throw new Error('conversation scrollport has no layout box')
+  await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3))
+  await page.mouse.wheel(0, deltaY)
+  await nextPaint(page)
+}
+
+async function wheelToHistoryStart(page: Page): Promise<void> {
+  for (let attempt = 0; attempt < 12; attempt += 1) {
+    if ((await scrollGeometry(page)).scrollTop <= 1) break
+    await wheelTranscript(page, -2_400)
+  }
+  await expect.poll(async () => (await scrollGeometry(page)).scrollTop, { timeout: 10_000 })
+    .toBeLessThanOrEqual(1)
+}
+
+async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
+  for (let attempt = 0; attempt < 16; attempt += 1) {
+    if (await page.locator(selector).count() > 0) return
+    await wheelTranscript(page, deltaY)
+  }
+  throw new Error(`selector did not mount during transcript wheel: ${selector}`)
+}
+
+async function wheelUntilVisible(page: Page, selector: string, deltaY: number): Promise<void> {
+  const target = page.locator(selector)
+  for (let attempt = 0; attempt < 32; attempt += 1) {
+    if (await target.count() > 0 && await target.evaluate((row) => {
+      const host = row.closest<HTMLElement>('[data-conversation-scroll]')
+      if (host === null) return false
+      const viewport = host.getBoundingClientRect()
+      const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
+      const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
+      const rect = row.getBoundingClientRect()
+      return rect.bottom > viewport.top && rect.top < visibleBottom
+    })) return
+    await wheelTranscript(page, deltaY)
+  }
+  throw new Error(`selector did not become visible during transcript wheel: ${selector}`)
+}
+
+function visibleFlowAnchor(page: Page): Promise<FlowAnchor> {
+  return page.locator('[data-conversation-scroll]').evaluate((host) => {
+    const rows = [...host.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
+    const viewport = host.getBoundingClientRect()
+    const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
+    const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
+    const visible = rows.filter((candidate) => {
+      const rect = candidate.getBoundingClientRect()
+      return rect.bottom > viewport.top && rect.top < visibleBottom
+    })
+    const row = visible[0]
+    if (row?.dataset.chatAnchorKey === undefined) {
+      throw new Error(`no visible settled Chat row: ${JSON.stringify({
+        composerTop: visibleBottom,
+        host: { bottom: viewport.bottom, top: viewport.top },
+        rows: rows.slice(0, 4).map(candidate => ({
+          callId: candidate.dataset.chatCallId,
+          key: candidate.dataset.chatAnchorKey,
+          rect: {
+            bottom: candidate.getBoundingClientRect().bottom,
+            top: candidate.getBoundingClientRect().top,
+          },
+        })),
+        totalRows: rows.length,
+      })}`)
+    }
+    return {
+      key: row.dataset.chatAnchorKey,
+      top: row.getBoundingClientRect().top - viewport.top,
+    }
+  })
+}
+
+function flowTop(page: Page, key: string): Promise<number> {
+  return page.locator('[data-chat-anchor-key]').evaluateAll((rows, anchorKey) => {
+    const row = rows.find(candidate => (candidate as HTMLElement).dataset.chatAnchorKey === anchorKey)
+    if (!(row instanceof HTMLElement)) throw new Error(`stable Chat anchor ${anchorKey} is not mounted`)
+    const host = row.closest('[data-conversation-scroll]')
+    if (!(host instanceof HTMLElement)) throw new Error('flow row has no conversation scrollport')
+    return row.getBoundingClientRect().top - host.getBoundingClientRect().top
+  }, key)
+}
+
+async function expectSameFlowTop(page: Page, anchor: FlowAnchor): Promise<void> {
+  await expect.poll(async () => Math.abs((await flowTop(page, anchor.key)) - anchor.top), {
+    timeout: 10_000,
+    message: `flow row ${anchor.key} moved relative to the transcript viewport`,
+  }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
+}
+
+async function expectBottom(page: Page): Promise<void> {
+  await expect.poll(async () => Math.abs((await scrollGeometry(page)).distanceFromBottom), {
+    timeout: 10_000,
+  }).toBeLessThanOrEqual(1)
+}
+
+async function expectMarkerAboveComposer(page: Page, marker: string): Promise<void> {
+  const geometry = await page.getByText(marker, { exact: false }).last().evaluate((node) => {
+    const row = node.closest('[data-chat-flow-key], [data-streaming]')
+    const composer = node.closest('[data-conversation-scroll]')?.querySelector('[data-composer-seat]')
+    if (!(row instanceof HTMLElement) || !(composer instanceof HTMLElement)) {
+      throw new Error('latest marker or composer geometry is unavailable')
+    }
+    return {
+      composerTop: composer.getBoundingClientRect().top,
+      rowBottom: row.getBoundingClientRect().bottom,
+    }
+  })
+  expect(geometry.rowBottom).toBeLessThanOrEqual(geometry.composerTop + GEOMETRY_TOLERANCE)
+}
+
+async function loadEarlierWithAnchor(page: Page): Promise<void> {
+  await wheelToHistoryStart(page)
+  const older = page.getByRole('button', { name: 'Load earlier', exact: true })
+  await older.waitFor({ timeout: 10_000 })
+  const anchor = await visibleFlowAnchor(page)
+  const before = await conversationTurns(page)
+  await older.click()
+  await expect.poll(() => conversationTurns(page), { timeout: 30_000 }).toBeGreaterThan(before)
+  await nextPaint(page)
+  await expectSameFlowTop(page, anchor)
+}
+
+async function fileExists(path: string): Promise<boolean> {
+  try {
+    await access(path)
+    return true
+  } catch {
+    return false
+  }
+}
+
+function eventCarries(event: SessionEvent, marker: string): boolean {
+  return JSON.stringify(event).includes(marker)
+}
+
+function assertClean(world: ScrollWorld): void {
+  expect(world.tripwire.pageErrors).toEqual([])
+  expect(world.tripwire.warnings).toEqual([])
+}
+
+let browser: Browser
+
+describe('web e2e: long Chat scroll contract', () => {
+  beforeAll(async () => {
+    browser = await chromium.launch()
+  })
+
+  afterAll(async () => {
+    await browser?.close()
+  })
+
+  it.skipIf(MODE === 'record')('preserves the reader anchor when history and streaming arrive concurrently', async () => {
+    await withScrollWorld({
+      failureShot: 'web-e2e-chat-scroll-history-stream',
+      replay: [replayEntry(textStream(LIVE_TEXT_FIRST, LIVE_TEXT_DONE, 120))],
+      seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }],
+    }, async (world) => {
+      await openSeed(
+        world.page,
+        HISTORY_FIXTURE,
+        HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns),
+      )
+      await expectBottom(world.page)
+
+      let releaseHistory = (): void => {}
+      let held = false
+      let releaseGate: (() => void) | undefined
+      const gate = new Promise<void>((resolve) => { releaseGate = resolve })
+      releaseHistory = () => { releaseGate?.() }
+      await world.page.route('**/api/session.history', async (route) => {
+        const request = route.request().postDataJSON() as {
+          method?: string
+          payload?: { beforeSeq?: number }
+        }
+        if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) {
+          held = true
+          await gate
+        }
+        await route.continue()
+      })
+
+      const settled = world.scaffold.whenTurnSettled(60_000)
+      try {
+        const composer = world.page.locator('textarea:enabled').last()
+        await composer.fill(LIVE_TEXT_PROMPT)
+        await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
+        await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 })
+        await wheelToHistoryStart(world.page)
+        const beforeTurns = await conversationTurns(world.page)
+        await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click()
+        await expect.poll(() => held, { timeout: 10_000 }).toBe(true)
+
+        await wheelTranscript(world.page, 420)
+        const readerAnchor = await visibleFlowAnchor(world.page)
+        const chunksAfterAnchor = world.events.filter(event => event.type === 'assistant/chunk').length
+        await expect.poll(
+          () => world.events.filter(event => event.type === 'assistant/chunk').length,
+          { timeout: 10_000 },
+        ).toBeGreaterThan(chunksAfterAnchor + 5)
+
+        releaseHistory()
+        await expect.poll(() => conversationTurns(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeTurns)
+        await nextPaint(world.page)
+        await expectSameFlowTop(world.page, readerAnchor)
+      } finally {
+        releaseHistory()
+      }
+
+      await settled
+      await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
+      await world.page.getByText(LIVE_TEXT_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
+      await world.page.unroute('**/api/session.history')
+
+      let additionalPages = 0
+      while (additionalPages < 8) {
+        await wheelToHistoryStart(world.page)
+        if (await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) break
+        await loadEarlierWithAnchor(world.page)
+        additionalPages += 1
+      }
+      expect(additionalPages).toBeGreaterThan(0)
+      expect(await conversationTurns(world.page)).toBe(HISTORY_FIXTURE.turns + 1)
+      expect(await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(0)
+      assertClean(world)
+    })
+  }, 180_000)
+
+  it.skipIf(MODE === 'record')('keeps streaming ownership and tool disclosure state across a long scroll-away cycle', async () => {
+    await withScrollWorld({
+      failureShot: 'web-e2e-chat-scroll-live-tool',
+      replay: [
+        replayEntry(toolStream()),
+        replayEntry(textStream(LIVE_TOOL_FIRST, LIVE_TOOL_DONE, 84)),
+      ],
+      seeds: [{ fixture: TOOL_FIXTURE, id: TOOL_SESSION_ID }],
+    }, async (world) => {
+      const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE)
+      const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE)
+      await openSeed(world.page, TOOL_FIXTURE, TOOL_FIXTURE.markers.assistant(TOOL_FIXTURE.turns))
+      const settled = world.scaffold.whenTurnSettled(60_000)
+      let released = false
+      try {
+        const composer = world.page.locator('textarea:enabled').last()
+        await composer.fill(LIVE_TOOL_PROMPT)
+        await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
+        await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
+        const liveRow = world.page.locator(`[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`)
+        await liveRow.waitFor({ timeout: 15_000 })
+        expect(await liveRow.getAttribute('data-state')).toBe('running')
+        await expectBottom(world.page)
+
+        await wheelTranscript(world.page, -1_200)
+        await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).waitFor({ timeout: 10_000 })
+        const awayAnchor = await visibleFlowAnchor(world.page)
+        const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length
+        await writeFile(releasePath, 'release\n')
+        released = true
+        await expect.poll(
+          () => world.events.some(event => event.type === 'tool/result'),
+          { timeout: 15_000 },
+        ).toBe(true)
+        await expect.poll(
+          () => world.events.some(event => eventCarries(event, LIVE_TOOL_FIRST)),
+          { timeout: 15_000 },
+        ).toBe(true)
+        await expect.poll(
+          () => world.events.filter(event => event.type === 'assistant/chunk').length,
+          { timeout: 15_000 },
+        ).toBeGreaterThan(chunksBeforeRelease + 5)
+        await expectSameFlowTop(world.page, awayAnchor)
+
+        const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length
+        await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
+        await expectBottom(world.page)
+        await expect.poll(
+          () => world.events.filter(event => event.type === 'assistant/chunk').length,
+          { timeout: 15_000 },
+        ).toBeGreaterThan(chunksAtRepin + 5)
+        await expectBottom(world.page)
+      } finally {
+        if (!released) await writeFile(releasePath, 'release\n').catch(() => {})
+      }
+
+      await settled
+      await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
+      await world.page.getByText(LIVE_TOOL_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
+      await expectBottom(world.page)
+      await expectMarkerAboveComposer(world.page, LIVE_TOOL_DONE)
+
+      const liveRowSelector = `[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`
+      const liveRow = world.page.locator(liveRowSelector)
+      await wheelUntilVisible(world.page, liveRowSelector, -300)
+      const toolAnchor = await liveRow.evaluate((row) => {
+        const flow = row.closest<HTMLElement>('[data-chat-anchor-key]')
+        const host = row.closest<HTMLElement>('[data-conversation-scroll]')
+        if (flow?.dataset.chatAnchorKey === undefined || host === null) {
+          throw new Error('live tool row has no settled flow identity')
+        }
+        return {
+          key: flow.dataset.chatAnchorKey,
+          top: flow.getBoundingClientRect().top - host.getBoundingClientRect().top,
+        }
+      })
+      await liveRow.click()
+      await expect.poll(() => liveRow.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
+      await expectSameFlowTop(world.page, toolAnchor)
+      await wheelToHistoryStart(world.page)
+      await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
+      await expectBottom(world.page)
+      await wheelUntilMounted(world.page, liveRowSelector, -1_100)
+      const restoredRow = world.page.locator(liveRowSelector)
+      await restoredRow.waitFor({ timeout: 10_000 })
+      expect(await restoredRow.getAttribute('aria-expanded')).toBe('true')
+      expect(await world.page.getByText(LIVE_TOOL_RESULT, { exact: false }).count()).toBeGreaterThan(0)
+      assertClean(world)
+    })
+  }, 180_000)
+
+  it.skipIf(MODE === 'record')('restores tab/session position and keeps composer resizing on the correct scroll owner', async () => {
+    await withScrollWorld({
+      failureShot: 'web-e2e-chat-scroll-restore-composer',
+      seeds: [
+        { fixture: RESTORE_FIXTURE_A, id: RESTORE_SESSION_A_ID },
+        { fixture: RESTORE_FIXTURE_B, id: RESTORE_SESSION_B_ID },
+      ],
+    }, async (world) => {
+      await openSeed(
+        world.page,
+        RESTORE_FIXTURE_A,
+        RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
+      )
+      await loadEarlierWithAnchor(world.page)
+      await loadEarlierWithAnchor(world.page)
+      await wheelToHistoryStart(world.page)
+      await wheelTranscript(world.page, 1_300)
+      const sessionAnchor = await visibleFlowAnchor(world.page)
+
+      await world.page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+      await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
+      await world.page.setViewportSize({ width: 700, height: 900 })
+      await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
+      await nextPaint(world.page)
+      await expectSameFlowTop(world.page, sessionAnchor)
+
+      await openSeed(
+        world.page,
+        RESTORE_FIXTURE_B,
+        RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
+      )
+      await openSeed(
+        world.page,
+        RESTORE_FIXTURE_A,
+      )
+      await expectSameFlowTop(world.page, sessionAnchor)
+
+      const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
+      await backToBottom.evaluate((button) => {
+        if (!(button instanceof HTMLElement)) throw new Error('Back-to-bottom control is not an HTML element')
+        button.click()
+        const trajectory = [...document.querySelectorAll<HTMLElement>('[role="tab"]')]
+          .find(tab => tab.textContent?.trim() === 'Trajectory')
+        if (!(trajectory instanceof HTMLElement)) {
+          throw new Error('Trajectory tab is unavailable during pinned remount')
+        }
+        trajectory.click()
+      })
+      await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
+      await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
+      await expectBottom(world.page)
+      await openSeed(
+        world.page,
+        RESTORE_FIXTURE_B,
+        RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
+      )
+      await openSeed(
+        world.page,
+        RESTORE_FIXTURE_A,
+        RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
+      )
+      await expectBottom(world.page)
+      const composer = world.page.locator('textarea:enabled').last()
+      const longDraft = Array.from(
+        { length: 18 },
+        (_, index) => `composer resize line ${String(index + 1).padStart(2, '0')}`,
+      ).join('\n')
+      await composer.fill(longDraft)
+      await nextPaint(world.page)
+      await expectBottom(world.page)
+      await expectMarkerAboveComposer(
+        world.page,
+        RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
+      )
+
+      await composer.fill('short draft')
+      await nextPaint(world.page)
+      await wheelTranscript(world.page, -900)
+      const resizeAnchor = await visibleFlowAnchor(world.page)
+      await composer.fill(longDraft)
+      await nextPaint(world.page)
+      await expectSameFlowTop(world.page, resizeAnchor)
+      await composer.fill('short draft')
+      await nextPaint(world.page)
+      await expectSameFlowTop(world.page, resizeAnchor)
+
+      const beforeChain = await scrollGeometry(world.page)
+      await composer.hover()
+      await world.page.mouse.wheel(0, -320)
+      await expect.poll(async () => (await scrollGeometry(world.page)).scrollTop, { timeout: 10_000 })
+        .toBeLessThan(beforeChain.scrollTop)
+      assertClean(world)
+    })
+  }, 180_000)
+})

+ 235 - 0
apps/web/tests/chat-scroll-fixture.ts

@@ -0,0 +1,235 @@
+// Synthetic long-chat history for browser behavior contracts. The fixture is
+// generated through Session so pagination exercises the same event shapes as
+// persisted conversations, while unique markers let tests identify semantic
+// rows without depending on CSS-module names or the eventual virtualizer DOM.
+import {
+  CallId,
+  createAssistantMessage,
+  createToolResultMessage,
+  createUserMessage,
+} from '@deepseek-ai/dsh-llm'
+import {
+  SESSION_FORMAT_VERSION,
+  Session,
+  SessionId,
+} from '@deepseek-ai/dsh-session'
+// Carries the session/title event declaration into this fixture builder.
+import type {} from '@deepseek-ai/dsh-session-title'
+
+/** Options for one deterministic long-chat fixture. */
+export interface ChatScrollFixtureOptions {
+  /** Marker namespace, used when two sessions share one browser world. */
+  readonly markerPrefix: string
+  /** Searchable title projected into the sidebar. */
+  readonly title: string
+  /** Number of closed turns to generate. */
+  readonly turns?: number
+}
+
+/** Semantic marker helpers returned with a generated fixture. */
+interface ChatScrollMarkers {
+  /** Marker painted in the human message for a turn. */
+  user(turn: number): string
+  /** Marker painted in the final assistant message for a turn. */
+  assistant(turn: number): string
+  /** Marker painted in one seeded bash call and result. */
+  tool(turn: number, index: number): string
+}
+
+/** Generated JSONL plus the stable facts browser scenarios assert. */
+export interface ChatScrollFixture {
+  readonly log: string
+  readonly markers: ChatScrollMarkers
+  readonly title: string
+  readonly turns: number
+}
+
+const DEFAULT_TURNS = 88
+const TOOL_INTERVAL = 8
+const CODE_INTERVAL = 11
+
+function text(value: string): { type: 'text'; text: string }[] {
+  return [{ type: 'text', text: value }]
+}
+
+function suffix(turn: number): string {
+  return String(turn).padStart(3, '0')
+}
+
+function markerHelpers(prefix: string): ChatScrollMarkers {
+  return {
+    user: turn => `CHAT_SCROLL_${prefix}_USER_${suffix(turn)}`,
+    assistant: turn => `CHAT_SCROLL_${prefix}_ASSISTANT_${suffix(turn)}`,
+    tool: (turn, index) => `CHAT_SCROLL_${prefix}_TOOL_${suffix(turn)}_${String(index)}`,
+  }
+}
+
+function appendRequestHeader(session: Session, turn: number, step: number): void {
+  session.append('request/header', {
+    header: {
+      config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+      system: `Synthetic chat-scroll request for turn ${String(turn)}, step ${String(step)}.`,
+    },
+    reason: turn === 1 && step === 1 ? 'initial' : 'change',
+  })
+}
+
+function appendAssistant(session: Session, turn: number, step: number, body: string): void {
+  session.append('assistant/message', {
+    turn,
+    step,
+    message: createAssistantMessage({
+      content: text(body),
+      source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+    }),
+    usage: {
+      inputTokens: 2_000 + turn * 7,
+      outputTokens: 180 + step * 20,
+    },
+  }, { surfaceOp: 'append' })
+}
+
+function codeBlock(turn: number): string {
+  if (turn % CODE_INTERVAL !== 0) return ''
+  const lines = Array.from(
+    { length: 30 },
+    (_, index) => `const scroll_case_${suffix(turn)}_${String(index).padStart(2, '0')} = ${String(turn + index)}`,
+  )
+  return `\n\n\`\`\`ts\n${lines.join('\n')}\n\`\`\``
+}
+
+function appendToolStep(
+  session: Session,
+  markers: ChatScrollMarkers,
+  turn: number,
+): void {
+  const calls = [1, 2].map((index) => {
+    const marker = markers.tool(turn, index)
+    const callId = CallId(`chat-scroll-${suffix(turn)}-${String(index)}`)
+    const args = JSON.stringify({
+      command: `printf '${marker}\\n'`,
+      description: marker,
+    })
+    return { args, callId, marker }
+  })
+
+  session.append('assistant/message', {
+    turn,
+    step: 1,
+    message: createAssistantMessage({
+      content: [
+        { type: 'reasoning', text: `Inspecting two scroll fixtures for turn ${String(turn)}.` },
+        ...calls.map(call => ({
+          type: 'tool-call' as const,
+          id: call.callId,
+          name: 'bash',
+          arguments: call.args,
+        })),
+      ],
+      source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+    }),
+    usage: { inputTokens: 2_000 + turn * 7, outputTokens: 240, reasoningTokens: 30 },
+  }, { surfaceOp: 'append' })
+
+  for (const call of calls) {
+    const source = session.append('tool/call', {
+      turn,
+      step: 1,
+      callId: call.callId,
+      name: 'bash',
+      arguments: call.args,
+    })
+    session.append('tool/result', {
+      turn,
+      step: 1,
+      message: createToolResultMessage({
+        callId: call.callId,
+        content: text(Array.from(
+          { length: 12 },
+          (_, line) => `${call.marker} output line ${String(line + 1).padStart(2, '0')}`,
+        ).join('\n')),
+        isError: false,
+      }),
+    }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
+  }
+}
+
+function fixtureLog(session: Session): string {
+  return [
+    JSON.stringify({
+      type: 'session',
+      version: SESSION_FORMAT_VERSION,
+      id: '{{sessionId}}',
+      createdAt: Date.now() - 60_000,
+      cwd: '{{cwd}}',
+      delegationDepth: 0,
+    }),
+    ...session.events.map(event => JSON.stringify(event)),
+    '',
+  ].join('\n')
+}
+
+/**
+ * Build a multi-page conversation with prose, fenced code, and paired bash
+ * calls/results. Every turn is closed, so cold resume cannot repair or mutate
+ * the seed before the browser observes it.
+ * @param options - Fixture identity and optional turn count.
+ * @returns Canonical JSONL and semantic marker helpers.
+ */
+export function createChatScrollFixture(options: ChatScrollFixtureOptions): ChatScrollFixture {
+  const turns = options.turns ?? DEFAULT_TURNS
+  const markers = markerHelpers(options.markerPrefix)
+  const session = new Session(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`))
+
+  for (let turn = 1; turn <= turns; turn += 1) {
+    session.append('turn/start', {
+      turn,
+      trigger: { kind: 'message', source: { kind: 'user' } },
+    })
+    const user = session.append('user/message', createUserMessage({
+      content: text(
+        `${markers.user(turn)} Review the long-running conversation state for turn ${String(turn)}. `
+        + 'Keep the visible message stable while history, tools, and new output change around it.',
+      ),
+      source: { kind: 'user' },
+    }), { surfaceOp: 'append' })
+    if (turn === 1) {
+      session.append('session/title', {
+        title: options.title,
+        messageSeqs: [user.seq],
+        source: { kind: 'fallback' },
+      })
+    }
+
+    session.append('step/start', { turn, step: 1 })
+    appendRequestHeader(session, turn, 1)
+    if (turn % TOOL_INTERVAL === 0) {
+      appendToolStep(session, markers, turn)
+      session.append('step/end', { turn, step: 1 })
+      session.append('step/start', { turn, step: 2 })
+      appendRequestHeader(session, turn, 2)
+      appendAssistant(
+        session,
+        turn,
+        2,
+        `${markers.assistant(turn)} Both tool results are accounted for. `
+        + `This settled response keeps turn ${String(turn)} identifiable after paging.${codeBlock(turn)}`,
+      )
+      session.append('step/end', { turn, step: 2 })
+    } else {
+      appendAssistant(
+        session,
+        turn,
+        1,
+        `${markers.assistant(turn)} The conversation remains readable after several paragraphs.\n\n`
+        + `Turn ${String(turn)} deliberately carries enough prose to wrap at narrower viewport widths. `
+        + 'The semantic marker stays near the start so geometry probes can find the same rendered row.\n\n'
+        + `The closing paragraph makes this a realistic assistant response rather than a one-line list item.${codeBlock(turn)}`,
+      )
+      session.append('step/end', { turn, step: 1 })
+    }
+    session.append('turn/end', { turn, reason: { kind: 'completed' } })
+  }
+
+  return { log: fixtureLog(session), markers, title: options.title, turns }
+}

+ 1434 - 0
apps/web/tests/complex-history.perf.ts

@@ -0,0 +1,1434 @@
+// Opt-in browser benchmark for high-cardinality workspace and history
+// rendering. It reports measurements without timing assertions because host
+// speed is not a correctness contract; structural assertions keep the load
+// shape from silently shrinking.
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { performance } from 'node:perf_hooks'
+import type { Browser, CDPSession, Locator, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import type { StreamChunk } from '@deepseek-ai/dsh-llm'
+import {
+  CallId,
+  createAssistantMessage,
+  createToolResultMessage,
+  createUserMessage,
+} from '@deepseek-ai/dsh-llm'
+import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import {
+  SESSION_FORMAT_VERSION,
+  Session,
+  SessionId,
+} from '@deepseek-ai/dsh-session'
+// Carries the session/title event declaration into the fixture builder.
+import type {} from '@deepseek-ai/dsh-session-title'
+import {
+  launchWebScaffold,
+  seedSession,
+  watchConsole,
+  webSnapshotMode,
+  type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage } from './support.ts'
+
+const SIDEBAR_SESSION_COUNT = 1_000
+const LONG_SESSION_ID = 'perf-long-history'
+const LONG_SESSION_TITLE = 'LONG_PERF_SENTINEL 500-turn session'
+const LONG_HISTORY_TURNS = 500
+const TOOL_TURN_INTERVAL = 10
+const TOOLS_PER_TOOL_TURN = 10
+const EXPECTED_TOOL_CALLS = LONG_HISTORY_TURNS / TOOL_TURN_INTERVAL * TOOLS_PER_TOOL_TURN
+const EXPECTED_TRAJECTORY_ROWS = 2_100
+const DEFAULT_HISTORY_TURNS = 24
+const PERF_REPLAY_CONTEXT_WINDOW = 10_000_000
+const STREAM_PACE_MS = 8
+const STREAM_DELTA_COUNT = 120
+const COMPARISON_TURNS = 8
+const COMPARISON_DELTA_COUNT = 24
+const COMPARISON_TOOL_INTERVAL = 3
+const SOAK_TURNS = 100
+const POST_SOAK_RENDER_TURN = SOAK_TURNS + 1
+const SOAK_DELTA_COUNT = 8
+const SOAK_TOOL_INTERVAL = 10
+const SOAK_CHECKPOINT_INTERVAL = 10
+const LONG_CONTINUATION_USER_PREFIX = 'LONG_CONTINUATION_USER'
+const LONG_CONTINUATION_FIRST_PREFIX = 'LONG_CONTINUATION_FIRST'
+const LONG_CONTINUATION_DONE_PREFIX = 'LONG_CONTINUATION_DONE'
+const SOAK_USER_PREFIX = 'SOAK_CONVERSATION_USER'
+const SOAK_FIRST_PREFIX = 'SOAK_CONVERSATION_FIRST'
+const SOAK_DONE_PREFIX = 'SOAK_CONVERSATION_DONE'
+const LIVE_PROMPT_MARKER = 'STREAM_PERF_USER_INPUT'
+const STREAM_FIRST_MARKER = 'STREAM_PERF_FIRST'
+const STREAM_DONE_MARKER = 'STREAM_PERF_DONE'
+const LIVE_PROMPT = [
+  LIVE_PROMPT_MARKER,
+  'Analyze the following mixed-language project context and return a concise diagnostic.',
+  ...Array.from(
+    { length: 48 },
+    (_, index) =>
+      `Context ${String(index + 1).padStart(2, '0')}: 用户正在检查长会话中的增量渲染性能。`
+      + ` Preserve item ${String(index)} and compare ${'payload'.repeat(8)}.`,
+  ),
+  '```ts',
+  ...Array.from(
+    { length: 40 },
+    (_, index) => `const sample_${String(index)} = ${JSON.stringify(`value-${String(index)}-${'x'.repeat(32)}`)}`,
+  ),
+  '```',
+].join('\n')
+const STREAM_DELTAS = Array.from({ length: STREAM_DELTA_COUNT }, (_, index) => {
+  if (index === 0) return `${STREAM_FIRST_MARKER} `
+  if (index === STREAM_DELTA_COUNT - 1) return `${STREAM_DONE_MARKER}.`
+  return `chunk-${String(index).padStart(3, '0')} ${'response'.repeat(3)} `
+})
+
+interface ChromiumMetrics {
+  readonly [name: string]: number
+}
+
+interface Measurement {
+  readonly wallMs: number
+  readonly taskMs: number
+  readonly scriptMs: number
+  readonly layoutMs: number
+  readonly recalcStyleMs: number
+  readonly devtoolsMs: number
+  readonly nodesDelta: number
+  readonly listenersDelta: number
+  readonly heapDeltaMb: number
+  readonly totalNodes: number
+  readonly heapMb: number
+}
+
+interface MutationProbeResult {
+  readonly batches: number
+  readonly records: number
+}
+
+interface UserRenderProbeResult {
+  readonly trustedClick: boolean
+  readonly sendToDomMs: number
+  readonly sendToPaintMs: number
+  readonly domToPaintMs: number
+  readonly mutationBatches: number
+  readonly mutationRecords: number
+}
+
+interface RetainedBrowserState {
+  readonly domElements: number
+  readonly nodes: number
+  readonly listeners: number
+  readonly heapMb: number
+}
+
+interface ContinuedTurnReport {
+  readonly ordinal: number
+  readonly resultingTurns: number
+  readonly kind: 'text' | 'tool'
+  readonly promptChars: number
+  readonly composerFill: Measurement
+  readonly stream: MutationProbeResult & Measurement & {
+    readonly paceMs: number
+    readonly deltaChunks: number
+    readonly persistedChunks: number
+    readonly toolCalls: number
+    readonly toolResults: number
+    readonly clickToUserEchoMs: number
+    readonly clickToFirstChunkMs: number
+    readonly firstChunkToSettledMs: number
+  }
+}
+
+interface ConversationTurnSpec {
+  readonly prompt: string
+  readonly deltas: readonly string[]
+  readonly userMarker: string
+  readonly firstMarker: string
+  readonly doneMarker: string
+  readonly toolResultMarker?: string
+}
+
+interface RetainedCheckpoint {
+  readonly turns: number
+  readonly state: RetainedBrowserState
+}
+
+interface ConversationReport {
+  readonly startingTurns: number
+  readonly turnsAdded: number
+  readonly toolTurns: number
+  readonly retainedBefore: RetainedBrowserState
+  readonly retainedAfter: RetainedBrowserState
+  readonly retainedDelta: RetainedBrowserState
+  readonly checkpoints: readonly RetainedCheckpoint[]
+  readonly turns: readonly ContinuedTurnReport[]
+}
+
+interface PerformanceWorld {
+  readonly scaffold: WebScaffold
+  readonly page: Page
+  readonly tripwire: ReturnType<typeof watchConsole>
+  readonly sessionEvents: SessionEvent[]
+  readonly setupMs: number
+  readonly replayDir?: string
+}
+
+interface PerformanceWorldOptions {
+  readonly browser: Browser
+  readonly replay?: ReplayOverrideDoc
+  readonly sidebarSessions?: number
+  readonly seedLongHistory?: boolean
+}
+
+function text(value: string): { type: 'text'; text: string }[] {
+  return [{ type: 'text', text: value }]
+}
+
+function appendTitle(session: Session, title: string, messageSeq: number): void {
+  session.append('session/title', {
+    title,
+    messageSeqs: [messageSeq],
+    source: { kind: 'fallback' },
+  })
+}
+
+function appendRequestHeader(session: Session, turn: number, step: number): void {
+  session.append('request/header', {
+    header: {
+      config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+      system: `Synthetic performance system prompt for turn ${String(turn)}, step ${String(step)}.`,
+    },
+    reason: turn === 1 && step === 1 ? 'initial' : 'change',
+  })
+}
+
+function appendAssistant(
+  session: Session,
+  turn: number,
+  step: number,
+  body: string,
+): void {
+  session.append('assistant/message', {
+    turn,
+    step,
+    message: createAssistantMessage({
+      content: text(body),
+      source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+    }),
+    usage: {
+      inputTokens: 4_000 + turn * 10,
+      outputTokens: 200 + step * 20,
+      cacheReadTokens: turn % 2 === 0 ? 2_000 : 0,
+    },
+  }, { surfaceOp: 'append' })
+}
+
+function appendToolStep(
+  session: Session,
+  turn: number,
+  step: number,
+  toolCount: number,
+): void {
+  const calls = Array.from({ length: toolCount }, (_, index) => {
+    const callId = CallId(`perf-call-${String(turn)}-${String(index)}`)
+    const args = JSON.stringify({
+      turn,
+      index,
+      payload: 'x'.repeat(120),
+    })
+    return { callId, index, args }
+  })
+
+  session.append('assistant/message', {
+    turn,
+    step,
+    message: createAssistantMessage({
+      content: [
+        {
+          type: 'reasoning',
+          text: `Dispatching ${String(toolCount)} synthetic tools for turn ${String(turn)}.`,
+        },
+        ...calls.map(({ callId, args }) => ({
+          type: 'tool-call' as const,
+          id: callId,
+          name: 'synthetic_tool',
+          arguments: args,
+        })),
+      ],
+      source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+    }),
+    usage: {
+      inputTokens: 6_000 + turn * 10,
+      outputTokens: 500,
+      cacheReadTokens: 3_000,
+      reasoningTokens: 50,
+    },
+  }, { surfaceOp: 'append' })
+
+  const callEvents = calls.map(({ callId, args }) =>
+    session.append('tool/call', {
+      turn,
+      step,
+      callId,
+      name: 'synthetic_tool',
+      arguments: args,
+    }))
+
+  for (const [index, call] of calls.entries()) {
+    const source = callEvents[index]
+    if (source === undefined) throw new Error(`missing synthetic tool call ${String(index)}`)
+    session.append('tool/result', {
+      turn,
+      step,
+      message: createToolResultMessage({
+        callId: call.callId,
+        content: text(
+          `synthetic result turn=${String(turn)} index=${String(call.index)} ${'r'.repeat(400)}`,
+        ),
+        isError: false,
+      }),
+    }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
+  }
+}
+
+function fencedCode(turn: number): string {
+  if (turn % 25 !== 0) return ''
+  const lines = Array.from(
+    { length: 80 },
+    (_, index) => `const value_${String(index)} = ${String(turn + index)}`,
+  )
+  return `\n\n\`\`\`ts\n${lines.join('\n')}\n\`\`\``
+}
+
+function fixtureLog(session: Session): string {
+  const header = {
+    type: 'session',
+    version: SESSION_FORMAT_VERSION,
+    id: '{{sessionId}}',
+    createdAt: Date.now() - 60_000,
+    cwd: '{{cwd}}',
+  }
+  return [
+    JSON.stringify(header),
+    ...session.events.map(event => JSON.stringify(event)),
+    '',
+  ].join('\n')
+}
+
+function smallSidebarFixture(): string {
+  const session = new Session(SessionId('perf-small-template'))
+  session.append('turn/start', {
+    turn: 1,
+    trigger: { kind: 'message', source: { kind: 'user' } },
+  })
+  const user = session.append('user/message', createUserMessage({
+    content: text('Inspect this compact synthetic session.'),
+    source: { kind: 'user' },
+  }), { surfaceOp: 'append' })
+  appendTitle(session, 'Synthetic sidebar session', user.seq)
+  session.append('step/start', { turn: 1, step: 1 })
+  appendRequestHeader(session, 1, 1)
+  appendToolStep(session, 1, 1, 2)
+  session.append('step/end', { turn: 1, step: 1 })
+  session.append('step/start', { turn: 1, step: 2 })
+  appendRequestHeader(session, 1, 2)
+  appendAssistant(session, 1, 2, 'Synthetic sidebar fixture complete.')
+  session.append('step/end', { turn: 1, step: 2 })
+  session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+  return fixtureLog(session)
+}
+
+function longHistoryFixture(): string {
+  const session = new Session(SessionId(LONG_SESSION_ID))
+  for (let turn = 1; turn <= LONG_HISTORY_TURNS; turn += 1) {
+    session.append('turn/start', {
+      turn,
+      trigger: { kind: 'message', source: { kind: 'user' } },
+    })
+    const user = session.append('user/message', createUserMessage({
+      content: text(
+        `LONG_PERF_SENTINEL turn ${String(turn)}: analyze payload ${'u'.repeat(200)}`,
+      ),
+      source: { kind: 'user' },
+    }), { surfaceOp: 'append' })
+    if (turn === 1) appendTitle(session, LONG_SESSION_TITLE, user.seq)
+
+    session.append('step/start', { turn, step: 1 })
+    appendRequestHeader(session, turn, 1)
+    if (turn % TOOL_TURN_INTERVAL === 0) {
+      appendToolStep(session, turn, 1, TOOLS_PER_TOOL_TURN)
+      session.append('step/end', { turn, step: 1 })
+      session.append('step/start', { turn, step: 2 })
+      appendRequestHeader(session, turn, 2)
+      appendAssistant(
+        session,
+        turn,
+        2,
+        `All synthetic tools completed for turn ${String(turn)}. ${'z'.repeat(320)}${fencedCode(turn)}`,
+      )
+      session.append('step/end', { turn, step: 2 })
+    } else {
+      appendAssistant(
+        session,
+        turn,
+        1,
+        `Synthetic assistant response for turn ${String(turn)}. ${'a'.repeat(320)}${fencedCode(turn)}`,
+      )
+      session.append('step/end', { turn, step: 1 })
+    }
+    session.append('turn/end', { turn, reason: { kind: 'completed' } })
+  }
+  return fixtureLog(session)
+}
+
+function textStream(deltas: readonly string[], inputTokens: number): StreamChunk[] {
+  const response = deltas.join('')
+  return [
+    { type: 'block-start', index: 0, blockType: 'text' },
+    ...deltas.map(text => ({
+      type: 'text-delta' as const,
+      index: 0,
+      text,
+    })),
+    {
+      type: 'block-end',
+      index: 0,
+      block: { type: 'text', text: response },
+    },
+    {
+      type: 'usage',
+      usage: {
+        inputTokens,
+        outputTokens: Math.ceil(response.length / 4),
+      },
+    },
+    { type: 'finish', reason: { kind: 'stop' } },
+  ]
+}
+
+function comparisonPrompt(index: number): string {
+  if (index === COMPARISON_TURNS) return LIVE_PROMPT
+  return (`${LONG_CONTINUATION_USER_PREFIX}_${String(index)} `
+    + `继续分析这个长会话的第 ${String(index)} 个增量问题,并保留当前滚动和输入响应。 `
+    + 'context '.repeat(80)).trimEnd()
+}
+
+function comparisonDeltas(index: number): string[] {
+  if (index === COMPARISON_TURNS) return STREAM_DELTAS
+  return Array.from({ length: COMPARISON_DELTA_COUNT }, (_, chunkIndex) => {
+    if (chunkIndex === 0) return `${LONG_CONTINUATION_FIRST_PREFIX}_${String(index)} `
+    if (chunkIndex === COMPARISON_DELTA_COUNT - 1) {
+      return `${LONG_CONTINUATION_DONE_PREFIX}_${String(index)}.`
+    }
+    return `turn-${String(index)}-chunk-${String(chunkIndex).padStart(2, '0')} ${'response'.repeat(2)} `
+  })
+}
+
+function comparisonTurn(index: number): ConversationTurnSpec {
+  const toolResultMarker = index % COMPARISON_TOOL_INTERVAL === 0
+    ? `LONG_CONTINUATION_TOOL_RESULT_${String(index)}`
+    : undefined
+  return {
+    prompt: comparisonPrompt(index),
+    deltas: comparisonDeltas(index),
+    userMarker: index === COMPARISON_TURNS
+      ? LIVE_PROMPT_MARKER
+      : `${LONG_CONTINUATION_USER_PREFIX}_${String(index)}`,
+    firstMarker: index === COMPARISON_TURNS
+      ? STREAM_FIRST_MARKER
+      : `${LONG_CONTINUATION_FIRST_PREFIX}_${String(index)}`,
+    doneMarker: index === COMPARISON_TURNS
+      ? STREAM_DONE_MARKER
+      : `${LONG_CONTINUATION_DONE_PREFIX}_${String(index)}`,
+    ...toolResultMarker === undefined ? {} : { toolResultMarker },
+  }
+}
+
+function soakTurn(index: number): ConversationTurnSpec {
+  const suffix = String(index).padStart(3, '0')
+  const toolResultMarker = index % SOAK_TOOL_INTERVAL === 0
+    ? `SOAK_CONVERSATION_TOOL_RESULT_${suffix}`
+    : undefined
+  return {
+    prompt: (`${SOAK_USER_PREFIX}_${suffix} `
+      + `持续对话第 ${String(index)} 轮,检查增量渲染与保留状态。 `
+      + 'context '.repeat(20)).trimEnd(),
+    deltas: Array.from({ length: SOAK_DELTA_COUNT }, (_, chunkIndex) => {
+      if (chunkIndex === 0) return `${SOAK_FIRST_PREFIX}_${suffix} `
+      if (chunkIndex === SOAK_DELTA_COUNT - 1) return `${SOAK_DONE_PREFIX}_${suffix}.`
+      return `soak-${suffix}-${String(chunkIndex).padStart(2, '0')} response `
+    }),
+    userMarker: `${SOAK_USER_PREFIX}_${suffix}`,
+    firstMarker: `${SOAK_FIRST_PREFIX}_${suffix}`,
+    doneMarker: `${SOAK_DONE_PREFIX}_${suffix}`,
+    ...toolResultMarker === undefined ? {} : { toolResultMarker },
+  }
+}
+
+function toolStream(index: number, marker: string): StreamChunk[] {
+  const callId = CallId(`performance-tool-${marker.toLowerCase()}-${String(index)}`)
+  const args = JSON.stringify({
+    command: `printf '${marker}\\n'`,
+    description: `Emit performance marker ${String(index)}`,
+  })
+  return [
+    { type: 'block-start', index: 0, blockType: 'tool-call' },
+    {
+      type: 'tool-call-delta',
+      index: 0,
+      id: callId,
+      name: 'bash',
+      argumentsDelta: args,
+    },
+    {
+      type: 'block-end',
+      index: 0,
+      block: { type: 'tool-call', id: callId, name: 'bash', arguments: args },
+    },
+    { type: 'usage', usage: { inputTokens: 256, outputTokens: 32 } },
+    { type: 'finish', reason: { kind: 'tool-calls' } },
+  ]
+}
+
+function performanceReplayOverride(
+  turnCount: number,
+  turnSpec: (index: number) => ConversationTurnSpec,
+): ReplayOverrideDoc {
+  const continuationEntries = Array.from(
+    { length: turnCount },
+    (_, offset): ReplayEntry[] => {
+      const index = offset + 1
+      const spec = turnSpec(index)
+      const finalResponse: ReplayEntry = {
+        kind: 'chunks',
+        chunks: textStream(spec.deltas, Math.ceil(spec.prompt.length / 4)),
+      }
+      return spec.toolResultMarker === undefined
+        ? [finalResponse]
+        : [{ kind: 'chunks', chunks: toolStream(index, spec.toolResultMarker) }, finalResponse]
+    },
+  ).flat()
+  return continuationEntries
+}
+
+function rounded(value: number): number {
+  return Math.round(value * 1_000) / 1_000
+}
+
+async function chromiumMetrics(cdp: CDPSession): Promise<ChromiumMetrics> {
+  const payload = await cdp.send('Performance.getMetrics')
+  return Object.fromEntries(payload.metrics.map(metric => [metric.name, metric.value]))
+}
+
+async function retainedBrowserState(
+  cdp: CDPSession,
+  page: Page,
+): Promise<RetainedBrowserState> {
+  await cdp.send('HeapProfiler.collectGarbage')
+  const metrics = await chromiumMetrics(cdp)
+  return {
+    domElements: await page.evaluate(() => document.querySelectorAll('*').length),
+    nodes: requiredMetric(metrics, 'Nodes'),
+    listeners: requiredMetric(metrics, 'JSEventListeners'),
+    heapMb: rounded(requiredMetric(metrics, 'JSHeapUsedSize') / 1_048_576),
+  }
+}
+
+function requiredMetric(metrics: ChromiumMetrics, name: string): number {
+  const value = metrics[name]
+  if (value === undefined) throw new Error(`Chromium performance metric ${name} is unavailable`)
+  return value
+}
+
+function metricDelta(
+  before: ChromiumMetrics,
+  after: ChromiumMetrics,
+  wallMs: number,
+): Measurement {
+  return {
+    wallMs: rounded(wallMs),
+    taskMs: rounded((requiredMetric(after, 'TaskDuration') - requiredMetric(before, 'TaskDuration')) * 1_000),
+    scriptMs: rounded((requiredMetric(after, 'ScriptDuration') - requiredMetric(before, 'ScriptDuration')) * 1_000),
+    layoutMs: rounded((requiredMetric(after, 'LayoutDuration') - requiredMetric(before, 'LayoutDuration')) * 1_000),
+    recalcStyleMs: rounded(
+      (requiredMetric(after, 'RecalcStyleDuration') - requiredMetric(before, 'RecalcStyleDuration')) * 1_000,
+    ),
+    devtoolsMs: rounded(
+      (requiredMetric(after, 'DevToolsCommandDuration') - requiredMetric(before, 'DevToolsCommandDuration')) * 1_000,
+    ),
+    nodesDelta: requiredMetric(after, 'Nodes') - requiredMetric(before, 'Nodes'),
+    listenersDelta: requiredMetric(after, 'JSEventListeners') - requiredMetric(before, 'JSEventListeners'),
+    heapDeltaMb: rounded(
+      (requiredMetric(after, 'JSHeapUsedSize') - requiredMetric(before, 'JSHeapUsedSize')) / 1_048_576,
+    ),
+    totalNodes: requiredMetric(after, 'Nodes'),
+    heapMb: rounded(requiredMetric(after, 'JSHeapUsedSize') / 1_048_576),
+  }
+}
+
+async function measure<T>(
+  cdp: CDPSession,
+  action: () => Promise<T>,
+): Promise<{ measurement: Measurement; value: T }> {
+  const before = await chromiumMetrics(cdp)
+  const started = performance.now()
+  const value = await action()
+  const wallMs = performance.now() - started
+  const after = await chromiumMetrics(cdp)
+  return { measurement: metricDelta(before, after, wallMs), value }
+}
+
+async function startMutationProbe(page: Page): Promise<void> {
+  await page.evaluate(() => {
+    const target = document.querySelector('[class*="centerCol"]')
+    if (target === null) throw new Error('stream mutation probe target is unavailable')
+    const probe = {
+      batches: 0,
+      records: 0,
+      observer: undefined as MutationObserver | undefined,
+    }
+    const observer = new MutationObserver((records) => {
+      probe.batches += 1
+      probe.records += records.length
+    })
+    probe.observer = observer
+    observer.observe(target, {
+      attributes: true,
+      attributeFilter: ['data-streaming'],
+      characterData: true,
+      childList: true,
+      subtree: true,
+    })
+    Reflect.set(globalThis, '__dshPerfMutationProbe', probe)
+  })
+}
+
+async function stopMutationProbe(page: Page): Promise<MutationProbeResult> {
+  return page.evaluate(() => {
+    const probe = Reflect.get(globalThis, '__dshPerfMutationProbe') as
+      | { batches: number; records: number; observer: MutationObserver }
+      | undefined
+    if (probe === undefined) throw new Error('stream mutation probe was not started')
+    probe.observer.disconnect()
+    Reflect.deleteProperty(globalThis, '__dshPerfMutationProbe')
+    return { batches: probe.batches, records: probe.records }
+  })
+}
+
+async function startUserRenderProbe(
+  page: Page,
+  marker: string,
+): Promise<void> {
+  const send = page.getByRole('button', { name: 'Send message', exact: true })
+  await send.waitFor({ timeout: 15_000 })
+  await expect.poll(() => send.isEnabled(), { timeout: 15_000 }).toBe(true)
+  await page.evaluate((expectedMarker) => {
+    const target = document.querySelector('[data-conversation-scroll]')
+    if (target === null) throw new Error('user render probe target is unavailable')
+    const probe: {
+      sendAt?: number
+      domAt?: number
+      paintAt?: number
+      trustedClick?: boolean
+      batches: number
+      records: number
+      observer?: MutationObserver
+      pollForMarker?: () => void
+    } = { batches: 0, records: 0 }
+    const transcriptContainsMarker = (): boolean => {
+      // The composer projects the draft into a backdrop and hidden sizing
+      // mirror; only a matching text node outside that card proves delivery.
+      const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT)
+      for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
+        if (!node.textContent?.includes(expectedMarker)) continue
+        const parent = node.parentElement
+        if (parent !== null && parent.closest('[data-composer-card]') === null) return true
+      }
+      return false
+    }
+    const markDomVisible = (): void => {
+      if (
+        probe.sendAt === undefined
+        || probe.domAt !== undefined
+        || !transcriptContainsMarker()
+      ) return
+      probe.domAt = globalThis.performance.now()
+      // The second callback runs only after a render opportunity for the DOM
+      // insertion observed before the first callback.
+      requestAnimationFrame(() => {
+        requestAnimationFrame(() => {
+          probe.paintAt = globalThis.performance.now()
+          observer.disconnect()
+        })
+      })
+    }
+    const pollForMarker = (): void => {
+      markDomVisible()
+      if (probe.domAt === undefined) requestAnimationFrame(pollForMarker)
+    }
+    const observer = new MutationObserver((records) => {
+      if (probe.sendAt === undefined) return
+      probe.batches += 1
+      probe.records += records.length
+      markDomVisible()
+    })
+    probe.observer = observer
+    probe.pollForMarker = pollForMarker
+    observer.observe(target, {
+      attributes: true,
+      attributeFilter: ['data-streaming'],
+      characterData: true,
+      childList: true,
+      subtree: true,
+    })
+    Reflect.set(globalThis, '__dshPerfUserRenderProbe', probe)
+  }, marker)
+  await send.evaluate((button) => {
+    button.addEventListener('click', (event) => {
+      const probe = Reflect.get(globalThis, '__dshPerfUserRenderProbe') as
+        | { sendAt?: number; trustedClick?: boolean; pollForMarker?: () => void }
+        | undefined
+      if (probe?.pollForMarker === undefined) throw new Error('user render probe was not started')
+      probe.trustedClick = event.isTrusted
+      probe.sendAt = globalThis.performance.now()
+      requestAnimationFrame(probe.pollForMarker)
+    }, { capture: true, once: true })
+  })
+}
+
+async function triggerUserRenderProbe(page: Page): Promise<void> {
+  const send = page.getByRole('button', { name: 'Send message', exact: true })
+  await send.click()
+}
+
+async function stopUserRenderProbe(
+  page: Page,
+  marker: string,
+): Promise<UserRenderProbeResult> {
+  try {
+    await page.waitForFunction(() => {
+      const probe = Reflect.get(globalThis, '__dshPerfUserRenderProbe') as
+        | { paintAt?: number }
+        | undefined
+      return probe?.paintAt !== undefined
+    }, undefined, { timeout: 15_000 })
+  } catch (error) {
+    const diagnostic = await page.evaluate((expectedMarker) => {
+      const probe = Reflect.get(globalThis, '__dshPerfUserRenderProbe') as
+        | {
+          sendAt?: number
+          domAt?: number
+          paintAt?: number
+          trustedClick?: boolean
+          batches: number
+          records: number
+        }
+        | undefined
+      const target = document.querySelector('[data-conversation-scroll]')
+      let markerOutsideComposer = false
+      if (target !== null) {
+        const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT)
+        for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
+          if (!node.textContent?.includes(expectedMarker)) continue
+          const parent = node.parentElement
+          if (parent !== null && parent.closest('[data-composer-card]') === null) {
+            markerOutsideComposer = true
+            break
+          }
+        }
+      }
+      return {
+        probe,
+        markerOutsideComposer,
+        markerInComposer: document.querySelector('[data-composer-card]')
+          ?.textContent?.includes(expectedMarker) ?? false,
+      }
+    }, marker)
+    throw new Error(`user render probe timed out: ${JSON.stringify(diagnostic)}`, { cause: error })
+  }
+  return page.evaluate(() => {
+    const probe = Reflect.get(globalThis, '__dshPerfUserRenderProbe') as
+      | {
+        sendAt?: number
+        domAt?: number
+        paintAt?: number
+        trustedClick?: boolean
+        batches: number
+        records: number
+      }
+      | undefined
+    Reflect.deleteProperty(globalThis, '__dshPerfUserRenderProbe')
+    if (
+      probe?.trustedClick !== true
+      || probe.sendAt === undefined
+      || probe.domAt === undefined
+      || probe.paintAt === undefined
+    ) {
+      throw new Error('user render probe did not observe a trusted click, DOM insertion, and paint')
+    }
+    return {
+      trustedClick: probe.trustedClick,
+      sendToDomMs: probe.domAt - probe.sendAt,
+      sendToPaintMs: probe.paintAt - probe.sendAt,
+      domToPaintMs: probe.paintAt - probe.domAt,
+      mutationBatches: probe.batches,
+      mutationRecords: probe.records,
+    }
+  })
+}
+
+async function stableCount(
+  locator: Locator,
+  accepts: (count: number) => boolean,
+  timeoutMs = 60_000,
+): Promise<number> {
+  const deadline = performance.now() + timeoutMs
+  let previous = -1
+  let stableReads = 0
+  while (performance.now() < deadline) {
+    const count = await locator.count()
+    stableReads = accepts(count) && count === previous ? stableReads + 1 : 0
+    if (stableReads >= 4) return count
+    previous = count
+    await new Promise(resolve => setTimeout(resolve, 50))
+  }
+  throw new Error(`browser row count did not stabilize; last count ${String(previous)}`)
+}
+
+async function conversationTurns(page: Page): Promise<number> {
+  const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last()
+  await stats.waitFor({ timeout: 15_000 })
+  const value = await stats.textContent()
+  const match = value?.match(/^(\d+) turns · \d+ steps$/)
+  if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`)
+  return Number(match[1])
+}
+
+function retainedDelta(
+  before: RetainedBrowserState,
+  after: RetainedBrowserState,
+): RetainedBrowserState {
+  return {
+    domElements: after.domElements - before.domElements,
+    nodes: after.nodes - before.nodes,
+    listeners: after.listeners - before.listeners,
+    heapMb: rounded(after.heapMb - before.heapMb),
+  }
+}
+
+async function launchPerformanceWorld(
+  options: PerformanceWorldOptions,
+): Promise<PerformanceWorld> {
+  const setupStarted = performance.now()
+  let replayDir: string | undefined
+  let scaffold: WebScaffold | undefined
+  let page: Page | undefined
+  try {
+    if (options.replay === undefined) {
+      scaffold = await launchWebScaffold()
+    } else {
+      replayDir = await mkdtemp(join(tmpdir(), 'dsh-web-perf-replay-'))
+      const replayOverride = join(replayDir, 'replay.override.json')
+      await writeFile(replayOverride, JSON.stringify(options.replay))
+      scaffold = await launchWebScaffold({
+        // The override-only fixture supplies one positional script for this
+        // world's single live session.
+        replayFixture: join(replayDir, 'override-only.jsonl'),
+        replayOverride,
+        paceMs: STREAM_PACE_MS,
+        replayContextWindow: PERF_REPLAY_CONTEXT_WINDOW,
+      })
+    }
+
+    const sessionEvents: SessionEvent[] = []
+    scaffold.ctx.on('session/event', (_session, event: SessionEvent) => {
+      sessionEvents.push(event)
+    })
+    if ((options.sidebarSessions ?? 0) > 0) {
+      const small = smallSidebarFixture()
+      for (let index = 0; index < (options.sidebarSessions ?? 0); index += 1) {
+        await seedSession(scaffold, small, `perf-sidebar-${String(index).padStart(4, '0')}`)
+      }
+    }
+    if (options.seedLongHistory === true) {
+      await seedSession(scaffold, longHistoryFixture(), LONG_SESSION_ID)
+    }
+    const setupMs = performance.now() - setupStarted
+    page = await newEnglishPage(options.browser)
+    return {
+      scaffold,
+      page,
+      tripwire: watchConsole(page),
+      sessionEvents,
+      setupMs,
+      ...replayDir === undefined ? {} : { replayDir },
+    }
+  } catch (error) {
+    const failures: unknown[] = [error]
+    if (page !== undefined) {
+      try {
+        await page.close()
+      } catch (cleanupError) {
+        failures.push(cleanupError)
+      }
+    }
+    if (scaffold !== undefined) {
+      try {
+        await scaffold.close()
+      } catch (cleanupError) {
+        failures.push(cleanupError)
+      }
+    }
+    if (replayDir !== undefined) {
+      try {
+        await rm(replayDir, { recursive: true, force: true })
+      } catch (cleanupError) {
+        failures.push(cleanupError)
+      }
+    }
+    if (failures.length === 1) throw error
+    throw new AggregateError(failures, 'web performance setup and cleanup failed')
+  }
+}
+
+async function closePerformanceWorld(world: PerformanceWorld): Promise<void> {
+  const failures: unknown[] = []
+  await world.page.close().catch((error: unknown) => failures.push(error))
+  await world.scaffold.close().catch((error: unknown) => failures.push(error))
+  if (world.replayDir !== undefined) {
+    await rm(world.replayDir, { recursive: true, force: true })
+      .catch((error: unknown) => failures.push(error))
+  }
+  if (failures.length === 1) throw failures[0]
+  if (failures.length > 1) throw new AggregateError(failures, 'web performance teardown failed')
+}
+
+async function openPerformancePage(
+  world: PerformanceWorld,
+  expectedSessions: number,
+): Promise<Locator> {
+  await world.page.goto(world.scaffold.baseUrl, { waitUntil: 'load' })
+  await world.page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+  const group = world.page.getByRole('treeitem').first()
+  await expect.poll(() => group.textContent(), { timeout: 30_000 })
+    .toContain(`${String(expectedSessions)} ${expectedSessions === 1 ? 'session' : 'sessions'}`)
+  return group
+}
+
+async function openLongHistory(page: Page): Promise<number> {
+  await page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
+    .fill('LONG_PERF_SENTINEL')
+  const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
+  await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
+  await results.first().click()
+  await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
+  return conversationTurns(page)
+}
+
+async function continueConversation(
+  world: PerformanceWorld,
+  cdp: CDPSession,
+  options: {
+    readonly startingTurns: number
+    readonly turnCount: number
+    readonly turnSpec: (index: number) => ConversationTurnSpec
+    readonly expectedSessionId?: SessionId
+    readonly checkpointInterval?: number
+  },
+): Promise<ConversationReport> {
+  const composer = world.page.locator('textarea:enabled').last()
+  await composer.waitFor({ timeout: 15_000 })
+  const retainedBefore = await retainedBrowserState(cdp, world.page)
+  const checkpoints: RetainedCheckpoint[] = [{ turns: options.startingTurns, state: retainedBefore }]
+  const turns: ContinuedTurnReport[] = []
+  let liveSessionId = options.expectedSessionId
+
+  for (let index = 1; index <= options.turnCount; index += 1) {
+    const spec = options.turnSpec(index)
+    const composerFill = await measure(cdp, async () => {
+      await composer.fill(spec.prompt)
+      await expect.poll(() => composer.inputValue()).toBe(spec.prompt)
+      return (await composer.inputValue()).length
+    })
+    expect(composerFill.value).toBe(spec.prompt.length)
+
+    const eventStart = world.sessionEvents.length
+    await startMutationProbe(world.page)
+    const streamBefore = await chromiumMetrics(cdp)
+    const streamStarted = performance.now()
+    const settled = world.scaffold.whenTurnSettled(60_000)
+    await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
+    await world.page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
+    const clickToUserEchoMs = performance.now() - streamStarted
+    await world.page.getByText(spec.firstMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
+    const clickToFirstChunkMs = performance.now() - streamStarted
+    const settledSessionId = await settled
+    if (liveSessionId === undefined) {
+      liveSessionId = settledSessionId
+    } else {
+      expect(settledSessionId).toBe(liveSessionId)
+    }
+    await expect.poll(
+      () => world.page.locator('[data-streaming="true"]').count(),
+      { timeout: 15_000 },
+    ).toBe(0)
+    await world.page.getByText(spec.doneMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
+    const clickToSettledMs = performance.now() - streamStarted
+    const streamAfter = await chromiumMetrics(cdp)
+    const mutations = await stopMutationProbe(world.page)
+    const turnEvents = world.sessionEvents.slice(eventStart)
+    const chunks = turnEvents.filter(event => event.type === 'assistant/chunk')
+    const toolCalls = turnEvents.filter(event => event.type === 'tool/call')
+    const toolResults = turnEvents.filter(event => event.type === 'tool/result')
+    const toolTurn = spec.toolResultMarker !== undefined
+    expect(chunks).toHaveLength(spec.deltas.length + (toolTurn ? 9 : 4))
+    expect(toolCalls).toHaveLength(toolTurn ? 1 : 0)
+    expect(toolResults).toHaveLength(toolTurn ? 1 : 0)
+    if (spec.toolResultMarker !== undefined) {
+      expect(toolCalls[0]?.data.name).toBe('bash')
+      const toolResult = toolResults[0]
+      if (toolResult?.type !== 'tool/result') {
+        throw new Error(`continued turn ${String(index)} did not log its tool result`)
+      }
+      const resultBlock = toolResult.data.message.content.find(block => block.type === 'tool-result')
+      expect(resultBlock?.isError).toBe(false)
+      expect(resultBlock?.content
+        .filter(block => block.type === 'text')
+        .map(block => block.text)
+        .join('')).toContain(spec.toolResultMarker)
+    }
+    const user = turnEvents.find(
+      event => event.type === 'user/message' && event.data.source.kind === 'user',
+    )
+    if (user?.type !== 'user/message') {
+      throw new Error(`continued turn ${String(index)} did not log its user message`)
+    }
+    expect(user.data.content
+      .filter(block => block.type === 'text')
+      .map(block => block.text)
+      .join('')).toBe(spec.prompt)
+    const resultingTurns = await conversationTurns(world.page)
+    expect(resultingTurns).toBe(options.startingTurns + index)
+    turns.push({
+      ordinal: index,
+      resultingTurns,
+      kind: toolTurn ? 'tool' : 'text',
+      promptChars: spec.prompt.length,
+      composerFill: composerFill.measurement,
+      stream: {
+        paceMs: STREAM_PACE_MS,
+        deltaChunks: spec.deltas.length,
+        persistedChunks: chunks.length,
+        toolCalls: toolCalls.length,
+        toolResults: toolResults.length,
+        clickToUserEchoMs: rounded(clickToUserEchoMs),
+        clickToFirstChunkMs: rounded(clickToFirstChunkMs),
+        firstChunkToSettledMs: rounded(clickToSettledMs - clickToFirstChunkMs),
+        ...mutations,
+        ...metricDelta(streamBefore, streamAfter, clickToSettledMs),
+      },
+    })
+
+    if (options.checkpointInterval !== undefined && index % options.checkpointInterval === 0) {
+      checkpoints.push({
+        turns: options.startingTurns + index,
+        state: await retainedBrowserState(cdp, world.page),
+      })
+    }
+  }
+
+  const lastCheckpoint = checkpoints.at(-1)
+  const retainedAfter = lastCheckpoint?.turns === options.startingTurns + options.turnCount
+    ? lastCheckpoint.state
+    : await retainedBrowserState(cdp, world.page)
+  if (lastCheckpoint?.turns !== options.startingTurns + options.turnCount) {
+    checkpoints.push({ turns: options.startingTurns + options.turnCount, state: retainedAfter })
+  }
+  return {
+    startingTurns: options.startingTurns,
+    turnsAdded: options.turnCount,
+    toolTurns: turns.filter(turn => turn.kind === 'tool').length,
+    retainedBefore,
+    retainedAfter,
+    retainedDelta: retainedDelta(retainedBefore, retainedAfter),
+    checkpoints,
+    turns,
+  }
+}
+
+async function measurePostSoakUserRender(
+  world: PerformanceWorld,
+  cdp: CDPSession,
+): Promise<object> {
+  const spec = soakTurn(POST_SOAK_RENDER_TURN)
+  if (spec.toolResultMarker !== undefined) {
+    throw new Error('post-soak render probe must remain a text-only turn')
+  }
+  const composer = world.page.locator('textarea:enabled').last()
+  const composerFill = await measure(cdp, async () => {
+    await composer.fill(spec.prompt)
+    await expect.poll(() => composer.inputValue()).toBe(spec.prompt)
+    return (await composer.inputValue()).length
+  })
+  expect(composerFill.value).toBe(spec.prompt.length)
+
+  const eventStart = world.sessionEvents.length
+  await startUserRenderProbe(world.page, spec.userMarker)
+  const browserBefore = await chromiumMetrics(cdp)
+  const fullTurnStarted = performance.now()
+  const settled = world.scaffold.whenTurnSettled(60_000)
+  await triggerUserRenderProbe(world.page)
+  const browserTiming = await stopUserRenderProbe(world.page, spec.userMarker)
+  const browserAfter = await chromiumMetrics(cdp)
+  const browserAfterPaintSample = metricDelta(
+    browserBefore,
+    browserAfter,
+    performance.now() - fullTurnStarted,
+  )
+  await world.page.getByText(spec.firstMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
+  const settledSessionId = await settled
+  await expect.poll(
+    () => world.page.locator('[data-streaming="true"]').count(),
+    { timeout: 15_000 },
+  ).toBe(0)
+  await world.page.getByText(spec.doneMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
+  const fullTurnMs = performance.now() - fullTurnStarted
+
+  const turnEvents = world.sessionEvents.slice(eventStart)
+  const chunks = turnEvents.filter(event => event.type === 'assistant/chunk')
+  const user = turnEvents.find(
+    event => event.type === 'user/message' && event.data.source.kind === 'user',
+  )
+  if (user?.type !== 'user/message') throw new Error('post-soak render probe did not log its user message')
+  expect(user.data.content
+    .filter(block => block.type === 'text')
+    .map(block => block.text)
+    .join('')).toBe(spec.prompt)
+  expect(chunks).toHaveLength(spec.deltas.length + 4)
+  expect(turnEvents.filter(event => event.type === 'tool/call')).toHaveLength(0)
+  expect(turnEvents.filter(event => event.type === 'tool/result')).toHaveLength(0)
+  expect(world.scaffold.ctx.agents.get(settledSessionId)).toBeDefined()
+  expect(await conversationTurns(world.page)).toBe(POST_SOAK_RENDER_TURN)
+
+  return {
+    ordinal: POST_SOAK_RENDER_TURN,
+    resultingTurns: POST_SOAK_RENDER_TURN,
+    promptChars: spec.prompt.length,
+    composerFill: composerFill.measurement,
+    trustedClick: browserTiming.trustedClick,
+    sendToDomMs: rounded(browserTiming.sendToDomMs),
+    sendToPaintMs: rounded(browserTiming.sendToPaintMs),
+    domToPaintMs: rounded(browserTiming.domToPaintMs),
+    mutationBatchesThroughPaint: browserTiming.mutationBatches,
+    mutationRecordsThroughPaint: browserTiming.mutationRecords,
+    browserAfterPaintSample,
+    fullTurnMs: rounded(fullTurnMs),
+    persistedChunks: chunks.length,
+  }
+}
+
+function average(values: readonly number[]): number {
+  return rounded(values.reduce((sum, value) => sum + value, 0) / values.length)
+}
+
+function p95(values: readonly number[]): number {
+  const sorted = [...values].sort((left, right) => left - right)
+  return sorted[Math.max(0, Math.ceil(sorted.length * 0.95) - 1)] ?? 0
+}
+
+function summarizeTurnWindows(
+  turns: readonly ContinuedTurnReport[],
+  windowSize: number,
+): object[] {
+  const windows: object[] = []
+  for (let start = 0; start < turns.length; start += windowSize) {
+    const window = turns.slice(start, start + windowSize)
+    const streamWall = window.map(turn => turn.stream.wallMs)
+    const userEcho = window.map(turn => turn.stream.clickToUserEchoMs)
+    const firstChunk = window.map(turn => turn.stream.clickToFirstChunkMs)
+    windows.push({
+      turns: `${String(start + 1)}-${String(start + window.length)}`,
+      toolTurns: window.filter(turn => turn.kind === 'tool').length,
+      average: {
+        composerFillWallMs: average(window.map(turn => turn.composerFill.wallMs)),
+        streamWallMs: average(streamWall),
+        clickToUserEchoMs: average(userEcho),
+        clickToFirstChunkMs: average(firstChunk),
+        taskMs: average(window.map(turn => turn.stream.taskMs)),
+        scriptMs: average(window.map(turn => turn.stream.scriptMs)),
+        recalcStyleMs: average(window.map(turn => turn.stream.recalcStyleMs)),
+        mutationBatches: average(window.map(turn => turn.stream.batches)),
+      },
+      p95: {
+        streamWallMs: rounded(p95(streamWall)),
+        clickToUserEchoMs: rounded(p95(userEcho)),
+        clickToFirstChunkMs: rounded(p95(firstChunk)),
+      },
+    })
+  }
+  return windows
+}
+
+describe('manual web performance: complex workspace and history', () => {
+  let browser: Browser
+
+  beforeAll(async () => {
+    if (webSnapshotMode() === 'record') {
+      throw new Error('manual web performance runs only with deterministic replay')
+    }
+    browser = await chromium.launch()
+  })
+
+  afterAll(async () => {
+    await browser?.close()
+  })
+
+  it('reports workspace, history, and trajectory cardinality costs', async () => {
+    const world = await launchPerformanceWorld({
+      browser,
+      sidebarSessions: SIDEBAR_SESSION_COUNT,
+      seedLongHistory: true,
+    })
+    try {
+      const bootStarted = performance.now()
+      const group = await openPerformancePage(world, SIDEBAR_SESSION_COUNT + 1)
+      const bootReadyMs = performance.now() - bootStarted
+      const page = world.page
+      const cdp = await page.context().newCDPSession(page)
+      await cdp.send('Performance.enable')
+      const firstContentfulPaintMs = await page.evaluate(
+        () => globalThis.performance.getEntriesByName('first-contentful-paint')[0]?.startTime,
+      )
+
+      const sidebar = await measure(cdp, async () => {
+        await group.click()
+        return stableCount(
+          page.getByRole('treeitem'),
+          count => count === SIDEBAR_SESSION_COUNT + 2,
+        )
+      })
+      expect(sidebar.value).toBe(SIDEBAR_SESSION_COUNT + 2)
+      await group.click()
+      await expect.poll(() => page.getByRole('treeitem').count()).toBe(1)
+
+      const contentSearch = await measure(cdp, async () => {
+        await page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
+          .fill('LONG_PERF_SENTINEL')
+        const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
+        await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
+        await results.first().waitFor({ timeout: 60_000 })
+        return results.first()
+      })
+      const opened = await measure(cdp, async () => {
+        await contentSearch.value.click()
+        await page.getByRole('tab', { name: 'Trajectory', exact: true }).waitFor({ timeout: 30_000 })
+        return conversationTurns(page)
+      })
+      expect(opened.value).toBe(DEFAULT_HISTORY_TURNS)
+
+      const trajectoryRows = page.getByRole('row')
+      const coldTrajectory = await measure(cdp, async () => {
+        await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+        return stableCount(trajectoryRows, count => count === EXPECTED_TRAJECTORY_ROWS)
+      })
+      expect(coldTrajectory.value).toBe(EXPECTED_TRAJECTORY_ROWS)
+
+      const collapseTurns = await measure(cdp, async () => {
+        await page.getByRole('button', { name: 'Collapse turns', exact: true }).click()
+        return stableCount(trajectoryRows, count => count > 0 && count < EXPECTED_TRAJECTORY_ROWS)
+      })
+      expect(collapseTurns.value).toBeLessThan(EXPECTED_TRAJECTORY_ROWS)
+      const trajectorySearch = await measure(cdp, async () => {
+        await page.getByRole('searchbox', { name: 'Search trajectory', exact: true }).fill('turn 499')
+        return stableCount(trajectoryRows, count => count > 0 && count < 20)
+      })
+      expect(trajectorySearch.value).toBeLessThan(20)
+
+      await page.getByRole('tab', { name: 'Chat', exact: true }).click()
+      const historyPages: { turns: number; measurement: Measurement }[] = []
+      let turns = await conversationTurns(page)
+      while (turns < LONG_HISTORY_TURNS) {
+        const previousTurns = turns
+        const older = await measure(cdp, async () => {
+          await page.getByRole('button', { name: 'Load earlier', exact: true }).click()
+          await expect.poll(() => conversationTurns(page), { timeout: 30_000 })
+            .toBeGreaterThan(previousTurns)
+          return conversationTurns(page)
+        })
+        turns = older.value
+        historyPages.push({ turns, measurement: older.measurement })
+      }
+
+      const warmTrajectory = await measure(cdp, async () => {
+        await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+        return stableCount(trajectoryRows, count => count === EXPECTED_TRAJECTORY_ROWS)
+      })
+      expect(warmTrajectory.value).toBe(EXPECTED_TRAJECTORY_ROWS)
+      const warmConversation = await measure(cdp, async () => {
+        await page.getByRole('tab', { name: 'Chat', exact: true }).click()
+        return conversationTurns(page)
+      })
+      expect(warmConversation.value).toBe(LONG_HISTORY_TURNS)
+
+      console.info(`WEB_PERF_RESULT ${JSON.stringify({
+        scenario: 'workspace-history-trajectory',
+        fixture: {
+          sidebarSessions: SIDEBAR_SESSION_COUNT,
+          totalSessions: SIDEBAR_SESSION_COUNT + 1,
+          longHistoryTurns: LONG_HISTORY_TURNS,
+          toolCalls: EXPECTED_TOOL_CALLS,
+          trajectoryRows: EXPECTED_TRAJECTORY_ROWS,
+        },
+        setupMs: rounded(world.setupMs),
+        boot: {
+          readyMs: rounded(bootReadyMs),
+          firstContentfulPaintMs: firstContentfulPaintMs === undefined
+            ? null
+            : rounded(firstContentfulPaintMs),
+        },
+        sidebarExpand: sidebar.measurement,
+        contentSearch: contentSearch.measurement,
+        openLongHistory: { initialTurns: opened.value, ...opened.measurement },
+        coldTrajectory: { rows: coldTrajectory.value, ...coldTrajectory.measurement },
+        collapseTurns: { rows: collapseTurns.value, ...collapseTurns.measurement },
+        trajectorySearch: { rows: trajectorySearch.value, ...trajectorySearch.measurement },
+        historyPages,
+        warmTrajectory: { rows: warmTrajectory.value, ...warmTrajectory.measurement },
+        warmConversation: { turns: warmConversation.value, ...warmConversation.measurement },
+      }, null, 2)}`)
+      expect(world.tripwire.warnings).toEqual([])
+      expect(world.tripwire.pageErrors).toEqual([])
+    } finally {
+      await closePerformanceWorld(world)
+    }
+  })
+
+  it('reports default 24-turn history plus eight continued turns', async () => {
+    const world = await launchPerformanceWorld({
+      browser,
+      replay: performanceReplayOverride(COMPARISON_TURNS, comparisonTurn),
+      seedLongHistory: true,
+    })
+    try {
+      await openPerformancePage(world, 1)
+      const cdp = await world.page.context().newCDPSession(world.page)
+      await cdp.send('Performance.enable')
+      const opened = await measure(cdp, () => openLongHistory(world.page))
+      expect(opened.value).toBe(DEFAULT_HISTORY_TURNS)
+      const conversation = await continueConversation(world, cdp, {
+        startingTurns: DEFAULT_HISTORY_TURNS,
+        turnCount: COMPARISON_TURNS,
+        turnSpec: comparisonTurn,
+        expectedSessionId: SessionId(LONG_SESSION_ID),
+      })
+      expect(conversation.toolTurns).toBe(2)
+      console.info(`WEB_PERF_RESULT ${JSON.stringify({
+        scenario: 'default-resume-24-plus-8',
+        setupMs: rounded(world.setupMs),
+        replayContextWindow: PERF_REPLAY_CONTEXT_WINDOW,
+        openLongHistory: { turns: opened.value, ...opened.measurement },
+        conversation,
+      }, null, 2)}`)
+      expect(world.tripwire.warnings).toEqual([])
+      expect(world.tripwire.pageErrors).toEqual([])
+    } finally {
+      await closePerformanceWorld(world)
+    }
+  })
+
+  it('reports fully expanded 500-turn history plus eight continued turns', async () => {
+    const world = await launchPerformanceWorld({
+      browser,
+      replay: performanceReplayOverride(COMPARISON_TURNS, comparisonTurn),
+      seedLongHistory: true,
+    })
+    try {
+      await openPerformancePage(world, 1)
+      const cdp = await world.page.context().newCDPSession(world.page)
+      await cdp.send('Performance.enable')
+      expect(await openLongHistory(world.page)).toBe(DEFAULT_HISTORY_TURNS)
+      const historyPages: { turns: number; measurement: Measurement }[] = []
+      let turns = DEFAULT_HISTORY_TURNS
+      while (turns < LONG_HISTORY_TURNS) {
+        const previousTurns = turns
+        const older = await measure(cdp, async () => {
+          await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click()
+          await expect.poll(() => conversationTurns(world.page), { timeout: 30_000 })
+            .toBeGreaterThan(previousTurns)
+          return conversationTurns(world.page)
+        })
+        turns = older.value
+        historyPages.push({ turns, measurement: older.measurement })
+      }
+      expect(turns).toBe(LONG_HISTORY_TURNS)
+      const conversation = await continueConversation(world, cdp, {
+        startingTurns: LONG_HISTORY_TURNS,
+        turnCount: COMPARISON_TURNS,
+        turnSpec: comparisonTurn,
+        expectedSessionId: SessionId(LONG_SESSION_ID),
+      })
+      expect(conversation.toolTurns).toBe(2)
+      console.info(`WEB_PERF_RESULT ${JSON.stringify({
+        scenario: 'expanded-history-500-plus-8',
+        setupMs: rounded(world.setupMs),
+        replayContextWindow: PERF_REPLAY_CONTEXT_WINDOW,
+        historyPages,
+        conversation,
+      }, null, 2)}`)
+      expect(world.tripwire.warnings).toEqual([])
+      expect(world.tripwire.pageErrors).toEqual([])
+    } finally {
+      await closePerformanceWorld(world)
+    }
+  })
+
+  it('reports one hundred generated turns and the next user-message paint', async () => {
+    const world = await launchPerformanceWorld({
+      browser,
+      replay: performanceReplayOverride(POST_SOAK_RENDER_TURN, soakTurn),
+    })
+    let testFailure: unknown
+    try {
+      await world.page.goto(world.scaffold.baseUrl, { waitUntil: 'load' })
+      await world.page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+      await connectFreshWorkspace(world.page, world.scaffold.workspaceCwd, 'continuous-conversation-perf')
+      const cdp = await world.page.context().newCDPSession(world.page)
+      await cdp.send('Performance.enable')
+      const conversation = await continueConversation(world, cdp, {
+        startingTurns: 0,
+        turnCount: SOAK_TURNS,
+        turnSpec: soakTurn,
+        checkpointInterval: SOAK_CHECKPOINT_INTERVAL,
+      })
+      expect(conversation.toolTurns).toBe(SOAK_TURNS / SOAK_TOOL_INTERVAL)
+      const postSoakUserRender = await measurePostSoakUserRender(world, cdp)
+      const { turns, ...retained } = conversation
+      console.info(`WEB_PERF_RESULT ${JSON.stringify({
+        scenario: 'continuous-100-turn-soak',
+        setupMs: rounded(world.setupMs),
+        replayContextWindow: PERF_REPLAY_CONTEXT_WINDOW,
+        ...retained,
+        windows: summarizeTurnWindows(turns, SOAK_CHECKPOINT_INTERVAL),
+        postSoakUserRender,
+      }, null, 2)}`)
+      expect(world.tripwire.warnings).toEqual([])
+      expect(world.tripwire.pageErrors).toEqual([])
+    } catch (error) {
+      testFailure = error
+      throw error
+    } finally {
+      try {
+        await closePerformanceWorld(world)
+      } catch (cleanupError) {
+        if (testFailure === undefined) throw cleanupError
+        throw new AggregateError(
+          [testFailure, cleanupError],
+          'continuous conversation performance test and teardown failed',
+        )
+      }
+    }
+  })
+})

+ 90 - 14
apps/web/tests/navigation-panes.e2e.ts

@@ -9,9 +9,9 @@
 import { mkdir, readFile, writeFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
-import type { Browser, Page } from 'playwright'
+import type { Browser, Page, Response } from 'playwright'
 import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest'
 import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
@@ -34,12 +34,46 @@ const SEED_ID = 'navigation-panes-web-e2e'
 const PROMPT_TURN1 = 'NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop.'
 const PROMPT_TURN2 = 'Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop.'
 
+async function baselineResponse(
+  page: Page,
+  method: 'session.list' | 'workspace.list',
+): Promise<Response> {
+  return page.waitForResponse(response => (
+    response.request().method() === 'POST'
+    && new URL(response.url()).pathname === `/api/${method}`
+  ), { timeout: 30_000 })
+}
+
+async function assertBaselineSucceeded(response: Response, method: string): Promise<void> {
+  expect(response.ok(), `${method} baseline HTTP response`).toBe(true)
+  const body = await response.json() as { result?: { ok?: unknown } }
+  expect(body.result?.ok, `${method} baseline RPC result`).toBe(true)
+}
+
+async function ensureSeedOpen(page: Page): Promise<void> {
+  const chat = page.getByRole('tab', { name: 'Chat', exact: true })
+  const search = page.getByPlaceholder('Search name, keywords', { exact: false })
+  if (await chat.count() === 0) {
+    await search.fill('WATERFALL')
+    const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
+    await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)
+    await result.click()
+    await chat.waitFor({ timeout: 15_000 })
+  }
+  await chat.click()
+  await page.getByText('FIRST_DONE', { exact: true }).waitFor({ timeout: 15_000 })
+  if (await search.inputValue() !== '') {
+    await search.fill('')
+    await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
+  }
+}
+
 describe('web e2e: navigation & panes over a rich seeded session', () => {
   let scaffold: WebScaffold
   let browser: Browser
   let page: Page
-  let tripwire: ReturnType<typeof watchConsole>
-  let slotErrors: string[]
+  let tripwire: ReturnType<typeof watchConsole> = { warnings: [], pageErrors: [] }
+  let slotErrors: string[] = []
 
   beforeAll(async () => {
     scaffold = await launchWebScaffold({})
@@ -57,6 +91,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
       await seedSession(scaffold, raw, SEED_ID)
     }
     browser = await chromium.launch()
+  }, 120_000)
+
+  beforeEach(async () => {
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
     slotErrors = []
@@ -65,7 +102,19 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
         slotErrors.push(message.text())
       }
     })
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    // Initial navigation and list ownership settle only after both independent
+    // RPC baselines succeed; arm before navigation so neither response is missed.
+    const sessionBaseline = baselineResponse(page, 'session.list')
+    const workspaceBaseline = baselineResponse(page, 'workspace.list')
+    const [, sessionResponse, workspaceResponse] = await Promise.all([
+      page.goto(scaffold.baseUrl, { waitUntil: 'load' }),
+      sessionBaseline,
+      workspaceBaseline,
+    ])
+    await Promise.all([
+      assertBaselineSucceeded(sessionResponse, 'session.list'),
+      assertBaselineSucceeded(workspaceResponse, 'workspace.list'),
+    ])
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     // The frame mounts before the asynchronous session-list baseline lands.
     // Search must target the settled seeded row, not the startup input that
@@ -73,9 +122,32 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
   }, 120_000)
 
+  afterEach(async () => {
+    const failures: unknown[] = []
+    try {
+      expect({
+        pageErrors: tripwire.pageErrors,
+        slotErrors,
+        warnings: tripwire.warnings,
+      }).toEqual({
+        pageErrors: [],
+        slotErrors: [],
+        warnings: [],
+      })
+    } catch (error) {
+      failures.push(error)
+    }
+    await page?.close().catch((error: unknown) => failures.push(error))
+    if (failures.length === 1) throw failures[0]
+    if (failures.length > 1) throw new AggregateError(failures, 'navigation case cleanup failed')
+  })
+
   afterAll(async () => {
-    await browser?.close()
-    await scaffold?.close()
+    const failures: unknown[] = []
+    await browser?.close().catch((error: unknown) => failures.push(error))
+    await scaffold?.close().catch((error: unknown) => failures.push(error))
+    if (failures.length === 1) throw failures[0]
+    if (failures.length > 1) throw new AggregateError(failures, 'navigation e2e cleanup failed')
   })
 
   it.skipIf(MODE !== 'record')('records the two-turn seed live through the composer', async () => {
@@ -102,6 +174,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
 
   it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
+    // The API baselines can settle before React commits their projection. The
+    // seeded count is the final user-visible barrier before editing search.
+    await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
     const search = page.getByPlaceholder('Search name, keywords', { exact: false })
     // The cold row has not been opened, so only the persisted log can satisfy
     // this query. First search lazily reconciles the SQLite content index.
@@ -136,6 +211,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
 
   it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
+    await ensureSeedOpen(page)
     await page.getByRole('tab', { name: 'Trajectory' }).click()
     await page.waitForTimeout(100)
     const overlayLayout = await page.getByRole('table').evaluate((table) => {
@@ -200,7 +276,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
 
   it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
+    await ensureSeedOpen(page)
+    await page.getByRole('tab', { name: 'Trajectory' }).click()
     const plot = page.getByLabel('Timeline overview; drag horizontally to focus events')
+    await plot.waitFor({ timeout: 15_000 })
     const before = await page.locator('tr[data-kind]').count()
     const box = await plot.boundingBox()
     if (box === null) throw new Error('trajectory timeline plot has no layout box')
@@ -217,7 +296,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
 
   it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
-    await page.getByRole('tab', { name: 'Chat' }).click()
+    await ensureSeedOpen(page)
     const bashRow = page.locator('[data-sample="bash"]').first()
     await bashRow.waitFor({ timeout: 15_000 })
     const frame = page.locator('[style*="grid-template-columns"]').first()
@@ -239,9 +318,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
 
   it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal'))
-    await page.getByRole('tab', { name: 'Chat' }).click()
+    await ensureSeedOpen(page)
     // The card is expand-gated behind the whole-row toggle (the unified
-    // tool-row interaction): open it if a previous case left it collapsed.
+    // tool-row interaction): open it if this fresh view leaves it collapsed.
     // Expanded, the recorded command's own output sits in the message flow,
     // derived from the logged call/result presentations alone.
     const bashRow = page.locator('[data-sample="bash"]').first()
@@ -322,10 +401,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK')
   }, 60_000)
 
-  it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
-    expect(tripwire.pageErrors).toEqual([])
-    expect(slotErrors).toEqual([])
-    expect(tripwire.warnings).toEqual([])
+  it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
       'seed.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
       'terminal-card.expected.md',

+ 11 - 1
apps/web/tests/scaffold.ts

@@ -85,6 +85,14 @@ const REPLAY_PROVIDERS = [{
   models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
 }]
 
+function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
+  if (contextWindow === undefined) return REPLAY_PROVIDERS
+  return REPLAY_PROVIDERS.map(provider => ({
+    ...provider,
+    models: provider.models.map(model => ({ ...model, contextWindow })),
+  }))
+}
+
 /** A booted web scaffold: real composition, mode-selected model backend, temp world. */
 export interface WebScaffold {
   /** The active snapshot mode this scaffold booted under. */
@@ -134,6 +142,8 @@ export interface LaunchOptions {
   replayOverride?: string
   /** Per-chunk replay pacing (ms) so the browser observes genuinely incremental SSE; replay/refresh only. */
   paceMs?: number
+  /** Synthetic model capacity for UI scenarios whose seeded history must remain uncompacted. */
+  replayContextWindow?: number
   /**
    * Tool presentation mode patched onto the shipped `tools` row (`code`
    * collapses the wire to run_code + the SDK prompt section). Omit for the
@@ -344,7 +354,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     if (mode !== 'record' && options.replayFixture !== undefined) {
       replayHandle = installLlmReplay(ctx, {
         file: options.replayFixture,
-        providers: REPLAY_PROVIDERS,
+        providers: replayProviders(options.replayContextWindow),
         ...(options.replayOverride === undefined ? {} : { overrideFile: options.replayOverride }),
         ...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
         ...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),

+ 6 - 1
apps/web/tsconfig.json

@@ -56,7 +56,12 @@
     "tests/goal-bar.e2e.ts",
     "tests/startup-auto-selection.e2e.ts",
     "tests/subagent-conversation.e2e.ts",
-    "tests/bash-abort-row.e2e.ts"
+    "tests/bash-abort-row.e2e.ts",
+    "tests/chat-scroll-fixture.ts",
+    "tests/chat-scroll-contract.e2e.ts",
+    "tests/chat-long-interactions.e2e.ts",
+    "tests/chat-continuous-conversation.e2e.ts",
+    "tests/complex-history.perf.ts"
   ],
   "references": [
     {

+ 1 - 0
knip.json

@@ -587,6 +587,7 @@
     "apps/web": {
       "entry": [
         "tests/**/*.e2e.ts",
+        "tests/**/*.perf.ts",
         "tests/**/*.snapshot.ts",
         "tests/support.ts",
         "src/node-module-stub.ts"

+ 2 - 0
package.json

@@ -33,6 +33,8 @@
     "test:web": "npm run build && npm run test:web:built",
     "test:web:refresh": "npm run build && DSH_SNAPSHOT=refresh vitest run --config vitest.web.config.ts",
     "test:web:built": "vitest run --config vitest.web.config.ts",
+    "test:web:perf": "npm run build && npm run test:web:perf:built",
+    "test:web:perf:built": "DSH_SNAPSHOT=replay vitest run --config vitest.web.perf.config.ts",
     "test:gui": "vitest run packages/client packages/host",
     "check:all": "tsx scripts/run-gates.ts check-all",
     "check:ci": "tsx scripts/run-gates.ts ci-primary",

+ 9 - 9
packages/client/ui-conversation/src/client/apply.ts

@@ -7,7 +7,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type { ViewTab } from './contract/views.ts'
 import type {
-  ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
+  ApprovalWait, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
   ConversationSessionInjected, DetailsInjected,
 } from './contract/slots.ts'
 import type { InputNotice } from './input/contract.ts'
@@ -113,10 +113,10 @@ export function apply(ctx: Context): void {
     return () => { row.dispose() }
   }, 'ui-conversation: Enter behavior settings row')
 
-  // Chat scroll offsets by session, surviving view switches (the chat view
-  // unmounts under the tab ring). Deliberately not persisted: a fresh page
-  // load should keep the open-jump-to-bottom default.
-  const chatScrollTops = new Map<SessionId, number>()
+  // Chat semantic reader positions by session, surviving view switches and
+  // width reflow when the tab ring remounts the view. Deliberately not
+  // persisted: a fresh page load keeps the open-jump-to-bottom default.
+  const chatScrollPositions = new Map<SessionId, ChatScrollPosition>()
 
   const viewTabs = (): ViewTab[] => {
     const tabs: ViewTab[] = []
@@ -316,11 +316,11 @@ export function apply(ctx: Context): void {
           actions.setView('trajectory')
         },
         chatScroll: {
-          save: (top) => {
-            if (top === null) chatScrollTops.delete(sessionId)
-            else chatScrollTops.set(sessionId, top)
+          save: (position) => {
+            if (position === null) chatScrollPositions.delete(sessionId)
+            else chatScrollPositions.set(sessionId, position)
           },
-          read: () => chatScrollTops.get(sessionId) ?? null,
+          read: () => chatScrollPositions.get(sessionId) ?? null,
         },
         forkAt: (seq) => {
           sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })

+ 6 - 0
packages/client/ui-conversation/src/client/chat/ChatView.module.css

@@ -42,6 +42,12 @@
   gap: 16px;
 }
 
+/* Settled-flow identity boundary. It is neutral today and becomes the natural
+   measurement/mount unit for a virtualizer without changing the column gap. */
+.flowItem {
+  min-width: 0;
+}
+
 .toolGroup {
   display: flex;
   flex-direction: column;

+ 198 - 28
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -44,6 +44,59 @@ function scrollerOf(from: HTMLElement): HTMLElement {
   return (from.closest('[data-conversation-scroll]')) ?? from
 }
 
+interface PagingAnchor {
+  /** Stable node/call identity, independent of boundary-spanning group keys. */
+  key: string
+  /** Row top relative to the scrollport after the latest user scroll. */
+  top: number
+}
+
+/** Find an already-rendered settled row without interpolating a selector. */
+function anchorElement(list: HTMLElement, key: string): HTMLElement | null {
+  for (const row of list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')) {
+    if (row.dataset.chatAnchorKey === key) return row
+  }
+  return null
+}
+
+/** Row position in scrollport coordinates (viewport-independent). */
+function flowTop(row: HTMLElement, scrollport: HTMLElement): number {
+  return row.getBoundingClientRect().top - scrollport.getBoundingClientRect().top
+}
+
+/** Select a visible stable node/call identity, falling back only when layout
+ * has not exposed a visible box yet. */
+function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement | null {
+  const viewport = scrollport.getBoundingClientRect()
+  const composer = scrollport.querySelector<HTMLElement>('[data-composer-seat]')
+  const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
+  // Scroll events are hot: hit-test a few points through the stretched flow
+  // rows before considering the full mounted set. The fallback keeps jsdom
+  // and pre-layout states deterministic; a virtualizer naturally bounds it.
+  if (typeof document.elementsFromPoint === 'function' && visibleBottom > viewport.top) {
+    const content = list.getBoundingClientRect()
+    const left = Math.max(viewport.left, content.left)
+    const right = Math.min(viewport.right, content.right)
+    const x = left + Math.max(0, right - left) / 2
+    const height = visibleBottom - viewport.top
+    const points = [1, Math.min(32, height / 3), height / 2, Math.max(1, height - 1)]
+    for (const offset of points) {
+      for (const element of document.elementsFromPoint(x, viewport.top + offset)) {
+        const row = element instanceof HTMLElement
+          ? element.closest<HTMLElement>('[data-chat-anchor-key]')
+          : null
+        if (row !== null && list.contains(row)) return row
+      }
+    }
+  }
+  const rows = [...list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
+  const visibleRows = rows.filter((row) => {
+    const rect = row.getBoundingClientRect()
+    return rect.bottom > viewport.top && rect.top < visibleBottom
+  })
+  return visibleRows[0] ?? rows[0] ?? null
+}
+
 type OpenFile = (path: string) => void
 
 type InspectCall = (callId: string) => void
@@ -51,6 +104,8 @@ type InspectCall = (callId: string) => void
 /** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
 type RenderToolRow = ChatViewSlotProps['renderSlot']
 
+type ChatScrollPosition = NonNullable<ReturnType<ChatViewSlotProps['chatScroll']['read']>>
+
 /** ui-slots' UseSession is deliberately wide (dependency direction); the
  *  chat view narrows once to the runtime snapshot the binding actually feeds. */
 type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
@@ -66,6 +121,18 @@ function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): n
   return null
 }
 
+/** Capture a reflow-resistant reader position from the current rendered window. */
+function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollPosition | null {
+  const row = pagingAnchor(list, scrollport)
+  const anchorKey = row?.dataset.chatAnchorKey
+  if (row === null || anchorKey === undefined) return null
+  return {
+    anchorKey,
+    anchorTop: flowTop(row, scrollport),
+    scrollTop: scrollport.scrollTop,
+  }
+}
+
 /** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a
  *  top-level call (same registrations, same fallback), nested by the parent.
  *  A started-but-unsettled sub-call arrives as the RunningToolCall shape and
@@ -86,7 +153,12 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select
     inspect: () => { inspectCall(node.callId) },
   }), [node, toolName, openFile, cwd, inspectCall])
   return (
-    <div className={css.callRow} data-selected={selected || undefined}>
+    <div
+      className={css.callRow}
+      data-chat-anchor-key={`call:${node.callId}`}
+      data-chat-call-id={node.callId}
+      data-selected={selected || undefined}
+    >
       {renderSlot('conversation.chat.toolview', owner, {
         entryKey: toolName,
         fallback: <GenericToolCard {...owner} t={t} />,
@@ -124,7 +196,12 @@ const CallRow = memo(function CallRow({
     inspect: () => { inspectCall(callId) },
   }), [callId, toolName, block, openFile, cwd, inspectCall])
   return (
-    <div className={css.callRow} data-selected={selected || undefined}>
+    <div
+      className={css.callRow}
+      data-chat-anchor-key={`call:${callId}`}
+      data-chat-call-id={callId}
+      data-selected={selected || undefined}
+    >
       {renderSlot('conversation.chat.toolview', owner, {
         entryKey: toolName,
         fallback: <GenericToolCard {...owner} t={t} />,
@@ -213,17 +290,13 @@ function TurnStatus() {
   )
 }
 
-/** The streaming partial, isolated so chunk batches re-render only this tail.
- *  onGrow lets the scroll owner follow content the parent never re-renders for. */
-function StreamingTail({ useSession, onGrow, t }: {
+/** The streaming partial, isolated so chunk batches re-render only this tail;
+ *  the column ResizeObserver owns bottom-follow when its box grows. */
+function StreamingTail({ useSession, t }: {
   useSession: UseConversation
-  onGrow: () => void
   t: ChatViewSlotProps['t']
 }) {
   const partial = useSession(s => s.partial)
-  useLayoutEffect(() => {
-    onGrow()
-  })
   if (partial === null) return null
   return <AssistantMarkdown blocks={partial.blocks} streaming t={t} />
 }
@@ -261,10 +334,17 @@ export function ChatView({
   const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
 
   const listRef = useRef<HTMLDivElement | null>(null)
+  const columnRef = useRef<HTMLDivElement | null>(null)
   const atBottomRef = useRef(true)
   const [atBottom, setAtBottom] = useState(true)
-  /** Paging anchor: height/position at click, compensated after the prepend lands. */
-  const anchorRef = useRef<{ h: number; t: number } | null>(null)
+  /** Last position delivered or written on the main thread. */
+  const observedTopRef = useRef(0)
+  /** Pre-input position for the current wheel gesture. */
+  const wheelStartRef = useRef<number | null>(null)
+  const wheelEpochRef = useRef(0)
+  /** Paging anchor: semantic row/position at click, updated by reader scrolls
+   * while the request is pending and restored after the prepend lands. */
+  const anchorRef = useRef<PagingAnchor | null>(null)
   const firstSeqRef = useRef<number | null>(null)
   const openedRef = useRef(false)
   const lastKeyRef = useRef<string | null>(null)
@@ -281,9 +361,14 @@ export function ChatView({
   const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}`
 
   const toBottom = (el: HTMLElement): void => {
+    wheelStartRef.current = null
+    wheelEpochRef.current += 1
+    anchorRef.current = null
     el.scrollTop = el.scrollHeight
+    observedTopRef.current = el.scrollTop
     atBottomRef.current = true
     setAtBottom(true)
+    chatScroll.save(null)
   }
 
   useLayoutEffect(() => {
@@ -300,10 +385,16 @@ export function ChatView({
       if (saved === null) {
         toBottom(el)
       } else {
-        el.scrollTop = saved
+        el.scrollTop = saved.scrollTop
+        const row = anchorElement(local, saved.anchorKey)
+        if (row !== null) el.scrollTop += flowTop(row, el) - saved.anchorTop
+        observedTopRef.current = el.scrollTop
         const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
         atBottomRef.current = isAtBottom
         setAtBottom(isAtBottom)
+        const normalized = isAtBottom ? null : scrollPosition(local, el)
+        if (isAtBottom) chatScroll.save(null)
+        else if (normalized !== null) chatScroll.save(normalized)
       }
       firstSeqRef.current = firstSeq
       lastKeyRef.current = lastKey
@@ -311,10 +402,15 @@ export function ChatView({
       followSigRef.current = followSig
       return
     }
-    // Prepend (head seq decreased): compensate by the height delta.
+    // Prepend (head seq decreased): preserve the same settled row at the
+    // position established by the reader's latest scroll. This excludes
+    // unrelated tail/composer growth while the request was in flight.
     if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) {
-      el.scrollTop = anchorRef.current.t + (el.scrollHeight - anchorRef.current.h)
+      const anchor = anchorRef.current
       anchorRef.current = null
+      const row = anchorElement(local, anchor.key)
+      if (row !== null) el.scrollTop += flowTop(row, el) - anchor.top
+      observedTopRef.current = el.scrollTop
       firstSeqRef.current = firstSeq
       /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
       lastKeyRef.current = lastKey
@@ -343,26 +439,66 @@ export function ChatView({
     /* v8 ignore next -- ref-null guard: the handler only fires while mounted. */
     if (local === null) return
     const el = scrollerOf(local)
-    const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
+    // Only wheel input may make raw scroll geometry change follow ownership.
+    // Browser clamping and delayed programmatic scroll events otherwise have
+    // the same event shape and must preserve the current ownership state.
+    const floor = Math.max(0, el.scrollHeight - el.clientHeight)
+    const wheelStart = wheelStartRef.current
+    const movedByWheel = wheelStart !== null
+      && Math.abs(el.scrollTop - Math.min(wheelStart, floor)) > 0.5
+    const isAtBottom = movedByWheel
+      ? floor - el.scrollTop <= FOLLOW_THRESHOLD + 1
+      : atBottomRef.current
+    if (!movedByWheel && isAtBottom) {
+      toBottom(el)
+      return
+    }
     atBottomRef.current = isAtBottom
     setAtBottom(isAtBottom)
+    const position = isAtBottom ? null : scrollPosition(local, el)
+    if (isAtBottom) {
+      anchorRef.current = null
+    } else if (anchorRef.current !== null && position !== null) {
+      anchorRef.current = { key: position.anchorKey, top: position.anchorTop }
+    }
     // Continuous save (unmount happens after ref detach, so saving there is
     // too late); pinned-to-bottom clears so a remount keeps following.
-    chatScroll.save(isAtBottom ? null : el.scrollTop)
+    if (isAtBottom) chatScroll.save(null)
+    else if (position !== null) chatScroll.save(position)
+    observedTopRef.current = el.scrollTop
   }
 
-  // Bind scroll to the resolved scrollport (host or local) once per mount.
+  // Bind scroll and the wheel provenance needed to distinguish reader input
+  // from layout-driven scrolls on the resolved scrollport once per mount.
   useEffect(() => {
     const local = listRef.current
     /* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
     if (local === null) return
     const el = scrollerOf(local)
     const onScroll = (): void => { onScrollRef.current() }
+    const onWheel = (event: WheelEvent): void => {
+      if (event.ctrlKey || event.deltaY === 0) return
+      const startTop = observedTopRef.current
+      const floor = Math.max(0, el.scrollHeight - el.clientHeight)
+      const canMove = event.deltaY < 0 ? startTop > 1 : startTop < floor - 1
+      if (!canMove) return
+      wheelStartRef.current = startTop
+      const epoch = ++wheelEpochRef.current
+      requestAnimationFrame(() => {
+        requestAnimationFrame(() => {
+          if (wheelEpochRef.current === epoch) wheelStartRef.current = null
+        })
+      })
+    }
     el.addEventListener('scroll', onScroll, { passive: true })
-    return () => { el.removeEventListener('scroll', onScroll) }
+    el.addEventListener('wheel', onWheel, { capture: true, passive: true })
+    return () => {
+      wheelStartRef.current = null
+      el.removeEventListener('scroll', onScroll)
+      el.removeEventListener('wheel', onWheel, true)
+    }
   }, [])
 
-  // Follow streaming growth the parent never re-renders for (stable ref).
   // The ref starts null and is assigned every render, so the placeholder
   // initializer a function initial value would need never exists.
   const followRef = useRef<(() => void) | null>(null)
@@ -371,16 +507,43 @@ export function ChatView({
     if (local !== null && atBottomRef.current) {
       const el = scrollerOf(local)
       el.scrollTop = el.scrollHeight
+      observedTopRef.current = el.scrollTop
+      chatScroll.save(null)
     }
   }
-  const onGrow = useRef(() => followRef.current?.()).current
+  // Streaming, tool disclosures, and other flow changes resize the column;
+  // the sticky composer resizes outside it. This observer owns ChatView's
+  // dynamic-height follow decisions and writes only while the reader is pinned.
+  useEffect(() => {
+    const column = columnRef.current
+    const local = listRef.current
+    if (column === null || local === null || typeof ResizeObserver === 'undefined') return
+    const scrollport = scrollerOf(local)
+    const composer = scrollport.querySelector<HTMLElement>('[data-composer-seat]')
+    const observer = new ResizeObserver(() => { followRef.current?.() })
+    observer.observe(column)
+    if (composer !== null) observer.observe(composer)
+    return () => { observer.disconnect() }
+  }, [])
+
+  // A failed/empty page leaves the head unchanged. Once the request leaves
+  // its busy state there is no future prepend for the saved anchor to own.
+  useEffect(() => {
+    if (!loadingOlder) anchorRef.current = null
+  }, [loadingOlder])
 
   const loadOlderAnchored = (): void => {
     const local = listRef.current
     /* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
     if (local !== null) {
       const el = scrollerOf(local)
-      anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
+      const row = pagingAnchor(local, el)
+      if (row !== null && row.dataset.chatAnchorKey !== undefined) {
+        anchorRef.current = {
+          key: row.dataset.chatAnchorKey,
+          top: flowTop(row, el),
+        }
+      }
     }
     loadOlder()
   }
@@ -392,7 +555,6 @@ export function ChatView({
           || codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true)
       return (
         <ToolGroup
-          key={item.key}
           renderSlot={renderSlot}
           results={item.results}
           openFile={openFile}
@@ -408,7 +570,6 @@ export function ChatView({
     if (node.kind === 'assistant') {
       return (
         <AssistantMarkdown
-          key={item.key}
           blocks={node.blocks}
           streaming={false}
           interrupted={node.interrupted}
@@ -421,13 +582,12 @@ export function ChatView({
       )
     }
     if (node.kind === 'command') {
-      return <CommandRow key={item.key} renderSlot={renderSlot} node={node} t={t} />
+      return <CommandRow renderSlot={renderSlot} node={node} t={t} />
     }
     /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
     if (node.kind === 'tool-result') return null
     return (
       <MessageItem
-        key={item.key}
         node={node}
         retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
         onFork={forkAt}
@@ -440,7 +600,7 @@ export function ChatView({
   return (
     <div className={css.root}>
       <div ref={listRef} className={css.scroll}>
-        <div className={css.column}>
+        <div ref={columnRef} className={css.column} data-chat-flow="">
           {openState === 'loading' && <div className={css.hint}>{t('chat.loadingHistory')}</div>}
           {openState === 'error' && openError !== null && (
             <div className={css.openError}>
@@ -454,8 +614,18 @@ export function ChatView({
               </button>
             </div>
           )}
-          {items.map(renderItem)}
-          <StreamingTail useSession={useSession} onGrow={onGrow} t={t} />
+          {items.map(item => (
+            <div
+              key={item.key}
+              className={css.flowItem}
+              data-chat-anchor-key={item.kind === 'node' ? `node:${String(item.node.seq)}` : undefined}
+              data-chat-flow-key={item.key}
+              data-chat-flow-kind={item.kind === 'node' ? item.node.kind : 'tool-group'}
+            >
+              {renderItem(item)}
+            </div>
+          ))}
+          <StreamingTail useSession={useSession} t={t} />
           {runningCalls.length > 0 && (
             <div className={css.toolGroup}>
               {runningCalls.map(call => (

+ 14 - 4
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -435,6 +435,16 @@ export class PendingApproval {
 export type ApprovalComposerProps =
   PropsRuntime<'conversation.composer'> & { matched: ApprovalWait } & PropsLocale<'conversation'>
 
+/** In-memory reader position resilient to transcript width reflow. */
+export interface ChatScrollPosition {
+  /** Stable rendered node/call identity nearest the visible reading edge. */
+  readonly anchorKey: string
+  /** Anchor top relative to the transcript scrollport when saved. */
+  readonly anchorTop: number
+  /** Approximate offset used before the semantic anchor is measured. */
+  readonly scrollTop: number
+}
+
 /**
  * Injected share of the chat view entry: the two callbacks whose targets live
  * outside the view (layout orchestration; the session object layer).
@@ -456,10 +466,10 @@ export interface ChatViewInjected {
    * fresh page load starts empty and keeps the open-jump-to-bottom default.
    */
   chatScroll: {
-    /** Record the scroll offset; null clears it (pinned to bottom). */
-    save: (top: number | null) => void
-    /** Last recorded offset, or null when pinned or never recorded. */
-    read: () => number | null
+    /** Record a semantic reader position; null clears it when pinned. */
+    save: (position: ChatScrollPosition | null) => void
+    /** Last reader position, or null when pinned or never recorded. */
+    read: () => ChatScrollPosition | null
   }
   /** Fork through the completed turn ending at the eligible message `seq`, then open the child. */
   forkAt: (seq: number) => void

+ 263 - 25
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -22,7 +22,10 @@ import { ChatView } from '../src/client/chat/ChatView.tsx'
 import { zh } from '../src/client/locales.ts'
 import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs } from '../src/client/chat/chat-flow.ts'
 
-afterEach(cleanup)
+afterEach(() => {
+  cleanup()
+  vi.unstubAllGlobals()
+})
 // Keyless create() persists under the bare declared key; clear between cases
 // so one harness's selection cannot rehydrate into the next.
 beforeEach(() => {
@@ -112,10 +115,10 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
   const loadOlder = vi.fn()
   const inspectCall = vi.fn<(callId: string) => void>()
   // In-memory scroll memory matching the apply.ts per-session map contract.
-  let savedScrollTop: number | null = null
-  const chatScroll = {
-    save: (top: number | null) => { savedScrollTop = top },
-    read: () => savedScrollTop,
+  let savedScroll: ReturnType<ChatViewSlotProps['chatScroll']['read']> = null
+  const chatScroll: ChatViewSlotProps['chatScroll'] = {
+    save: (position) => { savedScroll = position },
+    read: () => savedScroll,
   }
   const forkAt = vi.fn()
   // Selection rides the REAL chat store (same construction path as
@@ -154,6 +157,32 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
   return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
 }
 
+/** Simulate reader input before the browser delivers the host scroll event. */
+function readerScroll(element: HTMLElement, top: number): void {
+  fireEvent.wheel(element, { deltaY: top < element.scrollTop ? -120 : 120 })
+  element.scrollTop = top
+  fireEvent.scroll(element)
+}
+
+function installScrollMetrics(element: HTMLElement, initialHeight: number, clientHeight: number) {
+  let scrollHeight = initialHeight
+  let scrollTop = 0
+  Object.defineProperty(element, 'scrollHeight', { configurable: true, get: () => scrollHeight })
+  Object.defineProperty(element, 'clientHeight', { configurable: true, get: () => clientHeight })
+  Object.defineProperty(element, 'scrollTop', {
+    configurable: true,
+    get: () => scrollTop,
+    set: (value: number) => { scrollTop = Math.max(0, Math.min(value, scrollHeight - clientHeight)) },
+  })
+  return {
+    setHeight: (value: number) => { scrollHeight = value },
+    setLayout: (height: number, top: number) => {
+      scrollHeight = height
+      scrollTop = Math.max(0, Math.min(top, scrollHeight - clientHeight))
+    },
+  }
+}
+
 describe('chat-flow derivation', () => {
   it('groups consecutive tool results and keeps stable keys', () => {
     const nodes: ConversationNode[] = [
@@ -247,20 +276,36 @@ describe('ChatView', () => {
     expect(view.getByText('w1')).toBeTruthy()
   })
 
-  it('prepend keeps the viewport anchored when the reader is NOT at the bottom (no lastKey force)', () => {
-    // Covers the prepend early-return arm where lastItem exists but the key
-    // path is not taken (anchor branch wins before the appended-user check).
-    const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true })
+  it('prepend keeps the reader\'s latest pending-request scroll position anchored', () => {
+    const h = makeHarness({ nodes: [user(9, 'first visible'), user(10, 'next visible')], hasMore: true })
     const view = render(<h.ChatView {...h.props} />)
     const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    const first = view.container.querySelector('[data-chat-flow-key="n9"]') as HTMLDivElement
+    const next = view.container.querySelector('[data-chat-flow-key="n10"]') as HTMLDivElement
+    let firstTop = 100
+    let nextTop = 300
+    vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(
+      () => ({ top: 0, bottom: 200 } as DOMRect),
+    )
+    vi.spyOn(first, 'getBoundingClientRect').mockImplementation(
+      () => ({ top: firstTop, bottom: firstTop + 40 } as DOMRect),
+    )
+    vi.spyOn(next, 'getBoundingClientRect').mockImplementation(
+      () => ({ top: nextTop, bottom: nextTop + 40 } as DOMRect),
+    )
     Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
     Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
-    scroller.scrollTop = 50
-    fireEvent.scroll(scroller)
+    readerScroll(scroller, 50)
     fireEvent.click(view.getByText('加载更早'))
+    // The reader moves after the request starts; this, not the click-time
+    // row, is the intent the arriving page must preserve.
+    firstTop = -200
+    nextTop = 60
+    readerScroll(scroller, 90)
     Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
-    act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
-    expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800)
+    nextTop = 560
+    act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] }) })
+    expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift
   })
 
   it('renders the fixture main line: bubble, narration, grouped tool rows', () => {
@@ -272,6 +317,18 @@ describe('ChatView', () => {
     expect(view.getByText('running tools')).toBeTruthy()
     expect(view.getAllByText('Bash')).toHaveLength(2)
     expect(view.getByText('run a')).toBeTruthy()
+    expect([...view.container.querySelectorAll('[data-chat-flow-key]')].map(row => ({
+      key: row.getAttribute('data-chat-flow-key'),
+      kind: row.getAttribute('data-chat-flow-kind'),
+    }))).toEqual([
+      { key: 'n1', kind: 'user' },
+      { key: 'n2', kind: 'assistant' },
+      { key: 'g3', kind: 'tool-group' },
+    ])
+    expect([...view.container.querySelectorAll('[data-chat-call-id]')].map(row => row.getAttribute('data-chat-call-id')))
+      .toEqual(['a', 'b'])
+    expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key')))
+      .toEqual(['node:1', 'node:2', 'call:a', 'call:b'])
   })
 
   it('renders Host-pending steering at the flow tail and hands off to the durable node', () => {
@@ -622,31 +679,106 @@ describe('ChatView', () => {
     expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }])
   })
 
-  it('prepend compensates scrollTop by the height delta; a trailing user node force-scrolls', () => {
+  it('prepend preserves a semantic row; a trailing user node force-scrolls', () => {
     const h = makeHarness({ nodes: [user(5, 'later'), assistant(6, 'a')], hasMore: true })
     const view = render(<h.ChatView {...h.props} />)
     const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
     // jsdom has no layout: fake the metrics the anchor math reads.
     Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
     Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true })
+    const anchored = view.container.querySelector('[data-chat-flow-key="n5"]') as HTMLDivElement
+    let anchoredTop = 100
+    vi.spyOn(anchored, 'getBoundingClientRect').mockImplementation(
+      () => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect),
+    )
+    readerScroll(scroller, 80)
     // Arm the paging anchor, then deliver an older page (head seq decreases).
     fireEvent.click(view.getByText('加载更早'))
     Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
+    anchoredTop = 700
     act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) })
-    expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000)
+    expect(scroller.scrollTop).toBe(680) // reader offset 80 + the anchored row's 600px shift
     // A new trailing user bubble (own words) force-scrolls to the bottom.
     act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) })
     expect(scroller.scrollTop).toBe(1600)
   })
 
+  it('uses stable call identity when a prepend changes the tool-group key amid unrelated growth', () => {
+    const h = makeHarness({ nodes: [toolResult(5, 'late')], hasMore: true })
+    const view = render(<h.ChatView {...h.props} />)
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    let prepended = false
+    const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+      if (this.dataset.chatAnchorKey === 'call:late') {
+        const top = prepended ? 400 : 100
+        return { top, bottom: top + 40 } as DOMRect
+      }
+      return { top: 0, bottom: 200 } as DOMRect
+    })
+    try {
+      Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
+      Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
+      readerScroll(scroller, 80)
+      fireEvent.click(view.getByText('加载更早'))
+      // Total height grows by 500, but only 300 belongs before the call row.
+      Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
+      prepended = true
+      act(() => { h.set({ nodes: [toolResult(4, 'early'), toolResult(5, 'late')] }) })
+      expect(scroller.scrollTop).toBe(380)
+    } finally {
+      rect.mockRestore()
+    }
+  })
+
+  it('uses the latest retry identity when prepending an earlier retry changes the flow key', () => {
+    const h = makeHarness({ nodes: [retry(5)], hasMore: true })
+    const view = render(<h.ChatView {...h.props} />)
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    let prepended = false
+    const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+      if (this.dataset.chatAnchorKey === 'node:5') {
+        const top = prepended ? 400 : 100
+        return { top, bottom: top + 40 } as DOMRect
+      }
+      return { top: 0, bottom: 200 } as DOMRect
+    })
+    try {
+      Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
+      Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
+      readerScroll(scroller, 80)
+      fireEvent.click(view.getByText('加载更早'))
+      Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
+      prepended = true
+      act(() => { h.set({ nodes: [retry(4), retry(5)] }) })
+      expect(scroller.scrollTop).toBe(380)
+      expect(view.container.querySelector('[data-chat-flow-key="n4"][data-chat-anchor-key="node:5"]')).not.toBeNull()
+    } finally {
+      rect.mockRestore()
+    }
+  })
+
+  it('back-to-bottom cancels an in-flight paging anchor', () => {
+    const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true })
+    const view = render(<h.ChatView {...h.props} />)
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
+    Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
+    readerScroll(scroller, 50)
+    fireEvent.click(view.getByText('加载更早'))
+    fireEvent.click(view.getByLabelText('回到底部'))
+    Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
+    act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
+    expect(scroller.scrollTop).toBe(1_300)
+    expect(h.chatScroll.read()).toBeNull()
+  })
+
   it('scrolling away disables follow and shows the back-to-bottom button; clicking returns', () => {
     const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
     const view = render(<h.ChatView {...h.props} />)
     const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
     Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
     Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
-    scroller.scrollTop = 100 // far from bottom
-    fireEvent.scroll(scroller)
+    readerScroll(scroller, 100) // far from bottom
     const backButton = view.getByLabelText('回到底部')
     expect(backButton).toBeTruthy()
     // Streaming growth must NOT drag a scrolled-away reader down.
@@ -658,6 +790,71 @@ describe('ChatView', () => {
     expect(view.queryByLabelText('回到底部')).toBeNull()
   })
 
+  it('keeps following when a delayed clamp scroll arrives after layout regrows', () => {
+    const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+    const view = render(<h.ChatView {...h.props} />)
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    const metrics = installScrollMetrics(scroller, 1_000, 300)
+    scroller.scrollTop = 700
+    fireEvent.scroll(scroller)
+
+    // The wheel cannot move farther down. A stream-finalization shrink clamps
+    // the old position, then reflow grows the layout before scroll delivery.
+    fireEvent.wheel(scroller, { deltaY: 120 })
+    metrics.setLayout(1_040, 500)
+    fireEvent.scroll(scroller)
+    expect(scroller.scrollTop).toBe(740)
+    expect(view.queryByLabelText('回到底部')).toBeNull()
+    expect(h.chatScroll.read()).toBeNull()
+
+    metrics.setHeight(1_200)
+    act(() => { h.set({ running: true }) })
+    expect(scroller.scrollTop).toBe(900)
+  })
+
+  it('uses the last delivered top when compositor scrolling precedes passive wheel delivery', () => {
+    const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+    const view = render(<h.ChatView {...h.props} />)
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    installScrollMetrics(scroller, 1_000, 300)
+    scroller.scrollTop = 700
+    fireEvent.scroll(scroller)
+
+    scroller.scrollTop = 500
+    fireEvent.wheel(scroller, { deltaY: -200 })
+    fireEvent.scroll(scroller)
+    expect(view.getByLabelText('回到底部')).toBeTruthy()
+  })
+
+  it('one ResizeObserver owns pinned dynamic-height follow and ignores growth while away', () => {
+    let notify: (() => void) | undefined
+    const observe = vi.fn()
+    class ResizeObserverStub {
+      constructor(callback: ResizeObserverCallback) {
+        notify = () => { callback([], this as unknown as ResizeObserver) }
+      }
+
+      observe = observe
+      disconnect = vi.fn()
+    }
+    vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+    const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+    const view = render(<h.ChatView {...h.props} />)
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
+    Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
+    scroller.scrollTop = 700
+    fireEvent.scroll(scroller)
+    Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
+    act(() => { notify?.() })
+    expect(scroller.scrollTop).toBe(1_200)
+    readerScroll(scroller, 200)
+    Object.defineProperty(scroller, 'scrollHeight', { value: 1_400, writable: true })
+    act(() => { notify?.() })
+    expect(scroller.scrollTop).toBe(200)
+    expect(observe).toHaveBeenCalledTimes(1)
+  })
+
   it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
     const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
     const view = render(<h.ChatView {...h.props} />)
@@ -666,8 +863,7 @@ describe('ChatView', () => {
     Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
     // Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome
     // re-render from setAtBottom must not force scrollTop to scrollHeight.
-    scroller.scrollTop = 690 // distance-to-bottom = 10
-    fireEvent.scroll(scroller)
+    readerScroll(scroller, 690) // distance-to-bottom = 10
     expect(view.queryByLabelText('回到底部')).toBeNull()
     expect(scroller.scrollTop).toBe(690)
   })
@@ -684,8 +880,7 @@ describe('ChatView', () => {
       const view = render(<h.ChatView {...h.props} />, { container: host })
       // Open jump uses the host, not the local .scroll node.
       expect(host.scrollTop).toBe(2000)
-      host.scrollTop = 100
-      fireEvent.scroll(host)
+      readerScroll(host, 100)
       expect(view.getByLabelText('回到底部')).toBeTruthy()
       fireEvent.click(view.getByLabelText('回到底部'))
       expect(host.scrollTop).toBe(2000)
@@ -694,29 +889,72 @@ describe('ChatView', () => {
     }
   })
 
-  it('a remount restores the saved scroll position instead of re-jumping to the bottom', () => {
+  it('a remount restores the saved semantic row after width reflow', () => {
     const host = document.createElement('div')
     host.setAttribute('data-conversation-scroll', '')
     Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
     Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
     Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
     document.body.appendChild(host)
+    let anchorTop = 80
+    vi.spyOn(host, 'getBoundingClientRect').mockImplementation(
+      () => ({ top: 0, bottom: 500 } as DOMRect),
+    )
+    const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+      if (this.dataset.chatAnchorKey === 'node:1') {
+        return { top: anchorTop, bottom: anchorTop + 40 } as DOMRect
+      }
+      return { top: 0, bottom: 40 } as DOMRect
+    })
     try {
       const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
       // Fresh open (nothing saved): the bottom jump stands.
       const view = render(<h.ChatView {...h.props} />, { container: host })
       expect(host.scrollTop).toBe(2000)
       // Reader scrolls up; the position is recorded continuously.
-      host.scrollTop = 100
-      fireEvent.scroll(host)
+      readerScroll(host, 100)
       // View-tab switch away and back: the view unmounts, then remounts.
       view.rerender(<div />)
+      anchorTop = 560
       host.scrollTop = 0
       view.rerender(<h.ChatView {...h.props} />)
-      expect(host.scrollTop).toBe(100)
+      expect(host.scrollTop).toBe(580) // approximate 100 + the row's 480px reflow shift
       // The restored position is above the floor: follow stays disarmed.
       expect(view.getByLabelText('回到底部')).toBeTruthy()
     } finally {
+      rect.mockRestore()
+      host.remove()
+    }
+  })
+
+  it('normalizes a semantic restore clamped to the bottom before an immediate remount', () => {
+    const host = document.createElement('div')
+    host.setAttribute('data-conversation-scroll', '')
+    Object.defineProperty(host, 'scrollHeight', { value: 2_000, writable: true, configurable: true })
+    Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
+    let scrollTop = 0
+    Object.defineProperty(host, 'scrollTop', {
+      configurable: true,
+      get: () => scrollTop,
+      set: (value: number) => { scrollTop = Math.min(value, 1_500) },
+    })
+    document.body.appendChild(host)
+    const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+      if (this.dataset.chatAnchorKey === 'node:1') return { top: 300, bottom: 340 } as DOMRect
+      return { top: 0, bottom: 500 } as DOMRect
+    })
+    try {
+      const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+      h.chatScroll.save({ anchorKey: 'node:1', anchorTop: 80, scrollTop: 1_400 })
+      const view = render(<h.ChatView {...h.props} />, { container: host })
+      expect(host.scrollTop).toBe(1_500)
+      expect(h.chatScroll.read()).toBeNull()
+      view.rerender(<div />)
+      host.scrollTop = 0
+      view.rerender(<h.ChatView {...h.props} />)
+      expect(host.scrollTop).toBe(1_500)
+    } finally {
+      rect.mockRestore()
       host.remove()
     }
   })

+ 5 - 0
tsconfig.host.json

@@ -44,6 +44,11 @@
     "apps/web/tests/startup-auto-selection.e2e.ts",
     "apps/web/tests/subagent-conversation.e2e.ts",
     "apps/web/tests/bash-abort-row.e2e.ts",
+    "apps/web/tests/chat-scroll-fixture.ts",
+    "apps/web/tests/chat-scroll-contract.e2e.ts",
+    "apps/web/tests/chat-long-interactions.e2e.ts",
+    "apps/web/tests/chat-continuous-conversation.e2e.ts",
+    "apps/web/tests/complex-history.perf.ts",
     "apps/cli/tests/**/*.ts",
     "examples/*/src/**/*.ts",
     "examples/*/start.ts",

+ 15 - 0
vitest.web.perf.config.ts

@@ -0,0 +1,15 @@
+import { defineConfig } from 'vitest/config'
+import webConfig from './vitest.web.config.ts'
+
+// Manual high-cardinality diagnostics stay outside vitest.web.config.ts's
+// .e2e.ts/.snapshot.ts inventory and therefore outside the CI web gate.
+export default defineConfig({
+  ...webConfig,
+  test: {
+    ...webConfig.test,
+    include: ['apps/web/tests/**/*.perf.ts'],
+    disableConsoleIntercept: true,
+    hookTimeout: 180_000,
+    testTimeout: 600_000,
+  },
+})

Деякі файли не було показано, через те що забагато файлів було змінено