Просмотр исходного кода

fix(web): allow opening the sidebar before the first message

Yichen Jiang 1 неделя назад
Родитель
Сommit
7f2d7b1791

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
-2026-07-25-web-client-session-scope-and-provide-channel.md: 620102f9f5e7fd76f38bf0031b856b26c2a7840d
-2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 3f00658c672908ba92627416ba5d5db381c0d9cd
+2026-07-25-web-client-session-scope-and-provide-channel.md: 0f003edc4a53e7a04ea4f5613abe42c2d3926566
+2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 1bae16cfaa1c54eebc6c8709ac6db5323b68d222

+ 2 - 0
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md

@@ -95,6 +95,8 @@ Slot scope is the closed set `root | session-maybe | session`:
 
 `conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip.
 
+Blank Sessions retain the header's leading and corner slots so navigation controls, including the right-sidebar opener, are available before the first message. Title, actions, utilities, and View tabs remain hidden in the blank phase. The header still requires a selected Session; the Files and Terminal entries use that Session's workspace and execution services without requiring a recorded Turn.
+
 - The runtime's first built-in entry: the `'session'` hook — `useSession` itself rides the same mechanism, no special-casing.
 - Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders).
 - Third-party components take zero value dependencies; types are a one-line type-only import (declaration merging into `SessionStandardProps` / `SessionMaybeStandardProps`).

+ 2 - 0
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md

@@ -95,6 +95,8 @@ slot scope 是闭集 `root | session-maybe | session`:
 
 `conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无会话 → blank 会话的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时,其 machine faces 和消息动作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。
 
+blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入口等导航控件在首条消息之前即可使用。标题、actions、utilities 和 View tabs 在 blank phase 中继续隐藏。header 仍要求已选中的 Session;Files 与 Terminal 入口使用该 Session 的工作区和执行服务,无需已有 Turn 记录。
+
 - 运行时内建第一条:`'session'` 钩子——`useSession` 本身走同一机制,无特判。
 - Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。
 - 第三方组件值零依赖,类型一行 type-only import(declaration merging 进 `SessionStandardProps` / `SessionMaybeStandardProps`)。

+ 66 - 2
apps/web/tests/details-session-lifecycle.e2e.ts

@@ -1,5 +1,5 @@
 // Recorded-session Sidebar geometry and per-Session view state through the shipped browser composition.
-import { mkdir, readFile } from 'node:fs/promises'
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
 import type { Browser, Locator, Page } from 'playwright'
@@ -15,6 +15,7 @@ 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 BLANK_EXPECTED = join(SNAPSHOT_DIR, 'blank-session.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.v3.jsonl', import.meta.url))
 const SEED_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
@@ -127,17 +128,20 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
   let browser: Browser
   let page: Page
   let tripwire: ReturnType<typeof watchConsole>
+  const sessionEvents: string[] = []
 
   beforeAll(async () => {
     const fixture = await readFile(FIXTURE, 'utf8')
     expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
     scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false })
     await seedSession(scaffold, await readFile(SEED_FIXTURE, 'utf8'), 'details-session-lifecycle-seed')
+    scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event.type) })
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await appFrame(page).waitFor({ timeout: 30_000 })
+    expect(await page.locator('[data-sidebar-right-expand]').count()).toBe(0)
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)
 
@@ -154,12 +158,72 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
       await mkdir(SHOT_DIR, { recursive: true })
       await saveFailureShot(page, `screenshots/0907-sidebar-rules/details-session-lifecycle-${MODE}-${process.pid}`)
     })
+    const blankColumn = page.locator('[data-rightbar-col]')
+    const workspace = join(scaffold.workspaceCwd, 'workspace')
+    await writeFile(join(workspace, 'before-chat.md'), '# Before the first message\n\nWorkspace preview is available.\n')
+    await page.locator('[data-sidebar-right-expand]').waitFor({ state: 'visible' })
+    await page.screenshot({ path: join(SHOT_DIR, `blank-collapsed-${MODE}-${process.pid}.png`), fullPage: true })
+    await page.locator('[data-sidebar-right-expand]').click()
+    await blankColumn.locator('[data-sidebar-right-guide-entry="files"]').click()
+    await blankColumn.locator('[data-files-entry="file"]').getByRole('button', { name: 'before-chat.md', exact: true }).click()
+    await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor()
+    await page.getByText('Into the Unknown', { exact: false }).waitFor()
+    const blankPanes = await paneSnapshot(page)
+    expect(blankPanes.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files', 'before-chat.md']])
+    await page.screenshot({ path: join(SHOT_DIR, `blank-preview-${MODE}-${process.pid}.png`), fullPage: true })
+
+    const blankViewport = page.viewportSize()!
+    try {
+      await blankColumn.locator('[data-sidebar-right-toggle]').click()
+      await page.setViewportSize({ width: 767, height: blankViewport.height })
+      await page.locator('[data-sidebar-right-expand]').click()
+      await expect.poll(() => blankColumn.locator('[data-sidebar-right-panel]').boundingBox())
+        .toEqual({ x: 0, y: 0, width: 767, height: blankViewport.height })
+      await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor()
+      await blankColumn.locator('[data-sidebar-right-toggle]').click()
+    } finally {
+      await page.setViewportSize(blankViewport)
+    }
+    await page.locator('[data-sidebar-right-expand]').click()
+    await blankColumn.locator('[data-dockkit-add-tab]').click()
+    await blankColumn.locator('[data-sidebar-right-guide-entry="terminal"]')
+      .getByRole('button', { name: /^New terminal/u }).click()
+    const agent = scaffold.ctx.agents.list().find(agent => agent.session.header.cwd === workspace)
+    if (agent === undefined) throw new Error('Blank Session has no workspace Agent')
+    await expect.poll(() => scaffold.ctx.terminalController.list(agent.id).map(terminal => terminal.state)).toEqual(['running'])
+    await page.locator('.xterm-helper-textarea:visible').click()
+    await page.keyboard.insertText('node -e "require(\'fs\').writeFileSync(\'before-chat-terminal.txt\',\'READY\')"')
+    await page.keyboard.press('Enter')
+    await expect.poll(() => readFile(join(workspace, 'before-chat-terminal.txt'), 'utf8')).toBe('READY')
+    expect(sessionEvents).not.toContain('turn/start')
+    expect(sessionEvents).not.toContain('user/message')
+    await blankColumn.locator('[data-dockkit-tab][aria-selected="true"]').hover()
+    await blankColumn.locator('[data-dockkit-tab][aria-selected="true"] [data-dockkit-tab-close]').click()
+    await expect.poll(() => scaffold.ctx.terminalController.list(agent.id)).toEqual([])
+    await blankColumn.locator('[data-dockkit-tab]').filter({ hasText: 'before-chat.md' }).click()
+    await compareOrRefreshGolden(BLANK_EXPECTED, [
+      '# Blank Session workspace sidebar', '',
+      '- No selected Session: expand control absent',
+      '- Selected workspace before first message: expand control visible',
+      '- Files: before-chat.md opens as a Markdown preview',
+      '- Narrow viewport: reopened preview fills the viewport',
+      '- Terminal: writes a file in the selected workspace before any user message or turn', '',
+      `\`\`\`json\n${JSON.stringify(await paneSnapshot(page), null, 2)}\n\`\`\``,
+    ].join('\n'), MODE)
+
     const settled = scaffold.whenTurnSettled()
     const input = page.locator('[data-composer-input]').first()
     await input.fill(PROMPT)
     await input.press('Enter')
     await settled
     await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
+    expect(await paneSnapshot(page)).toEqual(blankPanes)
+    await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor()
+    for (const title of ['before-chat.md', 'Files']) {
+      const tab = blankColumn.locator('[data-dockkit-tab]').filter({ hasText: title })
+      await tab.hover()
+      await tab.locator('[data-dockkit-tab-close]').click()
+    }
 
     await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
     expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
@@ -356,6 +420,6 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     await compareOrRefreshGolden(SIDEBAR_EXPECTED, checkpoints.join('\n\n'), MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md', 'sidebar.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md', 'sidebar.expected.md', 'blank-session.expected.md'])
   })
 })

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 802ba3b8d853457e00cab3180c251ffec5291f19
-README.zh.md: 83199417289770640c9c038d84ee3e0fa6ac7866
+README.md: e07c2f66e55d0bfe55b869d5b395914670278be3
+README.zh.md: 81e5300e5bac3d6d54e83ead165598cb2c378a99

+ 2 - 0
packages/client/ui-conversation/README.md

@@ -46,6 +46,8 @@ Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and c
 
 The package occupies the root-scoped `main` key `conversation`, whose wrapper declares the optional-Session `main.conversation` shell. It registers strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
 
+A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent. Sidebar entries retain their own data and execution prerequisites.
+
 View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell.
 
 The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated.

+ 2 - 0
packages/client/ui-conversation/README.zh.md

@@ -46,6 +46,8 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
 
 本包占据 root 作用域 `main` 中的 `conversation` key,其包装层声明 optional-Session `main.conversation` shell。本包注册 strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
 
+blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载。侧栏各入口仍遵循自身的数据与执行环境要求。
+
 View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific 快照。
 
 Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。

+ 8 - 13
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css

@@ -46,10 +46,14 @@
   border-bottom: 0.5px solid var(--dsw-alias-border-l3);
 }
 
-/* Blank hero/settling: keep the strict Session header mounted without taking
-   column space; the root-owned scrollport and composer remain below it. */
-.headerHidden {
-  display: none;
+/* Blank Sessions retain navigation controls without conversation title or tabs. */
+.headerBlank {
+  min-height: 0;
+  border-bottom: none;
+}
+
+.headerBlank .headerCorner {
+  margin-left: auto;
 }
 
 /* macOS hiddenInset titlebar: the title row doubles as the window drag
@@ -67,15 +71,6 @@
   -webkit-app-region: no-drag;
 }
 
-/* Blank state on macOS desktop: the chrome rows are gone but the leading seat
-   (the hidden sidebar's reopen control) stays on screen, so the header keeps
-   its layout without the rule. */
-:global([data-platform='darwin']) .headerHidden {
-  display: block;
-  min-height: 0;
-  border-bottom: none;
-}
-
 .titleRow {
   display: flex;
   align-items: center;

+ 5 - 15
packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx

@@ -1,7 +1,6 @@
 /** Strict per-session header/body content inserted into the resident conversation layout. */
 
 import clsx from 'clsx'
-import { isDarwinDesktop } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {
@@ -55,8 +54,7 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum
 /**
  * Renders Session header chrome above the resident conversation scrollport.
  * @param props - Strict Session store, view ledger, navigation, render, and locale shares.
- * @returns the header: title and tabs when visible, and on macOS desktop the
- *   always-mounted leading seat even while blank-session chrome hides.
+ * @returns Session navigation controls, with title and tabs after conversation starts.
  */
 export function ConversationSessionHeader({
   sessionId, useSession, useSessions, useConversation, useConversationViews, useStore,
@@ -69,16 +67,8 @@ export function ConversationSessionHeader({
   const session = useSession(s => s)
   const conversation = useConversation(s => s)
   const hideChrome = session.blank && conversationPhase(session, conversation) === 'blank'
-  // macOS desktop keeps the leading seat mounted through the blank state (the
-  // hidden sidebar's reopen control lives there), so the header may not leave
-  // the layout or the accessibility tree while its chrome hides.
-  const darwinDesktop = isDarwinDesktop()
-
   return (
-    <header
-      className={clsx(css.header, hideChrome && css.headerHidden)}
-      aria-hidden={(hideChrome && !darwinDesktop) || undefined}
-    >
+    <header className={clsx(css.header, hideChrome && css.headerBlank)}>
       <div className={css.titleRow}>
         <div className={css.headerLeading} data-conversation-header-leading="">
           {renderSlot('conversation.session.header.leading', {})}
@@ -142,11 +132,11 @@ export function ConversationSessionHeader({
             <div className={css.headerUtilities}>
               {renderSlot('conversation.session.header.utilities', {})}
             </div>
-            <div className={css.headerCorner} data-conversation-header-corner="">
-              {renderSlot('conversation.session.header.corner', {})}
-            </div>
           </>
         )}
+        <div className={css.headerCorner} data-conversation-header-corner="">
+          {renderSlot('conversation.session.header.corner', {})}
+        </div>
       </div>
       {!hideChrome && tabs.length > 1 && (
         <div className={css.tabs} role="tablist">

+ 6 - 2
packages/client/ui-conversation/tests/skeleton.client.spec.tsx

@@ -462,7 +462,7 @@ describe('ConversationRoot resident composer', () => {
     expect(seat?.contains(fallback)).toBe(true)
   })
 
-  it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
+  it('hero phase: keeps sidebar controls accessible while hiding conversation chrome', () => {
     const b = mount(
       sessionSnapshotOf({ blank: true }),
       [
@@ -474,7 +474,11 @@ describe('ConversationRoot resident composer', () => {
     const host = b.view.container.querySelector('[data-conversation-scroll]')
     const header = b.view.container.querySelector('header')
     expect(host).not.toBeNull()
-    expect(header?.getAttribute('aria-hidden')).toBe('true')
+    expect(header?.getAttribute('aria-hidden')).not.toBe('true')
+    expect(b.view.getByTestId('view-conversation.session.header.corner')).toBeTruthy()
+    expect(b.view.queryByRole('tablist')).toBeNull()
+    expect(b.slotCalls).not.toContain('conversation.session.header.utilities')
+    expect(b.slotCalls).not.toContain('conversation.session.header.actions')
     expect(b.view.getByText('探索未至之境')).toBeTruthy()
     expect(b.view.getByText('预览版')).toBeTruthy()
     expect(b.view.queryByTestId('view-chat')).toBeNull()

+ 25 - 0
snapshots/web/details-session-lifecycle/blank-session.expected.md

@@ -0,0 +1,25 @@
+# Blank Session workspace sidebar
+
+- No selected Session: expand control absent
+- Selected workspace before first message: expand control visible
+- Files: before-chat.md opens as a Markdown preview
+- Narrow viewport: reopened preview fills the viewport
+- Terminal: writes a file in the selected workspace before any user message or turn
+
+```json
+[
+  {
+    "active": true,
+    "tabs": [
+      {
+        "title": "Files",
+        "selected": false
+      },
+      {
+        "title": "before-chat.md",
+        "selected": true
+      }
+    ]
+  }
+]
+```