Преглед изворни кода

test(web): cover Sidebar interactions and refresh recorded UI expectations

imccyu пре 3 недеља
родитељ
комит
fe5aee7e71
84 измењених фајлова са 1767 додато и 152 уклоњено
  1. 213 21
      apps/web/tests/details-session-lifecycle.e2e.ts
  2. 2 0
      apps/web/tests/expected/agent-preset-selection/header.expected.md
  3. 2 1
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  4. 2 0
      apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
  5. 2 0
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  6. 2 0
      apps/web/tests/expected/goal-command-presentation/ui.expected.md
  7. 2 0
      apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
  8. 2 0
      apps/web/tests/expected/markdown-images/ui.expected.md
  9. 2 0
      apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
  10. 2 0
      apps/web/tests/expected/math-rendering/ui.expected.md
  11. 2 0
      apps/web/tests/expected/reference-composer/order.expected.md
  12. 2 0
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  13. 2 0
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  14. 2 0
      apps/web/tests/expected/stats-paged-history/ui.expected.md
  15. 2 0
      apps/web/tests/expected/steer-all/mid-steer.expected.md
  16. 2 0
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  17. 2 0
      apps/web/tests/expected/steer-all/settled.expected.md
  18. 16 27
      apps/web/tests/markdown-wide-table.e2e.ts
  19. 25 21
      apps/web/tests/navigation-panes.e2e.ts
  20. 7 20
      apps/web/tests/produced-files.e2e.ts
  21. 3 3
      apps/web/tests/ptc-round.e2e.ts
  22. 14 46
      apps/web/tests/seeded-history.e2e.ts
  23. 847 0
      apps/web/tests/sidebar-right.e2e.ts
  24. 2 0
      apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
  25. 2 0
      snapshots/web/bash-abort-row/ui.expected.md
  26. 2 0
      snapshots/web/cordis-tool-round/ui.expected.md
  27. 496 0
      snapshots/web/details-session-lifecycle/sidebar.expected.md
  28. 2 0
      snapshots/web/feedback-command/ack-expanded.expected.md
  29. 2 0
      snapshots/web/feedback-command/ack.expected.md
  30. 2 0
      snapshots/web/feedback-release/ack-expanded.expected.md
  31. 2 0
      snapshots/web/feedback-release/ack.expected.md
  32. 2 0
      snapshots/web/file-upload-round/ui.expected.md
  33. 2 0
      snapshots/web/fresh-round-trip/ui-expanded.expected.md
  34. 2 0
      snapshots/web/fresh-round-trip/ui.expected.md
  35. 2 0
      snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md
  36. 2 0
      snapshots/web/goal-multi-turn-actions/ui.expected.md
  37. 0 3
      snapshots/web/lifecycle-chrome/hero.expected.md
  38. 0 3
      snapshots/web/lifecycle-chrome/plan-active.expected.md
  39. 2 0
      snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md
  40. 2 0
      snapshots/web/lifecycle-chrome/reloaded.expected.md
  41. 2 0
      snapshots/web/live-interactions/cancel-expanded.expected.md
  42. 2 0
      snapshots/web/live-interactions/cancel.expected.md
  43. 2 0
      snapshots/web/live-interactions/error-auth.expected.md
  44. 2 0
      snapshots/web/live-interactions/loading.expected.md
  45. 2 0
      snapshots/web/live-interactions/retry-exhausted.expected.md
  46. 2 0
      snapshots/web/live-interactions/retry-expanded.expected.md
  47. 2 0
      snapshots/web/live-interactions/retry.expected.md
  48. 2 0
      snapshots/web/live-interactions/running-draft.expected.md
  49. 2 0
      snapshots/web/message-actions/ui.expected.md
  50. 2 0
      snapshots/web/minimal-preset/ui.expected.md
  51. 2 0
      snapshots/web/plan-review/approved-expanded.expected.md
  52. 2 0
      snapshots/web/plan-review/approved.expected.md
  53. 2 0
      snapshots/web/ptc-round/ui.expected.md
  54. 2 0
      snapshots/web/question-composer/answered-expanded.expected.md
  55. 2 0
      snapshots/web/question-composer/answered.expected.md
  56. 2 0
      snapshots/web/question-composer/cancelled.expected.md
  57. 2 0
      snapshots/web/queue-actions/collapsed.expected.md
  58. 2 0
      snapshots/web/queue-actions/editing.expected.md
  59. 2 0
      snapshots/web/queue-actions/layout.expected.md
  60. 2 0
      snapshots/web/queue-actions/preserved-expanded.expected.md
  61. 2 0
      snapshots/web/queue-actions/preserved.expected.md
  62. 2 0
      snapshots/web/queue-actions/sending.expected.md
  63. 2 0
      snapshots/web/queue-actions/ui.expected.md
  64. 2 0
      snapshots/web/queued-image/delivered.expected.md
  65. 2 0
      snapshots/web/queued-image/queued.expected.md
  66. 2 0
      snapshots/web/seeded-history/command-row.expected.md
  67. 2 0
      snapshots/web/seeded-history/feedback-row.expected.md
  68. 0 7
      snapshots/web/seeded-history/file-open-failure.expected.md
  69. 2 0
      snapshots/web/seeded-history/ui-expanded.expected.md
  70. 2 0
      snapshots/web/seeded-history/ui.expected.md
  71. 2 0
      snapshots/web/skill-tool-row/ui.expected.md
  72. 2 0
      snapshots/web/steering/mid-steer.expected.md
  73. 2 0
      snapshots/web/steering/settled-expanded.expected.md
  74. 2 0
      snapshots/web/steering/settled.expected.md
  75. 2 0
      snapshots/web/subagent-conversation/nested.expected.md
  76. 2 0
      snapshots/web/subagent-conversation/ui-expanded.expected.md
  77. 2 0
      snapshots/web/subagent-conversation/ui.expected.md
  78. 2 0
      snapshots/web/subagent-interrupt/offline-composer.expected.md
  79. 2 0
      snapshots/web/turn-tail-actions/completed.expected.md
  80. 2 0
      snapshots/web/turn-tail-actions/focused.expected.md
  81. 2 0
      snapshots/web/turn-tail-actions/running.expected.md
  82. 2 0
      snapshots/web/turn-tail-actions/settled.expected.md
  83. 2 0
      snapshots/web/turn-tail-actions/usage-expanded.expected.md
  84. 2 0
      snapshots/web/web-search-round/ui.expected.md

+ 213 - 21
apps/web/tests/details-session-lifecycle.e2e.ts

@@ -1,10 +1,8 @@
-// Keyless browser regression for the details column's default visibility and Session ownership.
-// The shipped composition starts closed after selection and reload, retains an explicitly opened width through
-// unselected states, and closes it only when a different Session takes ownership.
-import { readFile } from 'node:fs/promises'
+// Recorded-session Sidebar geometry and per-Session view state through the shipped browser composition.
+import { mkdir, readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
-import type { Browser, Page } from 'playwright'
+import type { Browser, Locator, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import {
@@ -16,6 +14,8 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/details-session-lifecycle', import.meta.url))
 const HANDLES_EXPECTED = join(SNAPSHOT_DIR, 'handles.expected.md')
+const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
+const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0907-sidebar-rules', import.meta.url))
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v2.jsonl', import.meta.url))
 const SEED_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url))
 const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
@@ -64,6 +64,63 @@ async function handleSnapshot(page: Page): Promise<string> {
   ].join('\n').trimEnd()
 }
 
+/** Rendered frame tracks, rounded only to remove browser subpixel allocation. */
+async function columns(page: Page): Promise<number[]> {
+  return await appFrame(page).evaluate(element =>
+    getComputedStyle(element).gridTemplateColumns.split(' ').map(value => Math.round(Number.parseFloat(value))))
+}
+
+/** Tab order and selection inside each docked pane, independent of generated ids. */
+async function paneSnapshot(page: Page) {
+  return await page.locator('[data-rightbar-col] [data-dockkit-pane]').evaluateAll(panes => panes.map(pane => ({
+    active: pane.hasAttribute('data-dockkit-pane-active'),
+    tabs: [...pane.querySelectorAll('[data-dockkit-tab]')].map(tab => ({
+      title: tab.querySelector('[data-dockkit-tab-title]')?.textContent?.trim(),
+      selected: tab.getAttribute('aria-selected') === 'true',
+    })),
+  })))
+}
+
+/** Product-visible geometry, pane state, and expanded Files directories at a settled checkpoint. */
+async function sidebarSnapshot(page: Page) {
+  const geometry = await appFrame(page).evaluate((frame) => {
+    const panel = frame.querySelector<HTMLElement>('[data-sidebar-right-panel]')
+    if (panel === null) throw new Error('Sidebar panel is not mounted')
+    const expanded = panel.hasAttribute('data-sidebar-right-open')
+    const rect = panel.getBoundingClientRect()
+    const style = getComputedStyle(panel)
+    const handle = frame.querySelector('[data-side="rightbar"]')
+    return {
+      viewport: [window.innerWidth, window.innerHeight],
+      columns: getComputedStyle(frame).gridTemplateColumns.split(' ').map(value => Math.round(Number.parseFloat(value))),
+      expanded,
+      mode: panel.getAttribute('data-sidebar-right-panel'),
+      panelContentWidth: expanded ? Math.round(Number.parseFloat(style.width)) : 0,
+      panelOuterWidth: expanded ? Math.round(rect.width) : 0,
+      coversViewport: expanded && rect.x === 0 && rect.y === 0
+        && Math.round(rect.width) === window.innerWidth && Math.round(rect.height) === window.innerHeight,
+      resizeHandleWidth: handle === null ? 0 : Math.round(handle.getBoundingClientRect().width),
+      expandedDirectories: [...panel.querySelectorAll('[data-files-entry="directory"] > button[aria-expanded="true"]')]
+        .map(button => button.textContent?.trim()),
+    }
+  })
+  return { ...geometry, panes: await paneSnapshot(page) }
+}
+
+/** The native pointer gesture used for the left column's width preference. */
+async function dragSidebar(page: Page, target: number): Promise<void> {
+  const grip = await page.locator('[data-side="sidebar"]').boundingBox()
+  if (grip === null) throw new Error('Sidebar resize handle is not rendered')
+  await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2)
+  await page.mouse.down()
+  try {
+    await page.mouse.move(target, grip.y + grip.height / 2, { steps: 6 })
+  } finally {
+    await page.mouse.up()
+  }
+  await expect.poll(() => sidebarTrack(page)).toBe(target)
+}
+
 describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => {
   let scaffold: WebScaffold
   let browser: Browser
@@ -84,12 +141,18 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
   }, 120_000)
 
   afterAll(async () => {
-    await browser?.close()
-    await scaffold?.close()
+    try {
+      await browser?.close()
+    } finally {
+      await scaffold?.close()
+    }
   })
 
-  it('starts and reloads closed, then stays closed across Session ownership changes', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-details-session-lifecycle'))
+  it('retains each Session sidebar and applies normal, fullscreen, and capacity-close geometry', async () => {
+    onTestFailed(async () => {
+      await mkdir(SHOT_DIR, { recursive: true })
+      await saveFailureShot(page, `screenshots/0907-sidebar-rules/details-session-lifecycle-${MODE}-${process.pid}`)
+    })
     const settled = scaffold.whenTurnSettled()
     const input = page.locator('[data-composer-input]').first()
     await input.fill(PROMPT)
@@ -125,7 +188,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
     expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
 
-    const original = page.locator('[role=treeitem]').filter({ hasText: 'Reply with the single word' }).first()
+    const original = page.locator('[role="treeitem"][aria-selected]').filter({ hasText: 'Reply with the single word' }).first()
     await original.click()
     await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
     await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
@@ -133,20 +196,149 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
 
     const ungrouped = page.getByText('Ungrouped', { exact: true })
     const ungroupedRow = ungrouped.locator('..').locator('..')
-    const ungroupedSection = ungroupedRow.locator('..')
-    await expect.poll(async () => {
-      if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
-        await ungrouped.click()
-        await page.waitForTimeout(50)
-      }
-      return await ungroupedRow.getAttribute('aria-expanded')
-    }, { timeout: 5_000 }).toBe('true')
-    const seeded = ungroupedSection.locator('[role="treeitem"]').nth(1)
+    if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') await ungrouped.click()
+    await expect.poll(() => ungroupedRow.getAttribute('aria-expanded')).toBe('true')
+    // A cold Session's row may still show its cwd until its history loads.
+    const seeded = ungroupedRow.locator('..').locator('[role="treeitem"][aria-selected]')
+    await expect.poll(() => seeded.count()).toBe(1)
     await seeded.click()
     await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 })
     await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
+
+    const viewport = page.viewportSize()
+    if (viewport === null) throw new Error('expected a fixed viewport')
+    const column = page.locator('[data-rightbar-col]')
+    const panel = column.locator('[data-sidebar-right-panel]')
+    const panes = column.locator('[data-dockkit-pane]')
+    const normalWidth = Math.round(viewport.width * 0.45)
+    const normalColumns = [280, viewport.width - 280 - normalWidth, normalWidth]
+    const checkpoints: string[] = ['# Recorded-session Sidebar states']
+    const checkpoint = async (label: string): Promise<void> => {
+      checkpoints.push(`## ${label}\n\n\`\`\`json\n${JSON.stringify(await sidebarSnapshot(page), null, 2)}\n\`\`\``)
+    }
+    const select = async (row: Locator, reply: string): Promise<void> => {
+      await row.click()
+      await expect.poll(() => row.getAttribute('aria-selected')).toBe('true')
+      await page.getByText(reply, { exact: true }).waitFor({ timeout: 15_000 })
+    }
+    const open = async (): Promise<void> => {
+      await page.locator('[data-sidebar-right-expand]').click()
+      await expect.poll(() => column.locator('[data-sidebar-right-open]').count()).toBe(1)
+      await expect.poll(() => columns(page)).toEqual(normalColumns)
+    }
+    const close = async (): Promise<void> => {
+      await column.locator('[data-sidebar-right-toggle]').click()
+      await expect.poll(() => column.locator('[data-sidebar-right-open]').count()).toBe(0)
+      await expect.poll(() => detailsTrack(page)).toBe(0)
+    }
+
+    await select(original, 'LIGHTHOUSE')
+    await open()
+    // The content-box panel adds its one rendered border pixel outside the
+    // CSS width assigned by the grid solver.
+    await expect.poll(() => sidebarSnapshot(page), { timeout: 5_000 })
+      .toMatchObject({ mode: 'push', panelContentWidth: normalWidth, panelOuterWidth: normalWidth + 1, resizeHandleWidth: 8 })
+    await column.locator('[data-sidebar-right-guide-entry="files"]').click()
+    await column.locator('[data-files-state="tree"]').waitFor({ timeout: 15_000 })
+    await column.locator('[data-dockkit-add-tab]').click()
+    const split = column.locator('[data-dockkit-split-button]').first()
+    await expect.poll(() => split.isDisabled()).toBe(false)
+    await split.click()
+    await expect.poll(() => panes.count()).toBe(2)
+    await panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click()
+    await expect.poll(() => panes.first().locator('[data-files-state="tree"]').count()).toBe(1)
+    const retainedA = await paneSnapshot(page)
+    expect(retainedA.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files', 'Start'], ['Start']])
+    await checkpoint('A normal: two panes')
+
+    await column.locator('[data-sidebar-right-mode="fullscreen"]').click()
+    await expect.poll(() => panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport })
+    expect(await columns(page)).toEqual(normalColumns)
+    expect(await sidebarSnapshot(page)).toMatchObject({ mode: 'fullscreen', resizeHandleWidth: 0, coversViewport: true })
+    expect(await paneSnapshot(page)).toEqual(retainedA)
+    await checkpoint('A manual fullscreen: underlying columns retained')
+
+    // Closing the fullscreen panel exposes Session navigation without changing
+    // its manual mode; reopening after the round trip must restore that mode.
+    await close()
+    await checkpoint('A closed with manual fullscreen retained')
+    await select(seeded, 'DONE')
+    await expect.poll(() => detailsTrack(page)).toBe(0)
+    await open()
+    expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push')
+    await column.locator('[data-sidebar-right-guide-entry="files"]').click()
+    const workspaceDirectory = column.locator('[data-files-entry="directory"] > button').filter({ hasText: /^workspace$/ })
+    await workspaceDirectory.waitFor({ timeout: 15_000 })
+    await workspaceDirectory.click()
+    await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('true')
+    await expect.poll(() => column.locator('[data-files-row="loading"]').count()).toBe(0)
+    expect(await column.locator('[data-files-row="failed"]').count()).toBe(0)
+    const retainedB = await paneSnapshot(page)
+    expect(retainedB.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files']])
+    await close()
+    await checkpoint('B closed: independent pane and expanded workspace directory')
+
+    await select(original, 'LIGHTHOUSE')
+    await expect.poll(() => detailsTrack(page)).toBe(0)
+    expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen')
+    expect(await paneSnapshot(page)).toEqual(retainedA)
+    await open()
+    await expect.poll(() => panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport })
+    expect(await paneSnapshot(page)).toEqual(retainedA)
+    await checkpoint('A restored: manual fullscreen, tabs, and panes')
+    await column.locator('[data-sidebar-right-mode="push"]').click()
+    await expect.poll(async () => (await sidebarSnapshot(page)).panelContentWidth, { timeout: 5_000 }).toBe(normalWidth)
+
+    await select(seeded, 'DONE')
+    await expect.poll(() => detailsTrack(page)).toBe(0)
+    expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push')
+    expect(await paneSnapshot(page)).toEqual(retainedB)
+    await open()
+    await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('true')
+    expect(await paneSnapshot(page)).toEqual(retainedB)
+    await checkpoint('B restored: normal mode and Files directory state')
+    await close()
+    await select(original, 'LIGHTHOUSE')
+    await expect.poll(() => columns(page)).toEqual(normalColumns)
+    expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
+    expect(await paneSnapshot(page)).toEqual(retainedA)
+    await checkpoint('A restored: expanded normal panel')
+
+    try {
+      await page.setViewportSize({ width: 1024, height: viewport.height })
+      await expect.poll(() => columns(page)).toEqual([280, 400, 344])
+      await dragSidebar(page, 420)
+      await expect.poll(() => columns(page)).toEqual([420, 604, 0])
+      expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
+      expect(await paneSnapshot(page)).toEqual(retainedA)
+      await checkpoint('A capacity-closed: wide left preference protected')
+      await page.setViewportSize(viewport)
+      await expect.poll(() => columns(page)).toEqual([420, viewport.width - 420, 0])
+      expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
+      await checkpoint('A widened: remains closed')
+
+      await page.locator('[data-sidebar-right-expand]').click()
+      await expect.poll(() => columns(page)).toEqual([420, viewport.width - 420 - normalWidth, normalWidth])
+      await page.setViewportSize({ width: 767, height: viewport.height })
+      await expect.poll(() => panel.boundingBox()).toEqual({ x: 0, y: 0, width: 767, height: viewport.height })
+      await expect.poll(() => columns(page)).toEqual([56, 711, 0])
+      expect(await sidebarSnapshot(page)).toMatchObject({ mode: 'fullscreen', resizeHandleWidth: 0, coversViewport: true })
+      await checkpoint('A automatic fullscreen at 767px')
+      await column.locator('[data-sidebar-right-mode="push"]').click()
+      await expect.poll(() => column.locator('[data-sidebar-right-open]').count()).toBe(0)
+      await page.setViewportSize(viewport)
+      await expect.poll(() => columns(page)).toEqual([420, viewport.width - 420, 0])
+      expect(await paneSnapshot(page)).toEqual(retainedA)
+      expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push')
+      expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
+      await checkpoint('A automatic fullscreen exited: widening does not reopen')
+    } finally {
+      await page.setViewportSize(viewport)
+    }
+
+    await compareOrRefreshGolden(SIDEBAR_EXPECTED, checkpoints.join('\n\n'), MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md'])
-  }, 90_000)
+    await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md', 'sidebar.expected.md'])
+  })
 })

+ 2 - 0
apps/web/tests/expected/agent-preset-selection/header.expected.md

@@ -9,3 +9,5 @@
 - button "Session log":
   - text: Session log
   - img
+- button "Open the sidebar":
+  - img

+ 2 - 1
apps/web/tests/expected/clickable-links-gallery/ui.expected.md

@@ -4,6 +4,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
@@ -176,7 +178,6 @@
 - button "Open site/app.js": app.js
 - button "Open src/tokens.css": tokens.css
 - text: + 1 file
-- button "Show in folder"
 - button "Copy":
   - img
 - button "Good response":

+ 2 - 0
apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md

@@ -17,6 +17,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
apps/web/tests/expected/github-ready-review/conversation.expected.md

@@ -17,6 +17,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
apps/web/tests/expected/goal-command-presentation/ui.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
apps/web/tests/expected/markdown-cjk-strong/ui.expected.md

@@ -4,6 +4,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
apps/web/tests/expected/markdown-images/ui.expected.md

@@ -4,6 +4,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
apps/web/tests/expected/markdown-inline-code-links/ui.expected.md

@@ -4,6 +4,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
apps/web/tests/expected/math-rendering/ui.expected.md

@@ -4,6 +4,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
apps/web/tests/expected/reference-composer/order.expected.md

@@ -4,6 +4,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
apps/web/tests/expected/skill-user-invoke/ui.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
apps/web/tests/expected/stats-paged-history/ui.expected.md

@@ -4,6 +4,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
apps/web/tests/expected/steer-all/mid-steer.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
apps/web/tests/expected/steer-all/settled-expanded.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
apps/web/tests/expected/steer-all/settled.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 16 - 27
apps/web/tests/markdown-wide-table.e2e.ts

@@ -197,20 +197,13 @@ interface TableStop {
 }
 
 /**
- * Close the details pane so the transcript spans the viewport. Open, it pins
- * the transcript to exactly the message column and every breakout relation
- * would go vacuous.
- * @param target - the page whose pane to close.
+ * Wait for the right column to sit at its rail. Expanded, it would pin the
+ * transcript to exactly the message column and every breakout relation would
+ * go vacuous; the frame's collapse marker is the settled signal.
+ * @param target - the page whose frame to read.
  */
-async function closeDetailsPane(target: Page): Promise<void> {
-  await target.getByRole('button', { name: 'Close details', exact: true }).waitFor({ timeout: 10_000 })
-  await target.evaluate(() => {
-    document.querySelector<HTMLElement>('button[aria-label="Close details"]')?.click()
-  })
-  // Closed details resolve to zero width but never unmount (ui-layout
-  // columns contract), so the settled signal is the frame's collapse marker,
-  // not the button's detachment.
-  await target.waitForSelector('[data-details-collapsed]', { timeout: 5_000 })
+async function awaitRightRail(target: Page): Promise<void> {
+  await target.waitForSelector('[data-rightbar-collapsed]', { timeout: 5_000 })
 }
 
 /**
@@ -265,21 +258,17 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro
     await sessionRow.waitFor({ timeout: 10_000 })
     await sessionRow.click()
     await page.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 })
-    // Collapse the sidebar and close the details pane for the whole sweep:
-    // classic-scrollbar platforms (Linux CI) lose ~15px of layout width,
-    // which shifts how much of a narrow viewport the panes leave the
+    // Collapse the sidebar and keep the right column at its rail for the whole
+    // sweep: classic-scrollbar platforms (Linux CI) lose ~15px of layout
+    // width, which shifts how much of a narrow viewport the panes leave the
     // transcript and lands the narrow stop's readings far from the macOS
-    // ones — and the details pane alone pins the transcript to exactly the
-    // message column, which would make every breakout relation vacuous.
-    // With both out of the equation the transcript follows the viewport
-    // identically on every platform, which is what keeps one committed
-    // golden true for all lanes.
+    // ones — and an expanded right column alone pins the transcript to
+    // exactly the message column, which would make every breakout relation
+    // vacuous. With both out of the equation the transcript follows the
+    // viewport identically on every platform, which is what keeps one
+    // committed golden true for all lanes.
     await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
-    // JS click: after the transcript scrolled to its tail, the pane's close
-    // button can sit under the sticky header where a pointer click is
-    // intercepted; the pane itself is scaffolding, not the behavior under
-    // test, so actionability adds nothing here.
-    await closeDetailsPane(page)
+    await awaitRightRail(page)
   }, 180_000)
 
   afterAll(async () => {
@@ -436,7 +425,7 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro
       await sessionRow.click()
       await hidpiPage.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 })
       await hidpiPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
-      await closeDetailsPane(hidpiPage)
+      await awaitRightRail(hidpiPage)
       // The pane collapses ease over the layout transition: compare only a
       // settled reading (two consecutive equal wide-wrapper widths).
       let readings: TableReading[] = []

+ 25 - 21
apps/web/tests/navigation-panes.e2e.ts

@@ -12,7 +12,7 @@ import { join } from 'node:path'
 import type { Browser, Page, Response } from 'playwright'
 import { chromium } from 'playwright'
 import { strFromU8, unzipSync } from 'fflate'
-import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest'
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest'
 import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
 import { SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
 import {
@@ -277,13 +277,17 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     const exportButton = page.getByRole('button', { name: 'Session log' })
     expect(await exportButton.isDisabled()).toBe(false)
     const header = exportButton.locator('xpath=ancestor::header[1]')
-    const [buttonBox, headerBox] = await Promise.all([
-      exportButton.boundingBox(), header.boundingBox(),
+    // The right Sidebar's expand button holds the header's corner; the export
+    // control sits immediately to its left.
+    const sidebarButton = page.getByRole('button', { name: 'Open the sidebar' })
+    const [buttonBox, sidebarBox, headerBox] = await Promise.all([
+      exportButton.boundingBox(), sidebarButton.boundingBox(), header.boundingBox(),
     ])
-    if (buttonBox === null || headerBox === null) {
+    if (buttonBox === null || sidebarBox === null || headerBox === null) {
       throw new Error('Session Header export geometry is unavailable')
     }
-    expect(headerBox.x + headerBox.width - (buttonBox.x + buttonBox.width)).toBeLessThanOrEqual(32)
+    expect(headerBox.x + headerBox.width - (sidebarBox.x + sidebarBox.width)).toBeLessThanOrEqual(32)
+    expect(sidebarBox.x - (buttonBox.x + buttonBox.width)).toBeLessThanOrEqual(32)
     const responsePromise = page.waitForResponse(response =>
       response.request().method() === 'HEAD'
       && new URL(response.url()).pathname === '/api/session.export', { timeout: 30_000 })
@@ -382,33 +386,33 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await expect.poll(() => page.locator('tr[data-timeline-focus]').count(), { timeout: 10_000 }).toBe(0)
   }, 60_000)
 
-  it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
+  it.skipIf(MODE === 'record')('bash rows leave the right column at its rail; a file link opens the Sidebar', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-rightbar'))
     await ensureSeedOpen(page)
     const bashRow = page.locator('[data-sample="bash"]').first()
     await expandOwningTurnProcess(page, bashRow)
     await bashRow.waitFor({ timeout: 15_000 })
     const frame = page.locator('[style*="grid-template-columns"]').first()
-    expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
+    expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
     // The row click is the card's expand toggle (unified tool-row
     // interaction); it must not drive layout geometry either way.
     await bashRow.click()
-    await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
-    // The card's own controls are outside the summary row and must not open
-    // details either — the expanded terminal card is read in place.
+    await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true')
+    // The card's own controls are outside the summary row and must not expand
+    // the right column either — the expanded terminal card is read in place.
     await page.locator('[data-sample="bash"] ~ div [data-terminal] [class*="_copyButton_"]').first().click()
-    await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
-    // Read summaries are host-open file links; they also must not open details.
+    await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true')
+    // Read summaries are file links: one click opens the file as a text-preview
+    // tab in the right Sidebar, which expands to show it beside the guide tab.
     const fileLink = page.locator('[data-variant="read"] button').first()
     await fileLink.waitFor({ timeout: 10_000 })
-    const openPath = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath')
-      .mockResolvedValue({ opened: true })
-    try {
-      await fileLink.click()
-      await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
-    } finally {
-      openPath.mockRestore()
-    }
+    await fileLink.click()
+    await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null)
+    const column = page.locator('[data-rightbar-col]')
+    await expect.poll(() => column.locator('[data-dockkit-tab-title]').count(), { timeout: 5_000 }).toBe(2)
+    // Put the column back so later cases start from the default frame.
+    await column.locator('[data-sidebar-right-toggle]').click()
+    await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true')
   }, 60_000)
 
   it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {

+ 7 - 20
apps/web/tests/produced-files.e2e.ts

@@ -1,12 +1,12 @@
 // Web e2e scenario: the single-line produced-files summary a finished turn
 // ends with. Cold-seeds ten writes (zero model calls), then verifies the real
-// assembled lane adapts from a coarse width budget and keeps a capability-gated folder handoff.
-// The folder request is intercepted so one real browser click can exercise
-// the full client carrier without launching a native application in CI.
+// assembled lane adapts from a coarse width budget and offers no folder
+// handoff: chips open in the right Sidebar's text preview, which has no
+// directory form, so the row shows nothing rather than a dead button.
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
 import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
 import type {} from '@deepseek-ai/dsh-session-title'
@@ -152,24 +152,11 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
     expect(await chips.nth(1).innerText()).toBe('index.html')
     expect(await chips.nth(4).innerText()).toBe('app.ts')
     await expect.poll(() => row.getByText('+ 5 files', { exact: true }).isVisible()).toBe(true)
-    const showFolder = page.getByRole('button', { name: 'Show in folder', exact: true })
-    expect(await showFolder.count()).toBe(1)
+    // Chips open in the right Sidebar's text preview, and a directory is not
+    // something that preview can show, so the row offers no folder action.
+    expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0)
     expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
 
-    const openPath = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath')
-      .mockResolvedValue({ opened: true })
-    try {
-      const [response] = await Promise.all([
-        page.waitForResponse(response => new URL(response.url()).pathname === '/api/session/openWorkspacePath'),
-        showFolder.click({ clickCount: 1 }),
-      ])
-      expect(response.status()).toBe(200)
-      expect(openPath).toHaveBeenCalledTimes(1)
-      expect(openPath.mock.calls[0]![0]).toMatchObject({ path: `${scaffold.workspaceCwd}/.` })
-    } finally {
-      openPath.mockRestore()
-    }
-
     const tops = await row.locator(':scope > *:visible').evaluateAll(elements =>
       elements.map(element => element.getBoundingClientRect().top))
     expect(new Set(tops.map(top => Math.round(top))).size).toBe(1)

+ 3 - 3
apps/web/tests/ptc-round.e2e.ts

@@ -103,7 +103,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
   }, 60_000)
 
   it.skipIf(MODE === 'record')('expands the nested bash terminal inline before and after reload', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-details'))
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-rightbar'))
     let liveTerminalAria: string | undefined
     for (const reloaded of [false, true]) {
       if (reloaded) {
@@ -114,7 +114,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
       }
       const nest = page.locator('[data-subcalls]').first()
       const frame = page.locator('[style*="grid-template-columns"]').first()
-      expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
+      expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
       await expandOwningTurnProcess(page, nest)
       const row = nest.locator('[data-sample="bash"]').first()
       await expect.poll(() => row.getAttribute('data-state')).toBe('ok')
@@ -126,7 +126,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
       await terminal.getByText('echo CODE_ROUND_OK', { exact: true }).waitFor()
       await terminal.getByText('CODE_ROUND_OK', { exact: true }).waitFor()
       await expect.poll(() => terminal.locator('[data-state]').getAttribute('data-state')).toBe('done')
-      await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
+      await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true')
       const aria = await terminal.ariaSnapshot()
       if (reloaded) expect(aria).toBe(liveTerminalAria)
       else liveTerminalAria = aria

+ 14 - 46
apps/web/tests/seeded-history.e2e.ts

@@ -14,7 +14,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
 import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
@@ -38,7 +38,6 @@ const UI_EXPANDED_EXPECTED = fileURLToPath(
 // Command-row goldens over the same conversation after direct host commands.
 const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/command-row.expected.md', import.meta.url))
 const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/feedback-row.expected.md', import.meta.url))
-const FILE_OPEN_FAILURE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/file-open-failure.expected.md', import.meta.url))
 const MODE = webSnapshotMode()
 const SEED_ID = 'seeded-history-web-e2e'
 
@@ -407,56 +406,25 @@ describe('web e2e: seeded history renders through cold resume', () => {
     await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
   })
 
-  it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log stay details-inert', async () => {
+  it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log open the right Sidebar', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-toolrow'))
-    // Interaction over cold-resumed history: read summaries are host-open
-    // file links (not expand-in-place / not details). Runs after the golden
-    // capture; still zero model calls.
+    // Interaction over cold-resumed history: read summaries are file links
+    // that open a text-preview tab in the right Sidebar (not expand-in-place).
+    // Runs after the golden capture; still zero model calls.
     const fileLink = page.locator('[data-variant="read"] button').first()
     await expandOwningTurnProcess(page, fileLink)
     await fileLink.waitFor({ timeout: 10_000 })
     const frame = page.locator('[style*="grid-template-columns"]').first()
-    expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
-    const openPath = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath')
-      .mockResolvedValue({ opened: true })
-    try {
-      await fileLink.click()
-      await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
-    } finally {
-      openPath.mockRestore()
-    }
+    expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
+    await fileLink.click()
+    await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null)
+    const column = page.locator('[data-rightbar-col]')
+    await expect.poll(() => column.locator('[data-dockkit-tab-title]').count(), { timeout: 5_000 }).toBe(2)
     // Path label survives from the recorded args (a.txt).
     await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
-  })
-
-  it.skipIf(MODE === 'record')('a Host open refusal keeps the reason and retries the same path', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-file-open-failure'))
-    const fileLink = page.locator('[data-variant="read"] button').first()
-    await fileLink.waitFor({ timeout: 10_000 })
-    const openPath = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath')
-      .mockRejectedValue(new Error('xdg-open is not available'))
-    try {
-      await fileLink.click()
-      const dialog = page.getByRole('dialog', { name: 'Couldn’t open file' })
-      await dialog.waitFor({ timeout: 5_000 })
-      const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
-      await compareOrRefreshGolden(FILE_OPEN_FAILURE_EXPECTED, snapshot, MODE)
-      await expect.poll(() => dialog.innerText(), { timeout: 5_000 })
-        .toContain('path open failed: xdg-open is not available')
-      await page.getByRole('button', { name: 'Retry' }).click()
-      await expect.poll(() => openPath.mock.calls.length, { timeout: 5_000 }).toBe(2)
-      expect(openPath.mock.calls[0]![0]).toEqual(openPath.mock.calls[1]![0])
-      await page.getByRole('button', { name: 'Cancel' }).click()
-      await expect.poll(() => page.getByRole('dialog', { name: 'Couldn’t open file' }).count(), {
-        timeout: 5_000,
-      }).toBe(0)
-    } finally {
-      // Shared page: a leftover mask blocks later cases even when this one fails.
-      if (await page.getByRole('dialog', { name: 'Couldn’t open file' }).count() > 0) {
-        await page.keyboard.press('Escape')
-      }
-      openPath.mockRestore()
-    }
+    // Put the column back so the later goldens see the default frame.
+    await column.locator('[data-sidebar-right-toggle]').click()
+    await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true')
   })
 
   it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => {
@@ -563,7 +531,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'command-row.expected.md', 'feedback-row.expected.md', 'file-open-failure.expected.md',
+      'command-row.expected.md', 'feedback-row.expected.md',
       'session.v2.jsonl', 'ui.expected.md', 'ui-expanded.expected.md',
     ])
   })

+ 847 - 0
apps/web/tests/sidebar-right.e2e.ts

@@ -0,0 +1,847 @@
+// Keyless assembled-browser coverage for the shipped right Sidebar: the official
+// roster row, the real plugin graph, and one Chromium. No overlay is applied —
+// this scenario proves the surface is in the product's own composition.
+//
+// The frame owns the right column as a track; the Sidebar anchors its panel to
+// the column's edge and slides it in and out. Which of the two presentations
+// draws the panel is a recorded, reversible choice, so this file asserts against
+// the frame's track as much as against the panel itself. The way back in while
+// collapsed is not in the column at all: it is one button in the conversation
+// header, and it leaves when the panel opens.
+//
+// Ordering is the product's own: the hero comes before any session, so the
+// empty right edge is asserted first and the session-bound cases follow in a
+// nested block that seeds one turn.
+//
+// Copy is asserted in English because this page advertises English, which is
+// itself the point: every string in this column now comes from the dictionary,
+// so an English page renders English. The Chinese draft the product ships is
+// asserted, and captured for review, on its own page at the end.
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Locator, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
+import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
+import {
+  connectFreshWorkspace, newEnglishPage, saveFailureShot, ZH_BROWSER_LOCALE,
+} from './support.ts'
+
+/** The produced file the seeded turn writes, and what the preview should show. */
+const SAMPLE_NAME = 'notes.txt'
+const SAMPLE_TEXT = 'produced by the seeded turn\nsecond line\n'
+
+/** Where this batch's accepted product forms are archived. */
+const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0907-sidebar-rules', import.meta.url))
+
+/** Archive one accepted product form; the batch receipt cites these by name. */
+async function shot(page: Page, name: string): Promise<void> {
+  mkdirSync(SHOT_DIR, { recursive: true })
+  await page.screenshot({ path: `${SHOT_DIR}/${name}.png`, fullPage: true })
+}
+
+/** Centre of a rendered element, in viewport coordinates. */
+async function centre(locator: Locator): Promise<{ x: number; y: number }> {
+  const box = await locator.boundingBox()
+  if (box === null) throw new Error('element is not rendered')
+  return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
+}
+
+/**
+ * Press a tab chip and release it over a point.
+ *
+ * The press stays left of the chip's nested close control. An optional preview
+ * verifies the browser recognized the drop target before the release.
+ */
+async function dragTo(page: Page, tab: Locator, to: { x: number; y: number }, preview?: Locator): Promise<void> {
+  const box = await tab.boundingBox()
+  if (box === null) throw new Error('tab is not rendered')
+  const from = { x: box.x + 6, y: box.y + box.height / 2 }
+  await page.mouse.move(from.x, from.y)
+  await page.mouse.down()
+  try {
+    await page.mouse.move(to.x, to.y, { steps: 8 })
+    await preview?.waitFor({ state: 'visible' })
+  } finally {
+    await page.mouse.up()
+  }
+}
+
+/** Press an element itself and release over a point (no tab-title indirection). */
+async function dragElement(page: Page, handle: Locator, to: { x: number; y: number }): Promise<void> {
+  const from = await centre(handle)
+  await page.mouse.move(from.x, from.y)
+  await page.mouse.down()
+  await page.mouse.move(to.x, to.y, { steps: 8 })
+  await page.mouse.up()
+}
+
+/** A point inside `locator`, offset by fractions of its own box. */
+async function pointIn(locator: Locator, fx: number, fy: number): Promise<{ x: number; y: number }> {
+  const box = await locator.boundingBox()
+  if (box === null) throw new Error('element is not rendered')
+  return { x: box.x + box.width * fx, y: box.y + box.height * fy }
+}
+
+/** The expand button in the conversation header, present only while collapsed. */
+function expandOf(page: Page): Locator {
+  return page.locator('[data-sidebar-right-expand]')
+}
+
+/**
+ * Make sure the panel is open.
+ *
+ * These cases share one page and run in order, so an earlier one may have left
+ * the panel collapsed; a case that needs tabs says so rather than inheriting
+ * whatever the previous one happened to leave. The way in while collapsed is the
+ * header's expand button, which lives in the conversation, not the column.
+ */
+async function ensureExpanded(page: Page, column: Locator): Promise<void> {
+  if (await column.locator('[data-sidebar-right-open]').count() > 0) return
+  await expandOf(page).click()
+  await column.locator('[data-sidebar-right-open]').waitFor({ timeout: 10_000 })
+}
+
+/** Reload the session's transient sidebar state before an independent gesture case. */
+async function resetSidebar(page: Page): Promise<Locator> {
+  await page.reload({ waitUntil: 'load' })
+  const column = page.locator('[data-rightbar-col]')
+  await expandOf(page).waitFor({ timeout: 15_000 })
+  await ensureExpanded(page, column)
+  await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
+  await width(column)
+  return column
+}
+
+/** Whether the element at the centre of `locator` is the locator's own element or a descendant. */
+async function hitsItself(locator: Locator): Promise<boolean> {
+  const point = await centre(locator)
+  return await locator.evaluate((node, at) => {
+    const hit = document.elementFromPoint(at.x, at.y)
+    return hit !== null && node.contains(hit)
+  }, point)
+}
+
+/**
+ * Float a docked tab the way a user does: drag its chip clear of the docked
+ * surface and release over the conversation.
+ */
+async function floatByDrag(page: Page, tab: Locator): Promise<void> {
+  const surface = page.locator('[data-dockkit-surface]').first()
+  const box = await surface.boundingBox()
+  if (box === null) throw new Error('surface is not rendered')
+  await dragTo(page, tab, { x: box.x - 240, y: box.y + box.height / 2 })
+}
+
+/**
+ * Drag the frame's rightbar handle until the panel is `target` px wide (the
+ * frame clamps to its own range). The handle sits on the panel's left edge, so
+ * widening is a drag to the left.
+ */
+async function setPanelWidth(page: Page, target: number): Promise<void> {
+  const panel = page.locator('[data-sidebar-right-panel]')
+  const handle = page.locator('[data-side="rightbar"]').first()
+  for (let attempt = 0; attempt < 3; attempt += 1) {
+    const box = await panel.boundingBox()
+    if (box === null) throw new Error('panel is not rendered')
+    const delta = target - box.width
+    if (Math.abs(delta) < 2) return
+    const grip = await centre(handle)
+    await page.mouse.move(grip.x, grip.y)
+    await page.mouse.down()
+    await page.mouse.move(grip.x - delta, grip.y, { steps: 8 })
+    await page.mouse.up()
+    await page.waitForTimeout(300)
+  }
+}
+
+/** Tab titles inside one container, in strip order. */
+async function tabTitles(root: Locator): Promise<string[]> {
+  return await root.locator('[data-dockkit-tab-title]').allInnerTexts()
+}
+
+/**
+ * A rendered width, read once the frame's track transition has settled.
+ *
+ * The frame eases its grid tracks, so a single sample taken right after a
+ * gesture reports a frame of the animation. Column arithmetic is only exact at
+ * rest, so this samples until three consecutive readings agree.
+ */
+async function width(locator: Locator): Promise<number> {
+  let last = Number.NaN
+  let steady = 0
+  for (let attempt = 0; attempt < 80; attempt += 1) {
+    // Layout width, not the visible box: a zero-width track is still an answer.
+    const now = Math.round(await locator.evaluate(node => node.getBoundingClientRect().width))
+    steady = now === last ? steady + 1 : 0
+    if (steady === 2) return now
+    last = now
+    await locator.page().waitForTimeout(50)
+  }
+  throw new Error(`width never settled (last ${last}px)`)
+}
+
+describe('web e2e: shipped right Sidebar', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold()
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+  }, 180_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('shows nothing on the right while no session keys a surface', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-hero'))
+    const frame = page.locator('[class*="frame"]').first()
+    const column = page.locator('[data-rightbar-col]')
+    await column.waitFor({ state: 'attached', timeout: 15_000 })
+
+    // With no session there is no surface: no panel in the column, no expand
+    // button in the header, and no track — the conversation reaches the frame's edge.
+    expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
+    expect(await column.locator('[data-sidebar-right-panel]').count()).toBe(0)
+    expect(await expandOf(page).count()).toBe(0)
+    expect(await width(column)).toBe(0)
+    await shot(page, '01-hero-no-sidebar')
+
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+
+  describe('with a settled session', () => {
+    beforeAll(async () => {
+      await connectFreshWorkspace(page, scaffold.workspaceCwd)
+      // A settled session is what keys the surface; seed one turn through the
+      // real append path so the Chat surface is live before the Sidebar is driven.
+      const agent = scaffold.ctx.agents.list()[0]
+      if (agent === undefined) throw new Error('connected workspace did not create an Agent')
+      // The wire parameter is `agentId`; the client passes a session id. If the
+      // scaffold's Agent and Session carry different ids, that mismatch is the
+      // silent lookup failure.
+      expect(String(agent.id)).toBe(String(agent.session.id))
+      agent.session.append('turn/start', { turn: 1 })
+      agent.session.append('user/message', createUserMessage({
+        content: [{ type: 'text', text: 'Show the right sidebar.' }],
+        source: { kind: 'user' },
+      }), { surfaceOp: 'append' })
+      agent.session.append('step/start', { turn: 1, step: 1 })
+      // A successful mutation is what makes the turn tail offer a produced-file
+      // chip — the product's own way into the Sidebar. The file is written for
+      // real because the preview reads it through the workspace endpoint.
+      //
+      // It goes in the SESSION's cwd, not the scaffold's: the endpoint resolves
+      // relative paths against `sandboxPolicy.resolve({session}).workspaceRoot`,
+      // which is the session header's cwd. Writing anywhere else makes the read
+      // fail with workspace-file/not-found, which is the endpoint being right.
+      writeFileSync(join(agent.session.header.cwd ?? scaffold.workspaceCwd, SAMPLE_NAME), SAMPLE_TEXT, 'utf8')
+      agent.session.append('tool/call', {
+        turn: 1,
+        step: 1,
+        callId: 'call-write-1',
+        name: 'write',
+        arguments: JSON.stringify({ file_path: SAMPLE_NAME, content: SAMPLE_TEXT }),
+      } as never)
+      agent.session.append('tool/result', {
+        turn: 1,
+        step: 1,
+        message: {
+          id: 'result-call-write-1',
+          role: 'user',
+          source: { kind: 'tool', callId: 'call-write-1' },
+          content: [{ type: 'tool-result', toolCallId: 'call-write-1', content: [{ type: 'text', text: 'ok' }] }],
+        },
+      } as never, { surfaceOp: 'append' })
+      agent.session.append('assistant/message', {
+        stream: [],
+        turn: 1,
+        step: 1,
+        message: createMessage({
+          role: 'assistant',
+          content: [{ type: 'text', text: 'Ready.' }],
+          source: { kind: 'model', provider: 'fixture', model: 'fixture' },
+        }),
+      }, { surfaceOp: 'append' })
+      agent.session.append('step/end', { turn: 1, step: 1 })
+      agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      await scaffold.ctx.sessions.flush(agent.session)
+      await page.getByText('Ready.').waitFor({ timeout: 10_000 })
+    }, 120_000)
+
+    it('CONTROL: the old fileReferences namespace answers over the same wire', async () => {
+      const composer = page.locator('textarea, [contenteditable="true"]').first()
+      await composer.click()
+      await composer.pressSequentially('@')
+      // Any candidate list means the agent-scoped lookup and the gateway route
+      // both work in this scaffold; nothing rendered means the wire is the fault.
+      const answered = await page.locator('[data-input-trigger], [role="listbox"], [data-reference-list]')
+        .first().waitFor({ timeout: 10_000 }).then(() => true, () => false)
+      await page.keyboard.press('Escape')
+      // Leave the composer as it was found: these cases share one page, and a
+      // stray '@' rides into every later assertion and screenshot. `fill('')`
+      // does NOT clear this editor — it reported success while the character
+      // stayed — so the reset is a real keystroke, and it is asserted rather
+      // than assumed.
+      await composer.click()
+      await page.keyboard.press('ControlOrMeta+a')
+      await page.keyboard.press('Backspace')
+      await expect.poll(async () => (await composer.innerText()).trim()).toBe('')
+      expect(answered).toBe(true)
+    })
+
+    it('starts collapsed behind a header button and squeezes the conversation when opened', async () => {
+      onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right'))
+      const frame = page.locator('[class*="frame"]').first()
+      const column = page.locator('[data-rightbar-col]')
+      const conversation = page.locator('[class*="centerCol"]').first()
+      const expand = expandOf(page)
+
+      // Collapsed default: no track, the panel sits off the frame's edge, and
+      // the only way in is the header button — on the same row as the other
+      // header utilities, at its far right.
+      await expand.waitFor({ timeout: 15_000 })
+      expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
+      expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
+      const utilities = page.locator('[class*="headerUtilities"]')
+      const expandBox = await expand.boundingBox()
+      const rowBox = await utilities.boundingBox()
+      if (expandBox === null || rowBox === null) throw new Error('header utilities are not rendered')
+      expect(Math.round(expandBox.y + expandBox.height / 2)).toBe(Math.round(rowBox.y + rowBox.height / 2))
+      // Its own corner seat, past the utilities' right edge — not a utility.
+      expect(expandBox.x).toBeGreaterThan(rowBox.x + rowBox.width)
+      const conversationBoxBefore = await conversation.boundingBox()
+      if (conversationBoxBefore === null) throw new Error('conversation is not rendered')
+      // How far the utilities' right edge sits from the conversation's own.
+      const gapBefore = (conversationBoxBefore.x + conversationBoxBefore.width) - (rowBox.x + rowBox.width)
+      const centerBefore = await width(conversation)
+      await shot(page, '02a-collapsed-header-button')
+
+      // Opening squeezes by default: the column takes a track of the panel's
+      // width, the conversation gives up exactly that much room, and the header
+      // button leaves with the panel's arrival.
+      await expand.click()
+      await expect.poll(async () => await frame.getAttribute('data-rightbar-collapsed')).toBe(null)
+      await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(1)
+      const panelWidth = await width(column)
+      expect(panelWidth).toBeGreaterThan(0)
+      expect(await width(conversation)).toBe(centerBefore - panelWidth)
+      await expect.poll(async () => await expand.count()).toBe(0)
+      // The corner keeps its footprint, so the utilities' right edge stays where
+      // it was relative to the conversation's own right edge.
+      expect(await page.locator('[data-sidebar-right-expand-placeholder]').count()).toBe(1)
+      const utilitiesAfter = await utilities.boundingBox()
+      const conversationAfter = await conversation.boundingBox()
+      if (utilitiesAfter === null || conversationAfter === null) throw new Error('header is not rendered')
+      const gapAfter = (conversationAfter.x + conversationAfter.width) - (utilitiesAfter.x + utilitiesAfter.width)
+      expect(Math.round(gapAfter)).toBe(Math.round(gapBefore))
+
+      // The panel is in the column, not over it, and carries the seeded tab —
+      // whose body arrives through the guide type's keyed registration, not from
+      // any dispatch inside the seat. Its two controls sit at the end of the
+      // top-right pane's strip: the panel has no header row of its own.
+      expect(await column.locator('[data-sidebar-right-panel="push"]').count()).toBe(1)
+      const chrome = column.locator('[data-dockkit-strip-chrome]')
+      expect(await chrome.count()).toBe(1)
+      expect(await chrome.locator('[data-sidebar-right-mode]').count()).toBe(1)
+      expect(await chrome.locator('[data-sidebar-right-toggle]').count()).toBe(1)
+
+      // One centre line across the strip: chip text, split, and the two panel
+      // controls all sit at the same height. The add control joins the check
+      // below, once the strip draws it.
+      const centreY = async (selector: string): Promise<number> => {
+        const box = await column.locator(selector).first().boundingBox()
+        if (box === null) throw new Error(`${selector} is not rendered`)
+        return Math.round(box.y + box.height / 2)
+      }
+      const textLine = await centreY('[data-dockkit-tab-title]')
+      for (const selector of ['[data-dockkit-split-button]', '[data-sidebar-right-mode]', '[data-sidebar-right-toggle]']) {
+        expect(await centreY(selector), selector).toBe(textLine)
+      }
+
+      // The guide is unique per pane, so while this pane holds one its strip
+      // offers no add control. Closing it brings the control back, and the
+      // control opens the guide again in that pane.
+      const addTab = column.locator('[data-dockkit-add-tab]')
+      expect(await addTab.count()).toBe(0)
+      await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
+      await column.locator('[data-dockkit-tab-close]').first().click()
+      await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME])
+      await expect.poll(async () => await addTab.count()).toBe(1)
+      expect(await centreY('[data-dockkit-add-tab]')).toBe(textLine)
+      await addTab.click()
+      await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME, 'Start'])
+      await expect.poll(async () => await column.locator('[data-sidebar-right-guide]').count()).toBe(1)
+      await expect.poll(async () => await addTab.count()).toBe(0)
+      // Back to the seeded shape the cases below start from.
+      await column.locator('[data-dockkit-tab-close]').first().click()
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
+      await shot(page, '02-squeezed-panel')
+
+      expect(tripwire.pageErrors).toEqual([])
+      expect(tripwire.warnings).toEqual([])
+    })
+
+    it('covers the viewport in fullscreen without changing the underlying columns', async () => {
+      onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-mode'))
+      const frame = page.locator('[class*="frame"]').first()
+      const column = page.locator('[data-rightbar-col]')
+      const panel = column.locator('[data-sidebar-right-panel]')
+      const conversation = page.locator('[class*="centerCol"]').first()
+      const squeezed = await width(conversation)
+      const before = await panel.boundingBox()
+      const trackWidth = await width(column)
+
+      await column.locator('[data-sidebar-right-mode="fullscreen"]').click()
+      await expect.poll(async () => await panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen')
+      expect(await frame.getAttribute('data-rightbar-collapsed')).toBe(null)
+      expect(await width(conversation)).toBe(squeezed)
+      expect(await width(column)).toBe(trackWidth)
+      const viewport = page.viewportSize()
+      if (viewport === null) throw new Error('expected a fixed viewport')
+      await expect.poll(async () => await panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport })
+      expect(await expandOf(page).count()).toBe(0)
+      expect(await frame.locator('[data-side="rightbar"]').count()).toBe(0)
+      await shot(page, '03-fullscreen-panel')
+
+      await column.locator('[data-sidebar-right-mode="push"]').click()
+      await expect.poll(async () => await panel.getAttribute('data-sidebar-right-panel')).toBe('push')
+      expect(await width(conversation)).toBe(squeezed)
+      expect(await panel.boundingBox()).toEqual(before)
+
+      expect(tripwire.pageErrors).toEqual([])
+      expect(tripwire.warnings).toEqual([])
+    })
+
+    it('keeps a capacity-closed panel closed after widening and uses fullscreen on a narrow viewport', async () => {
+      const viewport = page.viewportSize()
+      if (viewport === null) throw new Error('expected a fixed viewport')
+      const frame = page.locator('[class*="frame"]').first()
+      const column = page.locator('[data-rightbar-col]')
+      const sidebar = page.locator('[class*="sidebarCol"]').first()
+      const panel = column.locator('[data-sidebar-right-panel]')
+      try {
+        await page.setViewportSize({ width: 1024, height: viewport.height })
+        const leftGrip = frame.locator('[data-side="sidebar"]')
+        const grip = await centre(leftGrip)
+        await dragElement(page, leftGrip, { x: 420, y: grip.y })
+        await expect.poll(async () => await width(sidebar)).toBe(420)
+        await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
+        await page.setViewportSize(viewport)
+        await expect.poll(async () => await width(sidebar)).toBe(420)
+        expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
+        await expandOf(page).click()
+        await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(1)
+
+        await page.setViewportSize({ width: 767, height: viewport.height })
+        await expect.poll(async () => await panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen')
+        await expect.poll(async () => await width(panel)).toBe(767)
+        expect(await frame.locator('[data-side="rightbar"]').count()).toBe(0)
+        await column.locator('[data-sidebar-right-mode="push"]').click()
+        await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
+        await page.setViewportSize(viewport)
+        await expect.poll(async () => await width(sidebar)).toBe(420)
+        expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
+      } finally {
+        await page.setViewportSize(viewport)
+        const grip = await centre(frame.locator('[data-side="sidebar"]'))
+        await dragElement(page, frame.locator('[data-side="sidebar"]'), { x: 280, y: grip.y })
+        await expect.poll(async () => await width(sidebar)).toBe(280)
+        await ensureExpanded(page, column)
+      }
+      expect(tripwire.pageErrors).toEqual([])
+      expect(tripwire.warnings).toEqual([])
+    })
+
+    it('CONTROL: the host endpoint answers when called directly, bypassing the wire', async () => {
+      const files = (scaffold.ctx as unknown as {
+        get(name: string): {
+          read(agent: unknown, path: string, range: object, signal: AbortSignal): Promise<{ text: string; eof: boolean }>
+        } | undefined
+      }).get('workspaceFiles')
+      if (files === undefined) throw new Error('host endpoint is not provided')
+      const agent = scaffold.ctx.agents.list()[0]
+      if (agent === undefined) throw new Error('no Agent to read for')
+
+      // Raced against a timer so a hang reports a verdict instead of stalling
+      // the suite: this case exists to tell host logic apart from the wire.
+      // A page is the file's lines joined by `\n`, without the final terminator.
+      const verdict = await Promise.race([
+        files.read(agent, SAMPLE_NAME, {}, new AbortController().signal)
+          .then(value => ({ kind: 'settled' as const, text: value.text, eof: value.eof }))
+          .catch((error: unknown) => ({ kind: 'threw' as const, text: String(error), eof: false })),
+        new Promise<{ kind: 'hung'; text: string; eof: boolean }>((resolve) => {
+          setTimeout(() => { resolve({ kind: 'hung', text: 'no settlement in 10s', eof: false }) }, 10_000)
+        }),
+      ])
+      expect(verdict).toEqual({ kind: 'settled', text: SAMPLE_TEXT.replace(/\n$/u, ''), eof: true })
+    }, 30_000)
+
+    it('opens content once, splits, and floats it outside the column', async () => {
+      onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-content'))
+      const column = page.locator('[data-rightbar-col]')
+      const panes = column.locator('[data-dockkit-pane]')
+      const floats = page.locator('[data-sidebar-right-float-host] [data-dockkit-float]')
+
+      // Observation before action: does the read ever leave the browser? The
+      // assertion states the healthy answer so a failure prints the real one.
+      //
+      // CAVEAT: `sent` is trustworthy — a request frame carries the endpoint
+      // name. `received` is NOT: a response frame carries only its rpc id, so a
+      // zero here means "my filter saw nothing", not "the host never answered".
+      // Correlate by rpc id before drawing any conclusion from it.
+      const wire = { sent: 0, received: 0 }
+      const watch = (payload: string): void => {
+        if (!payload.includes('workspaceFiles')) return
+        wire.sent += 1
+      }
+      page.on('websocket', (ws) => {
+        ws.on('framesent', (frame) => { watch(String(frame.payload)) })
+        ws.on('framereceived', (frame) => {
+          if (String(frame.payload).includes('workspaceFiles')) wire.received += 1
+        })
+      })
+      page.on('request', (request) => {
+        if (request.url().includes('workspaceFiles')) wire.sent += 1
+      })
+
+      // The product's own entry point: the turn tail's produced-file chip. It
+      // reaches the Sidebar through openFile → ctx.sidebarRight.openResource, and the
+      // text type claims the address.
+      const chip = page.getByRole('button', { name: `Open ${SAMPLE_NAME}` })
+      await chip.click()
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
+
+      // Opening the same content again focuses rather than duplicating.
+      await panes.first().locator('[data-dockkit-tab]').first().click()
+      await chip.click()
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
+
+      // The body arrives through the text type's keyed registration, and its
+      // content came over the wire from the real file.
+      // A real Remote round-trip settles well after the default poll window.
+      await column.locator('[data-textpreview-state="text"]')
+        .waitFor({ timeout: 15_000 })
+        .catch(() => { throw new Error(`preview never settled; wire=${JSON.stringify(wire)}`) })
+      expect(await column.locator('pre').first().innerText()).toContain('produced by the seeded turn')
+      // The whole batch-E chain in one frame: a produced-file chip in the
+      // conversation, the tab it opened, and the file's real content read over
+      // the workspace endpoint.
+      await shot(page, '06-produced-chip-to-preview')
+
+      // The directory scenario's V1 behaviour, asserted in the shipped product:
+      // there is no folder affordance at all. `openFile('.')` would name a
+      // directory, which a text preview correctly refuses, and the native opener
+      // it used to reach is gone — so the row offers nothing rather than a
+      // button that always fails.
+      expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0)
+
+      // Split, then dock-drag: the kit's gestures drive the store's actions.
+      await panes.first().locator('[data-dockkit-split-button]').click()
+      await expect.poll(async () => await panes.count()).toBe(2)
+      await dragTo(
+        page,
+        column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }).first(),
+        await pointIn(panes.nth(1), 0.5, 0.94),
+      )
+      await expect.poll(async () => await panes.count()).toBe(2)
+
+      await panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
+        .locator('[data-dockkit-tab-close]').click()
+      await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME])
+
+      // The guide is unique per pane: panes seeded with one offer no
+      // add control; the pane holding only the file is the one that does.
+      const filePane = panes.filter({ has: page.locator('[data-dockkit-tab-title]', { hasText: SAMPLE_NAME }) })
+      await expect.poll(async () => await filePane.locator('[data-dockkit-add-tab]').count()).toBe(1)
+      expect(await column.locator('[data-dockkit-add-tab]').count()).toBe(1)
+
+      // Floating leaves the column entirely, and survives collapsing it. The
+      // pane the tab was alone in goes with it: an emptied pane never stays.
+      const tab = column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }).first()
+      await floatByDrag(page, tab)
+      await expect.poll(async () => await floats.count()).toBe(1)
+      expect(await column.locator('[data-dockkit-float]').count()).toBe(0)
+      await expect.poll(async () => await panes.count()).toBe(1)
+      await shot(page, '04-split-and-float')
+
+      await column.locator('[data-sidebar-right-toggle]').click()
+      await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
+      expect(await floats.count()).toBe(1)
+
+      expect(tripwire.pageErrors).toEqual([])
+      expect(tripwire.warnings).toEqual([])
+    })
+
+    // §9.2 (an explicit second copy of the same content) has no control on the
+    // panel by product decision, and copy has no service method yet:
+    // `duplicateTab` is a store/kit intent only, which service.client.spec.ts covers.
+
+    it('keeps each session\'s surface to itself, and restores it on return', async () => {
+      const fx = await newEnglishPage(browser)
+      const fxTripwire = watchConsole(fx)
+      onTestFailed(() => saveFailureShot(fx, 'web-e2e-sidebar-right-sessions'))
+      try {
+        await fx.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+        const settled = fx.getByRole('treeitem', { name: /Show the right sidebar\./u }).first()
+        await settled.click()
+        await expect.poll(async () => await settled.getAttribute('aria-selected')).toBe('true')
+        const frame = fx.locator('[class*="frame"]').first()
+        const column = fx.locator('[data-rightbar-col]')
+        await ensureExpanded(fx, column)
+        await width(column)
+        await fx.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
+        await column.locator('[data-textpreview-state="text"]').waitFor({ timeout: 15_000 })
+        const wrap = column.locator('[data-textpreview-tool="wrap"]')
+        expect(await wrap.getAttribute('aria-pressed')).toBe('true')
+        await wrap.click()
+        await expect.poll(async () => await wrap.getAttribute('aria-pressed')).toBe('false')
+        await column.locator('[data-dockkit-split-button]').first().click()
+        const panes = column.locator('[data-dockkit-pane]')
+        await expect.poll(async () => await panes.count()).toBe(2)
+        const records = async (): Promise<{ panes: string[]; tabs: string[]; titles: string[] }> => ({
+          panes: await panes.evaluateAll(nodes => nodes.map(node => node.getAttribute('data-dockkit-pane')!)),
+          tabs: await column.locator('[data-dockkit-tab]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-dockkit-tab')!)),
+          titles: await tabTitles(column),
+        })
+        const before = await records()
+
+        // The real New Session action selects a distinct blank Session; its
+        // collapsed surface must not inherit the settled Session's tabs.
+        await fx.getByRole('button', { name: 'New session', exact: true }).last().click()
+        await expect.poll(async () => await settled.getAttribute('aria-selected')).toBe('false')
+        await expect.poll(async () => await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
+        expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
+        expect(await column.locator('[data-textpreview-state="text"]').count()).toBe(0)
+
+        await settled.click()
+        await expect.poll(async () => await settled.getAttribute('aria-selected')).toBe('true')
+        await expect.poll(records, { timeout: 15_000 }).toEqual(before)
+        expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
+        expect(await wrap.getAttribute('aria-pressed')).toBe('false')
+        expect(await column.locator('pre').first().innerText()).toContain('produced by the seeded turn')
+        expect(fxTripwire.pageErrors).toEqual([])
+        expect(fxTripwire.warnings).toEqual([])
+      } finally {
+        await fx.close()
+      }
+    }, 120_000)
+
+    it('§9.3/§9.4 runs the whole pointer chain in a real browser', async () => {
+      onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-gestures'))
+      const column = await resetSidebar(page)
+      const panes = column.locator('[data-dockkit-pane]')
+      const floats = page.locator('[data-sidebar-right-float-host] [data-dockkit-float]')
+
+      // Chromium cancels pointer capture if a render replaces the pressed
+      // element; jsdom cannot establish that the whole gesture survives.
+
+      // 1. Reorder inside one strip: drop the last tab left of its neighbours.
+      //    The first pane needs two tabs for this — and for the move below to
+      //    leave it standing, since a pane emptied by a move is dropped.
+      const first = panes.first()
+      const strip = first.locator('[data-dockkit-strip]')
+      await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
+      await expect.poll(async () => await tabTitles(first)).toEqual(['Start', SAMPLE_NAME])
+      const order = await tabTitles(first)
+      // The insertion index is measured against chip midpoints, not strip width.
+      await dragTo(page, first.locator('[data-dockkit-tab]').last(),
+        await pointIn(first.locator('[data-dockkit-tab]').first(), 0.25, 0.5),
+        strip.locator('[data-dockkit-caret="0"]'))
+      await expect.poll(async () => await tabTitles(first)).toEqual([...order].reverse())
+
+      // 2. Cross-pane move into a second pane: the tab leaves one pane's strip
+      //    for another's.
+      if (await panes.count() < 2) {
+        await first.locator('[data-dockkit-split-button]').click()
+        await expect.poll(async () => await panes.count()).toBe(2)
+      }
+      const moving = first.locator('[data-dockkit-tab]').first()
+      const title = await moving.locator('[data-dockkit-tab-title]').innerText()
+      await dragTo(page, moving, await pointIn(panes.nth(1), 0.5, 0.5))
+      await expect.poll(async () => await tabTitles(panes.nth(1))).toContain(title)
+
+      const splitOf = (pane: Locator): Locator => pane.locator('[data-dockkit-split-button]')
+      await expect.poll(async () => await splitOf(panes.first()).isDisabled()).toBe(true)
+      expect(await splitOf(panes.first()).getAttribute('data-dockkit-split-blocked')).toBe('budget')
+      expect(await panes.count()).toBe(2)
+      await setPanelWidth(page, 560)
+      await expect.poll(async () => await splitOf(panes.last()).isDisabled()).toBe(true)
+      expect(await splitOf(panes.last()).getAttribute('data-dockkit-split-blocked')).toBe('budget')
+
+      const outer = column.locator('[data-dockkit-divider]').first()
+      const before = await width(panes.last())
+      const grip = await centre(outer)
+      await dragElement(page, outer, { x: grip.x - 100, y: grip.y })
+      await expect.poll(async () => await width(panes.last())).toBeGreaterThan(before)
+      await dragElement(page, outer, { x: 0, y: grip.y })
+      const ratio = async (): Promise<number> => {
+        const left = await width(panes.first())
+        const right = await width(panes.last())
+        return left / (left + right)
+      }
+      await expect.poll(ratio).toBeCloseTo(0.2, 2)
+      const surfaceBox = await column.locator('[data-dockkit-surface]').boundingBox()
+      if (surfaceBox === null) throw new Error('surface is not rendered')
+      await dragElement(page, outer, { x: surfaceBox.x + surfaceBox.width / 2, y: grip.y })
+      await expect.poll(ratio).toBeCloseTo(0.5, 2)
+      expect(await panes.count()).toBe(2)
+      expect(await splitOf(panes.first()).isDisabled()).toBe(true)
+      expect(await splitOf(panes.first()).getAttribute('data-dockkit-split-blocked')).toBe('budget')
+      expect(await splitOf(panes.first()).getAttribute('title')).toBe('Two panes is the limit')
+
+      // 5. Two floats coexist, and one of them moves. Both leave the widest
+      //    pane; a pane emptied by the first float is merged away, so the
+      //    second one comes from whichever pane is widest by then.
+      const floatOne = panes.last().locator('[data-dockkit-tab]').first()
+      await floatByDrag(page, floatOne)
+      await expect.poll(async () => await floats.count()).toBe(1)
+      const box = await floats.first().boundingBox()
+      if (box === null) throw new Error('float is not rendered')
+      await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 })
+      await expect.poll(async () => (await floats.first().boundingBox())?.x ?? box.x).not.toBe(box.x)
+
+      const second = panes.last().locator('[data-dockkit-tab]').first()
+      await floatByDrag(page, second)
+      await expect.poll(async () => await floats.count()).toBe(2)
+
+      // 6. Dock one back: the docked tree takes it, the other float stays. Dock
+      //    the TOPMOST float — floats render bottom-to-top, so the newest one
+      //    covers the older one's controls and would intercept the click.
+      await floats.last().locator('[data-dockkit-float-dock]').click()
+      await expect.poll(async () => await floats.count()).toBe(1)
+
+      expect(tripwire.pageErrors).toEqual([])
+      expect(tripwire.warnings).toEqual([])
+    }, 90_000)
+
+    it('drops a pane whose last tab closes, and reseeds the guide when none is left', async () => {
+      onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-settle'))
+      const column = await resetSidebar(page)
+      const panes = column.locator('[data-dockkit-pane]')
+      await panes.first().locator('[data-dockkit-split-button]').click()
+      await expect.poll(async () => await panes.count()).toBe(2)
+      const closeAllIn = async (pane: Locator): Promise<void> => {
+        const tabs = await pane.locator('[data-dockkit-tab]').count()
+        for (let i = 0; i < tabs; i += 1) {
+          await pane.locator('[data-dockkit-tab-close]').first().click()
+        }
+      }
+
+      // Closing a pane's last tab drops the pane: there is no separate
+      // "close pane" gesture, and none is needed.
+      let count = await panes.count()
+      expect(count).toBeGreaterThan(1)
+      while (count > 1) {
+        await closeAllIn(panes.nth(count - 1))
+        await expect.poll(async () => await panes.count()).toBe(count - 1)
+        count -= 1
+      }
+
+      // The last pane cannot be dropped, so closing everything in it reseeds
+      // the guide: the surface always has one tab to look at.
+      await closeAllIn(panes.first())
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
+      expect(await column.locator('[data-sidebar-right-guide]').count()).toBe(1)
+
+      expect(tripwire.pageErrors).toEqual([])
+      expect(tripwire.warnings).toEqual([])
+    }, 90_000)
+
+    it('§9.7 returns to the default surface after a reload', async () => {
+      onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-reload'))
+      await page.reload({ waitUntil: 'load' })
+      await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+      const frame = page.locator('[class*="frame"]').first()
+      const column = page.locator('[data-rightbar-col]')
+      await column.waitFor({ state: 'attached', timeout: 15_000 })
+      // The surface is view state, not durable session data: a reload zeroes it
+      // back to the collapsed default. Expected behaviour, not a defect.
+      await expect.poll(async () => await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
+      await expect.poll(async () => await expandOf(page).count()).toBe(1)
+      expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
+    })
+
+    it('opens a context menu on right-click that the strip cannot clip', async () => {
+      onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-menu'))
+      const column = page.locator('[data-rightbar-col]')
+
+      await ensureExpanded(page, column)
+      await expect.poll(async () => await column.locator('[data-dockkit-tab]').count()).toBeGreaterThan(0)
+      // No "more" control on the chip: the chip carries its close, and the menu
+      // is the secondary press.
+      expect(await column.locator('[data-dockkit-tab-more]').count()).toBe(0)
+      await column.locator('[data-dockkit-tab]').first().click({ button: 'right' })
+      const menu = page.locator('[data-dockkit-tab-menu]')
+      await expect.poll(async () => await menu.count()).toBe(1)
+
+      // The kit's one item; the extension seat is declared and rendered, and
+      // with no registrant it contributes nothing, which is what "declared, not
+      // speculative" looks like from the outside.
+      await expect.poll(async () => await menu.getByRole('menuitem').allInnerTexts()).toEqual(['Close'])
+      // The menu hangs below the strip that clips its overflow. Its pixels are
+      // its own: a hit test at its centre lands on it, not on whatever the
+      // strip would have shown through a clipped box.
+      expect(await hitsItself(menu)).toBe(true)
+      const box = await menu.boundingBox()
+      const viewport = page.viewportSize()
+      if (box === null || viewport === null) throw new Error('menu or viewport is not measurable')
+      expect(box.x).toBeGreaterThanOrEqual(0)
+      expect(box.x + box.width).toBeLessThanOrEqual(viewport.width)
+      await page.keyboard.press('Escape')
+
+      expect(tripwire.pageErrors).toEqual([])
+      expect(tripwire.warnings).toEqual([])
+    })
+
+    // The product ships Chinese; the cases above advertise English so their role
+    // locators stay stable. This is the other half of the same seam, and the
+    // screenshot it takes is what the copy draft gets reviewed from. It lives in
+    // this block because a settled session is its precondition too — a case that
+    // depends on a sibling block's setup passes only in the right order.
+    it('renders the shipped Chinese copy on a Chinese page', async () => {
+      const zhPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+      const zhTripwire = watchConsole(zhPage)
+      onTestFailed(() => saveFailureShot(zhPage, 'web-e2e-sidebar-right-zh'))
+      try {
+        await zhPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+        await zhPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+        // A fresh page opens the workspace on a blank session's hero, which has
+        // no session header and so no expand button. The settled session is the
+        // second row of the tree; pick it the way a user would.
+        await zhPage.getByRole('treeitem', { name: /Show the right sidebar\./u }).first().click()
+        const column = zhPage.locator('[data-rightbar-col]')
+        await expandOf(zhPage).waitFor({ timeout: 20_000 })
+        await expandOf(zhPage).click()
+
+        const guide = column.locator('[data-sidebar-right-guide]')
+        await expect.poll(async () => await guide.count()).toBe(1)
+        // Wait for the track, not just the panel: the copy is only legible once
+        // the column has the width, and a screenshot taken mid-transition reads
+        // as a layout defect that is not there.
+        expect(await width(column)).toBeGreaterThan(300)
+        await expect.poll(async () => await tabTitles(column)).toEqual(['开始'])
+        await expect.poll(async () => await guide.locator('p').first().innerText())
+          .toBe('侧栏用来放你想一直看着的东西。')
+        await shot(zhPage, '05-guide-copy-zh')
+
+        expect(zhTripwire.pageErrors).toEqual([])
+        expect(zhTripwire.warnings).toEqual([])
+      } finally {
+        await zhPage.close()
+      }
+    }, 120_000)
+  })
+})

+ 2 - 0
apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/bash-abort-row/ui.expected.md

@@ -4,6 +4,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/cordis-tool-round/ui.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 496 - 0
snapshots/web/details-session-lifecycle/sidebar.expected.md

@@ -0,0 +1,496 @@
+# Recorded-session Sidebar states
+
+## A normal: two panes
+
+```json
+{
+  "viewport": [
+    1680,
+    1000
+  ],
+  "columns": [
+    280,
+    644,
+    756
+  ],
+  "expanded": true,
+  "mode": "push",
+  "panelContentWidth": 756,
+  "panelOuterWidth": 757,
+  "coversViewport": false,
+  "resizeHandleWidth": 8,
+  "expandedDirectories": [],
+  "panes": [
+    {
+      "active": true,
+      "tabs": [
+        {
+          "title": "Files",
+          "selected": true
+        },
+        {
+          "title": "Start",
+          "selected": false
+        }
+      ]
+    },
+    {
+      "active": false,
+      "tabs": [
+        {
+          "title": "Start",
+          "selected": true
+        }
+      ]
+    }
+  ]
+}
+```
+
+## A manual fullscreen: underlying columns retained
+
+```json
+{
+  "viewport": [
+    1680,
+    1000
+  ],
+  "columns": [
+    280,
+    644,
+    756
+  ],
+  "expanded": true,
+  "mode": "fullscreen",
+  "panelContentWidth": 1680,
+  "panelOuterWidth": 1680,
+  "coversViewport": true,
+  "resizeHandleWidth": 0,
+  "expandedDirectories": [],
+  "panes": [
+    {
+      "active": true,
+      "tabs": [
+        {
+          "title": "Files",
+          "selected": true
+        },
+        {
+          "title": "Start",
+          "selected": false
+        }
+      ]
+    },
+    {
+      "active": false,
+      "tabs": [
+        {
+          "title": "Start",
+          "selected": true
+        }
+      ]
+    }
+  ]
+}
+```
+
+## A closed with manual fullscreen retained
+
+```json
+{
+  "viewport": [
+    1680,
+    1000
+  ],
+  "columns": [
+    280,
+    1400,
+    0
+  ],
+  "expanded": false,
+  "mode": "fullscreen",
+  "panelContentWidth": 0,
+  "panelOuterWidth": 0,
+  "coversViewport": false,
+  "resizeHandleWidth": 0,
+  "expandedDirectories": [],
+  "panes": [
+    {
+      "active": true,
+      "tabs": [
+        {
+          "title": "Files",
+          "selected": true
+        },
+        {
+          "title": "Start",
+          "selected": false
+        }
+      ]
+    },
+    {
+      "active": false,
+      "tabs": [
+        {
+          "title": "Start",
+          "selected": true
+        }
+      ]
+    }
+  ]
+}
+```
+
+## B closed: independent pane and expanded workspace directory
+
+```json
+{
+  "viewport": [
+    1680,
+    1000
+  ],
+  "columns": [
+    280,
+    1400,
+    0
+  ],
+  "expanded": false,
+  "mode": "push",
+  "panelContentWidth": 0,
+  "panelOuterWidth": 0,
+  "coversViewport": false,
+  "resizeHandleWidth": 0,
+  "expandedDirectories": [
+    "workspace"
+  ],
+  "panes": [
+    {
+      "active": true,
+      "tabs": [
+        {
+          "title": "Files",
+          "selected": true
+        }
+      ]
+    }
+  ]
+}
+```
+
+## A restored: manual fullscreen, tabs, and panes
+
+```json
+{
+  "viewport": [
+    1680,
+    1000
+  ],
+  "columns": [
+    280,
+    644,
+    756
+  ],
+  "expanded": true,
+  "mode": "fullscreen",
+  "panelContentWidth": 1680,
+  "panelOuterWidth": 1680,
+  "coversViewport": true,
+  "resizeHandleWidth": 0,
+  "expandedDirectories": [],
+  "panes": [
+    {
+      "active": true,
+      "tabs": [
+        {
+          "title": "Files",
+          "selected": true
+        },
+        {
+          "title": "Start",
+          "selected": false
+        }
+      ]
+    },
+    {
+      "active": false,
+      "tabs": [
+        {
+          "title": "Start",
+          "selected": true
+        }
+      ]
+    }
+  ]
+}
+```
+
+## B restored: normal mode and Files directory state
+
+```json
+{
+  "viewport": [
+    1680,
+    1000
+  ],
+  "columns": [
+    280,
+    644,
+    756
+  ],
+  "expanded": true,
+  "mode": "push",
+  "panelContentWidth": 756,
+  "panelOuterWidth": 757,
+  "coversViewport": false,
+  "resizeHandleWidth": 8,
+  "expandedDirectories": [
+    "workspace"
+  ],
+  "panes": [
+    {
+      "active": true,
+      "tabs": [
+        {
+          "title": "Files",
+          "selected": true
+        }
+      ]
+    }
+  ]
+}
+```
+
+## A restored: expanded normal panel
+
+```json
+{
+  "viewport": [
+    1680,
+    1000
+  ],
+  "columns": [
+    280,
+    644,
+    756
+  ],
+  "expanded": true,
+  "mode": "push",
+  "panelContentWidth": 756,
+  "panelOuterWidth": 757,
+  "coversViewport": false,
+  "resizeHandleWidth": 8,
+  "expandedDirectories": [],
+  "panes": [
+    {
+      "active": true,
+      "tabs": [
+        {
+          "title": "Files",
+          "selected": true
+        },
+        {
+          "title": "Start",
+          "selected": false
+        }
+      ]
+    },
+    {
+      "active": false,
+      "tabs": [
+        {
+          "title": "Start",
+          "selected": true
+        }
+      ]
+    }
+  ]
+}
+```
+
+## A capacity-closed: wide left preference protected
+
+```json
+{
+  "viewport": [
+    1024,
+    1000
+  ],
+  "columns": [
+    420,
+    604,
+    0
+  ],
+  "expanded": false,
+  "mode": "push",
+  "panelContentWidth": 0,
+  "panelOuterWidth": 0,
+  "coversViewport": false,
+  "resizeHandleWidth": 0,
+  "expandedDirectories": [],
+  "panes": [
+    {
+      "active": true,
+      "tabs": [
+        {
+          "title": "Files",
+          "selected": true
+        },
+        {
+          "title": "Start",
+          "selected": false
+        }
+      ]
+    },
+    {
+      "active": false,
+      "tabs": [
+        {
+          "title": "Start",
+          "selected": true
+        }
+      ]
+    }
+  ]
+}
+```
+
+## A widened: remains closed
+
+```json
+{
+  "viewport": [
+    1680,
+    1000
+  ],
+  "columns": [
+    420,
+    1260,
+    0
+  ],
+  "expanded": false,
+  "mode": "push",
+  "panelContentWidth": 0,
+  "panelOuterWidth": 0,
+  "coversViewport": false,
+  "resizeHandleWidth": 0,
+  "expandedDirectories": [],
+  "panes": [
+    {
+      "active": true,
+      "tabs": [
+        {
+          "title": "Files",
+          "selected": true
+        },
+        {
+          "title": "Start",
+          "selected": false
+        }
+      ]
+    },
+    {
+      "active": false,
+      "tabs": [
+        {
+          "title": "Start",
+          "selected": true
+        }
+      ]
+    }
+  ]
+}
+```
+
+## A automatic fullscreen at 767px
+
+```json
+{
+  "viewport": [
+    767,
+    1000
+  ],
+  "columns": [
+    56,
+    711,
+    0
+  ],
+  "expanded": true,
+  "mode": "fullscreen",
+  "panelContentWidth": 767,
+  "panelOuterWidth": 767,
+  "coversViewport": true,
+  "resizeHandleWidth": 0,
+  "expandedDirectories": [],
+  "panes": [
+    {
+      "active": true,
+      "tabs": [
+        {
+          "title": "Files",
+          "selected": true
+        },
+        {
+          "title": "Start",
+          "selected": false
+        }
+      ]
+    },
+    {
+      "active": false,
+      "tabs": [
+        {
+          "title": "Start",
+          "selected": true
+        }
+      ]
+    }
+  ]
+}
+```
+
+## A automatic fullscreen exited: widening does not reopen
+
+```json
+{
+  "viewport": [
+    1680,
+    1000
+  ],
+  "columns": [
+    420,
+    1260,
+    0
+  ],
+  "expanded": false,
+  "mode": "push",
+  "panelContentWidth": 0,
+  "panelOuterWidth": 0,
+  "coversViewport": false,
+  "resizeHandleWidth": 0,
+  "expandedDirectories": [],
+  "panes": [
+    {
+      "active": true,
+      "tabs": [
+        {
+          "title": "Files",
+          "selected": true
+        },
+        {
+          "title": "Start",
+          "selected": false
+        }
+      ]
+    },
+    {
+      "active": false,
+      "tabs": [
+        {
+          "title": "Start",
+          "selected": true
+        }
+      ]
+    }
+  ]
+}
+```

+ 2 - 0
snapshots/web/feedback-command/ack-expanded.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/feedback-command/ack.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/feedback-release/ack-expanded.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/feedback-release/ack.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/file-upload-round/ui.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/fresh-round-trip/ui-expanded.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/fresh-round-trip/ui.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/goal-multi-turn-actions/ui.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 0 - 3
snapshots/web/lifecycle-chrome/hero.expected.md

@@ -40,6 +40,3 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: Details
-- button "Close details"
-- text: Click a tool row in the message flow to view its details

+ 0 - 3
snapshots/web/lifecycle-chrome/plan-active.expected.md

@@ -40,6 +40,3 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: Details
-- button "Close details"
-- text: Click a tool row in the message flow to view its details

+ 2 - 0
snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/lifecycle-chrome/reloaded.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/live-interactions/cancel-expanded.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/live-interactions/cancel.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/live-interactions/error-auth.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/live-interactions/loading.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/live-interactions/retry-exhausted.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/live-interactions/retry-expanded.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/live-interactions/retry.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/live-interactions/running-draft.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/message-actions/ui.expected.md

@@ -4,6 +4,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/minimal-preset/ui.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/plan-review/approved-expanded.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/plan-review/approved.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/ptc-round/ui.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/question-composer/answered-expanded.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/question-composer/answered.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/question-composer/cancelled.expected.md

@@ -4,6 +4,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/queue-actions/collapsed.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/queue-actions/editing.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/queue-actions/layout.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/queue-actions/preserved-expanded.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/queue-actions/preserved.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/queue-actions/sending.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/queue-actions/ui.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/queued-image/delivered.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/queued-image/queued.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/seeded-history/command-row.expected.md

@@ -4,6 +4,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/seeded-history/feedback-row.expected.md

@@ -4,6 +4,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 0 - 7
snapshots/web/seeded-history/file-open-failure.expected.md

@@ -1,7 +0,0 @@
-- dialog "Couldn’t open file":
-  - heading "Couldn’t open file" [level=2]
-  - button "Close":
-    - img
-  - paragraph: "path open failed: xdg-open is not available"
-  - button "Cancel"
-  - button "Retry"

+ 2 - 0
snapshots/web/seeded-history/ui-expanded.expected.md

@@ -4,6 +4,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/seeded-history/ui.expected.md

@@ -4,6 +4,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/skill-tool-row/ui.expected.md

@@ -4,6 +4,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/steering/mid-steer.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/steering/settled-expanded.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/steering/settled.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/subagent-conversation/nested.expected.md

@@ -8,6 +8,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/subagent-conversation/ui-expanded.expected.md

@@ -11,6 +11,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/subagent-conversation/ui.expected.md

@@ -11,6 +11,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/subagent-interrupt/offline-composer.expected.md

@@ -8,6 +8,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/turn-tail-actions/completed.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/turn-tail-actions/focused.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/turn-tail-actions/running.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/turn-tail-actions/settled.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/turn-tail-actions/usage-expanded.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
snapshots/web/web-search-round/ui.expected.md

@@ -6,6 +6,8 @@
   - button "Session log":
     - text: Session log
     - img
+  - button "Open the sidebar":
+    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"