Przeglądaj źródła

test(web): expand long-chat regression and performance coverage

kingwl 1 miesiąc temu
rodzic
commit
bf6cb38fbc

+ 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: e9559aa1f6fba89a99d91e79026552511b337793
-2026-07-24-web-gui-browser-e2e-lane.zh.md: 5f2047125e38f6e1eecf4b8b81d6f5da16686afa
+2026-07-24-web-gui-browser-e2e-lane.md: f8519a9622d2f7216226a695db95dbebdbf24ea1
+2026-07-24-web-gui-browser-e2e-lane.zh.md: 294f3e840e0242d9a0d9c53ac510d44d3b0d100f

Plik diff jest za duży
+ 1 - 2
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md


Plik diff jest za duży
+ 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, '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-global"]')
+      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)
+})

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

@@ -0,0 +1,268 @@
+// 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 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-global"]')
+    const summary2 = call2.locator('[data-sample="bash-global"]')
+    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)}"]`)
+    expect(await userRow.textContent()).toContain(branchUserMarker)
+    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 userRow.hover()
+    await userRow.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)
+})

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

@@ -0,0 +1,667 @@
+// 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}`)
+}
+
+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-global"]`)
+        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-global"]`
+      const liveRow = world.page.locator(liveRowSelector)
+      await liveRow.scrollIntoViewIfNeeded()
+      await nextPaint(world.page)
+      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. */
+export 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 }
+}

+ 260 - 3
apps/web/tests/complex-history.perf.ts

@@ -50,6 +50,7 @@ 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
@@ -107,6 +108,15 @@ interface MutationProbeResult {
   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
@@ -608,6 +618,168 @@ async function stopMutationProbe(page: Page): Promise<MutationProbeResult> {
   })
 }
 
+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,
@@ -887,6 +1059,77 @@ async function continueConversation(
   }
 }
 
+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)
 }
@@ -1142,11 +1385,12 @@ describe('manual web performance: complex workspace and history', () => {
     }
   })
 
-  it('reports one hundred continuously generated turns with retained checkpoints', async () => {
+  it('reports one hundred generated turns and the next user-message paint', async () => {
     const world = await launchPerformanceWorld({
       browser,
-      replay: performanceReplayOverride(SOAK_TURNS, soakTurn),
+      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 })
@@ -1160,6 +1404,7 @@ describe('manual web performance: complex workspace and history', () => {
         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',
@@ -1167,11 +1412,23 @@ describe('manual web performance: complex workspace and history', () => {
         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 {
-      await closePerformanceWorld(world)
+      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',

+ 4 - 0
apps/web/tsconfig.json

@@ -57,6 +57,10 @@
     "tests/startup-auto-selection.e2e.ts",
     "tests/subagent-conversation.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": [

+ 4 - 0
tsconfig.host.json

@@ -44,6 +44,10 @@
     "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",

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików