Browse Source

cleanup(web): give adding a Workspace one route

Both Workspace surfaces offered "Open local folder…" and "Create a new
workspace" for one outcome. The browse occupant already carries its own
New folder affordance, so picking a directory covered creating one; the
name dialog only added a second vocabulary and a create target the
operator could neither see nor choose.

The surviving entry is named after the outcome — "Add workspace…" — and a
menu now appears only where there is something to choose between: with no
Workspace listed (the add-only sidebar header, or an empty hero list) the
anchor gesture raises the directory flow directly instead of a one-row
popover. An empty list counts as final only after the list baseline lands,
and a composition with no directory-flow occupant hides the sidebar button
rather than offering a dead one.

WorkspaceCreateFlow becomes WorkspacePickFlow (createOnly -> addOnly) and
the injected createWorkspace narrows to { path }. The host's
workspace.create({ name }) branch and `dsh web --workspace-root` lost their
last product consumer; both are marked at the call site for a follow-up.
creatixchu 1 month ago
parent
commit
d6231007af
37 changed files with 437 additions and 413 deletions
  1. 6 0
      .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml
  2. 52 0
      .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md
  3. 52 0
      .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md
  4. 10 6
      apps/web/tests/access-confirmation.e2e.ts
  5. 1 1
      apps/web/tests/approval-composer.e2e.ts
  6. 1 1
      apps/web/tests/code-mode-round.e2e.ts
  7. 1 1
      apps/web/tests/composer-draft-scroll.e2e.ts
  8. 1 1
      apps/web/tests/cordis-tool-round.e2e.ts
  9. 1 1
      apps/web/tests/details-session-lifecycle.e2e.ts
  10. 6 6
      apps/web/tests/lifecycle-chrome.e2e.ts
  11. 1 1
      apps/web/tests/live-interactions.e2e.ts
  12. 1 1
      apps/web/tests/navigation-panes.e2e.ts
  13. 11 0
      apps/web/tests/pin-browse-picker.overlay.yml
  14. 1 1
      apps/web/tests/plan-review.e2e.ts
  15. 1 1
      apps/web/tests/question-composer.e2e.ts
  16. 1 1
      apps/web/tests/queue-actions.e2e.ts
  17. 1 1
      apps/web/tests/replay-round-trip.e2e.ts
  18. 1 1
      apps/web/tests/seeded-history.e2e.ts
  19. 1 1
      apps/web/tests/skill-invocation-policy.e2e.ts
  20. 8 3
      apps/web/tests/smoke-real.e2e.ts
  21. 1 1
      apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
  22. 1 1
      apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md
  23. 1 1
      apps/web/tests/steering.e2e.ts
  24. 20 10
      apps/web/tests/support.ts
  25. 59 48
      apps/web/tests/workspace-management.e2e.ts
  26. 2 2
      packages/client/ui-workspace/README.i18n.yaml
  27. 2 2
      packages/client/ui-workspace/README.md
  28. 2 2
      packages/client/ui-workspace/README.zh.md
  29. 31 21
      packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
  30. 2 28
      packages/client/ui-workspace/src/client/WorkspacePicker.module.css
  31. 58 126
      packages/client/ui-workspace/src/client/WorkspacePicker.tsx
  32. 13 10
      packages/client/ui-workspace/src/client/contract/slots.ts
  33. 5 15
      packages/client/ui-workspace/src/client/locales.ts
  34. 2 2
      packages/client/ui-workspace/tests/apply.spec.ts
  35. 15 12
      packages/client/ui-workspace/tests/workspace-browser.spec.tsx
  36. 60 104
      packages/client/ui-workspace/tests/workspace-picker.spec.tsx
  37. 5 0
      packages/host/apiproxy/src/api-proxy.ts

+ 6 - 0
.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/simplification/2026-07-31-one-route-to-add-a-workspace.md
+2026-07-31-one-route-to-add-a-workspace.md: a236be35d1f94af3cb583d542bb827472797ffe4
+2026-07-31-one-route-to-add-a-workspace.zh.md: fdc559649083189bac5e047a68ec3466f691dbf1

+ 52 - 0
.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md

@@ -0,0 +1,52 @@
+# Agent Note: One route to add a Workspace
+
+Status: implemented
+
+English | [中文](2026-07-31-one-route-to-add-a-workspace.zh.md)
+
+## Problem
+
+Both Workspace surfaces — the sidebar region header's `+` and the conversation hero's chip — offered two ways to get a Workspace: **Open local folder…**, which raised the composed directory flow, and **Create a new workspace**, which took a name and created `<workspaceRoot>/<name>`. The two overlapped: the browse occupant carries its own **New folder** affordance, so picking a directory already covered creating one. Two entries meant two vocabularies for one outcome, a name dialog with its own duplicate-name rule, and a create target the operator could neither see nor choose.
+
+Removing the weaker entry leaves the sidebar header with exactly one action, which raised the presentation question this Note also settles: what a popover with a single row should look like.
+
+## Decision
+
+Adding a Workspace has one route: pick a host directory through the composed directory flow, new or existing. `menu.addWorkspace` ("添加工作区…" / "Add workspace…") is the entry; the create-by-name dialog and its `create.*` / `menu.createWorkspace` / `workspace.new` strings are gone. The label names the outcome, not the mechanism, because it is now the only door to that outcome — a user looking for "新建" must find it.
+
+**A menu exists to disambiguate between targets.** When the only entry left is the add action — the add-only sidebar surface, or the hero with an empty list — the anchor gesture *is* that action: the flow opens directly and no popover renders. A one-row popover costs a click and offers nothing to choose between. The rule is one predicate (`addIsTheOnlyEntry`) covering both surfaces rather than a per-surface special case.
+
+Two boundaries fall out of that rule and are part of it:
+
+- **An empty list is only final once the baseline lands.** While `phase` is `pending` the hero keeps its menu and loading status instead of jumping into a flow that the arriving workspaces would have made unnecessary. The add-only surface lists nothing and never waits.
+- **An unoccupied directory-flow hole leaves nothing to add with.** The sidebar header then renders no button at all rather than a dead one; the hero's menu keeps working as a picker over whatever is listed. This is the seam's documented no-flow default reaching its conclusion: with the occupant gone, so is the only creation affordance.
+
+`WorkspaceCreateFlow` is now `WorkspacePickFlow` and its `createOnly` prop is `addOnly`; the injected `createWorkspace` narrows from `{ name } | { path }` to `{ path }`.
+
+## Wire and CLI residue
+
+The host's `workspace.create` still accepts `{ name }`, and `dsh web --workspace-root` still feeds its target directory, but no product surface reaches either any more. Both are marked for deletion at the call site in `packages/host/apiproxy/src/api-proxy.ts` and left to a follow-up change: they are backend and CLI surface with their own reviewer and their own test fallout (the api-proxy workspace suite, the config catalog), and the release-blocking part of this decision is the UI.
+
+## Testing
+
+`connectFreshWorkspace` — the helper every web e2e scenario boots through — now drives the real dialog: path editor → **New folder** → **Create** → **Open**. That keeps the produced path (`<workspaceCwd>/workspace`) identical to what create-by-name produced, so scenario goldens stay valid, and it puts the surviving creation route under every scenario in the lane rather than one. `workspace-management.e2e.ts` owns the focused coverage (adding two workspaces on folders it creates, reusing a deleted title on a different directory, the browser-dialog aria golden).
+
+## Alternatives considered
+
+**Keep `Open local folder…` as the label.** Rejected: after the merge the entry both opens and creates, and naming it after the mechanism hides the creation half from exactly the users whose entry we removed. The counter-argument — "本地" usefully disambiguates the browser's machine from the harness's — is answered one step later by the dialog's own title and breadcrumbs.
+
+**Keep the two-entry menu and make `Create a new workspace` open the same flow.** Rejected: two labels for one action is the confusion we were asked to remove, not a smaller version of it.
+
+**Keep a one-row popover for consistency with the hero's menu.** Rejected: a popover that offers no choice is a wasted click and reads as unfinished. Consistency here is the *rule* (menu ⇔ a choice exists), not the widget.
+
+**Keep the menu shell for entries we might add later (clone a repo, remote directory).** Rejected under "require a current owner and need": no such entry exists, and restoring a menu when one arrives is a smaller change than shipping an empty frame now.
+
+**Delete the wire's create-by-name branch in the same change.** Rejected for this PR: it is backend/CLI surface with a different reviewer and a wider test fallout, and the urgent decision is the UI. See the residue section — it is marked, not forgotten.
+
+**Seed workspaces out-of-band in the e2e scaffold instead of driving the dialog.** Rejected: it would have decoupled 15 scenarios from the picker entirely, leaving the product's only creation route covered by a single scenario. Driving the real dialog costs a few interactions per scenario and buys coverage everywhere.
+
+## Consequences
+
+- Creating a Workspace outside the operator's chosen directory is no longer possible from the UI; the server-controlled `--workspace-root` target was the one way to constrain where new workspace folders land, and nothing replaces it. A deployment that needs that constraint has to re-introduce it deliberately.
+- The one remaining route browses the host filesystem, so the picker's reach is now the whole host rather than one configured parent. That is already the browse occupant's contract; this change makes it the only contract.
+- A composition that mounts `ui-workspace` without any directory-picker package can no longer add a Workspace at all, and now says so by omitting the button instead of offering a create-by-name fallback.

+ 52 - 0
.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md

@@ -0,0 +1,52 @@
+# Agent Note: One route to add a Workspace
+
+Status: implemented
+
+[English](2026-07-31-one-route-to-add-a-workspace.md) | 中文
+
+## Problem
+
+两处 Workspace 表层——侧边栏区头的 `+` 与会话主视觉区的 chip——都提供了两条获得 Workspace 的路径:**打开本地文件夹…** 拉起组合的目录流程,**新建工作区** 接收一个名称并创建 `<workspaceRoot>/<name>`。两者重叠:浏览占用者自带 **新建文件夹** 能力,因此"选一个目录"本就覆盖了"建一个目录"。两个入口意味着同一结果有两套词汇、一个自带重名规则的名称对话框,以及一个操作者既看不到也选不了的创建位置。
+
+删掉较弱的那个入口后,侧边栏区头只剩一个动作,于是引出了本 Note 一并裁定的展示问题:只有一行的浮层应该长什么样。
+
+## Decision
+
+添加 Workspace 只有一条路径:通过组合的目录流程选一个宿主机目录,新建的或已存在的都可以。入口是 `menu.addWorkspace`("添加工作区…" / "Add workspace…");按名称创建的对话框及其 `create.*`/`menu.createWorkspace`/`workspace.new` 文案全部删除。标签命名的是结果而非机制,因为它现在是通往该结果的唯一一扇门——找"新建"的用户必须能找到它。
+
+**菜单的存在是为了在多个目标之间消歧。** 当只剩添加这一个入口时——仅添加的侧边栏表层,或列表为空的主视觉区——锚点手势*就是*该动作:直接拉起流程,不渲染浮层。只有一行的浮层多花一次点击,却没有任何可选项。这条规则是一个谓词(`addIsTheOnlyEntry`)覆盖两处表层,而不是逐表层特判。
+
+由该规则派生出两条边界,它们同属这个决定:
+
+- **列表为空只有在基线落地后才算最终结果。** `phase` 仍为 `pending` 时,主视觉区保留菜单与加载状态,而不是跳进一个即将到达的 workspace 会使其变得多余的流程。仅添加表层不列任何东西,因此从不等待。
+- **目录流程的洞没有占用者时,就没有任何可添加的手段。** 此时侧边栏区头干脆不渲染按钮,而不是留下一个点了没反应的按钮;主视觉区的菜单则继续作为选择器工作,列出已有内容。这是 seam 文档化的无流程默认行为走到它的结论:占用者不在,唯一的创建能力也就不在。
+
+`WorkspaceCreateFlow` 现更名为 `WorkspacePickFlow`,其 `createOnly` prop 更名为 `addOnly`;注入的 `createWorkspace` 从 `{ name } | { path }` 收窄为 `{ path }`。
+
+## Wire and CLI residue
+
+Host 侧的 `workspace.create` 仍接受 `{ name }`,`dsh web --workspace-root` 也仍在为它提供目标目录,但已没有任何产品表层会走到它们。两者都在 `packages/host/apiproxy/src/api-proxy.ts` 的调用点标记为待删除,并留给后续改动:它们属于 backend 与 CLI 面,有各自的 reviewer 和各自的测试波及面(api-proxy workspace 套件、配置目录),而本决定中阻塞发布的部分是 UI。
+
+## Testing
+
+`connectFreshWorkspace`——所有 web e2e 场景启动时都会走的辅助函数——现在驱动真实对话框:路径编辑器 → **新建文件夹** → **创建** → **打开**。这让产出的路径(`<workspaceCwd>/workspace`)与按名称创建时完全一致,因此场景 golden 保持有效;同时它把幸存的创建路径放到了整条 lane 的每个场景之下,而不只是某一个场景。`workspace-management.e2e.ts` 承担针对性覆盖(在自己创建的文件夹上添加两个 workspace、在另一个目录上复用已删除的标题、浏览对话框的 aria golden)。
+
+## Alternatives considered
+
+**保留 `打开本地文件夹…` 作为标签。** 否决:合并后该入口既能打开也能创建,用机制命名会恰好对那些入口被我们删掉的用户隐藏创建这一半。反方理由——"本地"二字有效区分了浏览器所在机器与 harness 所在机器——在下一步就由对话框自身的标题和面包屑回答了。
+
+**保留双入口菜单,让 `新建工作区` 也打开同一个流程。** 否决:同一动作两个标签正是我们被要求消除的混淆,而不是它的缩小版。
+
+**为了与主视觉区菜单保持一致而保留只有一行的浮层。** 否决:不提供选择的浮层是一次浪费的点击,读起来像半成品。这里要一致的是*规则*(有菜单 ⇔ 存在选择),不是控件。
+
+**为将来可能新增的入口(克隆仓库、远程目录)保留菜单壳。** 否决,依据"require a current owner and need":这样的入口目前并不存在,而等它到来时再恢复菜单,比现在就发一个空壳的改动更小。
+
+**在同一改动中删除 wire 的按名称创建分支。** 本 PR 否决:那是 backend/CLI 面,reviewer 不同、测试波及面更广,而紧急的决定是 UI。见 residue 一节——它是被标记了,不是被遗忘了。
+
+**在 e2e scaffold 中旁路种入 workspace,而不驱动对话框。** 否决:那会让 15 个场景彻底与选择器解耦,使产品唯一的创建路径只剩一个场景覆盖。驱动真实对话框每个场景多花几次交互,换来的是处处都有覆盖。
+
+## Consequences
+
+- 从 UI 已无法在操作者选定目录之外创建 Workspace;服务端控制的 `--workspace-root` 目标曾是约束新 workspace 文件夹落点的唯一手段,现在没有替代品。需要该约束的部署必须有意识地重新引入它。
+- 仅存的这条路径会浏览宿主机文件系统,因此选择器的可达范围现在是整台宿主机,而非一个配置好的父目录。这本就是浏览占用者的契约,本改动使它成为唯一的契约。
+- 挂载了 `ui-workspace` 但未挂任何 directory-picker 包的组合,已完全无法添加 Workspace;现在它通过不渲染按钮来说明这一点,而不是提供一个按名称创建的兜底。

+ 10 - 6
apps/web/tests/access-confirmation.e2e.ts

@@ -2,6 +2,7 @@
 // the same locale-aware, in-page risk confirmation. Zero model calls: the
 // scenario boots the shipped Web composition and exercises the real
 // permission projection, client command path, HTTP RPC, and pushed update.
+import { mkdirSync } from 'node:fs'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
 import type { Browser, Page } from 'playwright'
@@ -19,13 +20,16 @@ import { saveFailureShot } from './support.ts'
  * boots; this scenario deliberately keeps zh, so the localized picker
  * copy is the anchor set).
  */
-async function connectFreshWorkspaceZh(page: Page, name = 'workspace'): Promise<void> {
+async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise<void> {
+  mkdirSync(join(root, name), { recursive: true })
   await page.getByRole('button', { name: '选择工作区' }).click()
-  await page.getByRole('menuitem', { name: '新建工作区' }).click()
-  const dialog = page.getByRole('dialog', { name: '新建工作区' })
+  const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
   await dialog.waitFor({ timeout: 10_000 })
-  await dialog.getByLabel('新工作区名称').fill(name)
-  await dialog.getByRole('button', { name: '创建工作区' }).click()
+  await dialog.getByRole('button', { name: '编辑路径' }).click()
+  const pathInput = dialog.getByRole('textbox', { name: '编辑路径' })
+  await pathInput.fill(join(root, name))
+  await pathInput.press('Enter')
+  await dialog.getByRole('button', { name: '打开', exact: true }).click()
   await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]')
     .waitFor({ timeout: 15_000 })
 }
@@ -53,7 +57,7 @@ describe('web e2e: Full access confirmation', () => {
     tripwire = watchConsole(page)
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
-    await connectFreshWorkspaceZh(page)
+    await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
   }, 120_000)
 
   afterAll(async () => {

+ 1 - 1
apps/web/tests/approval-composer.e2e.ts

@@ -61,7 +61,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
     tripwire = watchConsole(page)
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
-    await connectFreshWorkspace(page)
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)
 
   afterAll(async () => {

+ 1 - 1
apps/web/tests/code-mode-round.e2e.ts

@@ -49,7 +49,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     // Fresh world: connect a Workspace so the composer scenarios start live.
-    await connectFreshWorkspace(page)
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)
 
   afterAll(async () => {

+ 1 - 1
apps/web/tests/composer-draft-scroll.e2e.ts

@@ -232,7 +232,7 @@ describe('web e2e: composer draft scrolling', () => {
     tripwire = watchConsole(page)
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
-    await connectFreshWorkspace(page, 'composer-draft-scroll')
+    await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-draft-scroll')
     await page.locator('textarea:enabled').first().fill(DRAFT)
   }, 180_000)
 

+ 1 - 1
apps/web/tests/cordis-tool-round.e2e.ts

@@ -66,7 +66,7 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
     tripwire = watchConsole(page)
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
-    await connectFreshWorkspace(page)
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)
 
   afterAll(async () => {

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

@@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     tripwire = watchConsole(page)
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await appFrame(page).waitFor({ timeout: 30_000 })
-    await connectFreshWorkspace(page)
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)
 
   afterAll(async () => {

+ 6 - 6
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -50,7 +50,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     // Fresh world: connect a Workspace so the composer scenarios start live.
-    await connectFreshWorkspace(page)
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)
 
   afterAll(async () => {
@@ -93,7 +93,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     try {
       await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' })
       await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
-      await connectFreshWorkspace(activePage)
+      await connectFreshWorkspace(activePage, scaffold.workspaceCwd)
       const input = activePage.locator('textarea').first()
       await activePage.getByRole('button', { name: 'Commands' }).click()
       const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
@@ -172,10 +172,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
     await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
     await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
-    // Host: the session's durable header cwd is the workspace flow's
-    // create-by-name target (<workspaceRoot>/workspace, the composer's
-    // default draft name) — the proof the send went through workspace
-    // materialization rather than a bare default-cwd session.
+    // Host: the session's durable header cwd is the folder the workspace
+    // flow created and adopted (<workspaceCwd>/workspace) — the proof the
+    // send went through workspace materialization rather than a bare
+    // default-cwd session.
     const cwds = scaffold.ctx.sessions.list().map(session => session.header.cwd)
     expect(cwds).toEqual([join(scaffold.workspaceCwd, 'workspace')])
     const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')

+ 1 - 1
apps/web/tests/live-interactions.e2e.ts

@@ -96,7 +96,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     // Fresh world: connect a Workspace so the composer scenarios start live.
-    await connectFreshWorkspace(page)
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }
 
   /**

+ 1 - 1
apps/web/tests/navigation-panes.e2e.ts

@@ -43,7 +43,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
 
   beforeAll(async () => {
     scaffold = await launchWebScaffold({})
-    // The workspace-aware flow runs sessions in <workspaceRoot>/workspace;
+    // The workspace-aware flow runs sessions in <workspaceCwd>/workspace;
     // the read targets must live in that session cwd (pre-creation is safe:
     // create-by-name adopts an existing directory).
     const sessionCwd = join(scaffold.workspaceCwd, 'workspace')

+ 11 - 0
apps/web/tests/pin-browse-picker.overlay.yml

@@ -0,0 +1,11 @@
+# Loader overlay for the W5 real-host smoke (`dsh web --config`): pin the
+# in-browser directory picker. The shipped row is `-auto`, which resolves to
+# the native OS chooser on a loopback bind with a local display — an
+# interaction a Playwright page cannot drive, so the resolved backend would
+# otherwise decide whether the smoke passes. The disable+insert pair mirrors
+# apps/web/tests/scaffold.ts.
+- id: directory-picker
+  disabled: true
+- insert:
+    - id: directory-picker-browse
+      name: '@deepseek-ai/dsh-host-directory-picker-browse'

+ 1 - 1
apps/web/tests/plan-review.e2e.ts

@@ -54,7 +54,7 @@ describe('web e2e: plan review takeover round trip', () => {
     tripwire = watchConsole(page)
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
-    await connectFreshWorkspace(page)
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)
 
   afterAll(async () => {

+ 1 - 1
apps/web/tests/question-composer.e2e.ts

@@ -49,7 +49,7 @@ describe('web e2e: resident question composer round trip', () => {
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     // Fresh world: connect a Workspace so the composer scenarios start live.
-    await connectFreshWorkspace(page)
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)
 
   afterAll(async () => {

+ 1 - 1
apps/web/tests/queue-actions.e2e.ts

@@ -68,7 +68,7 @@ describe('web e2e: queue row actions', () => {
     const tripwire = watchConsole(page)
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
-    await connectFreshWorkspace(page)
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
     onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions'))
 
     const input = page.locator('textarea').first()

+ 1 - 1
apps/web/tests/replay-round-trip.e2e.ts

@@ -48,7 +48,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     // Fresh world: connect a Workspace so the composer scenarios start live.
-    await connectFreshWorkspace(page)
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)
 
   afterAll(async () => {

+ 1 - 1
apps/web/tests/seeded-history.e2e.ts

@@ -42,7 +42,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
 
   beforeAll(async () => {
     scaffold = await launchWebScaffold({})
-    // The workspace-aware flow runs sessions in <workspaceRoot>/workspace
+    // The workspace-aware flow runs sessions in <workspaceCwd>/workspace
     // (the composer's default draft name); the read-tool targets must live in
     // that session cwd. Pre-creating the directory is safe: create-by-name
     // adopts an existing directory.

+ 1 - 1
apps/web/tests/skill-invocation-policy.e2e.ts

@@ -84,7 +84,7 @@ describe('web e2e: skill invocation policy through the real host', () => {
     tripwire = watchConsole(page)
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
-    await connectFreshWorkspace(page)
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)
 
   afterAll(async () => {

+ 8 - 3
apps/web/tests/smoke-real.e2e.ts

@@ -20,7 +20,7 @@ import { createServer } from 'node:http'
 import { createRequire } from 'node:module'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { pathToFileURL } from 'node:url'
+import { fileURLToPath, pathToFileURL } from 'node:url'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
@@ -455,7 +455,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
     const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
     child = spawn(
       process.execPath,
-      ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port)],
+      [
+        '--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port),
+        // Pin the in-browser picker: the shipped `-auto` row would resolve to
+        // the native OS chooser on this bind, and no page can drive that.
+        '--config', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
+      ],
       {
         cwd: sessionsDir,
         env: {
@@ -496,7 +501,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
   it('2+3 empty-state first send completes a real model round', async () => {
     onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
     // Fresh world: connect a Workspace so the composer starts live.
-    await connectFreshWorkspace(page)
+    await connectFreshWorkspace(page, sessionsDir)
     const input = page.locator('textarea').first()
     await input.waitFor({ timeout: 10_000 })
     await screen(page, '02-empty-state')

+ 1 - 1
apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md

@@ -7,7 +7,7 @@
 - text: Workspaces
 - button "Group by":
   - img
-- button "Create workspace":
+- button "Add workspace":
   - img
 - button "Search sessions":
   - img

+ 1 - 1
apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md

@@ -7,7 +7,7 @@
 - text: Workspaces
 - button "Group by":
   - img
-- button "Create workspace":
+- button "Add workspace":
   - img
 - button "Search sessions":
   - img

+ 1 - 1
apps/web/tests/steering.e2e.ts

@@ -72,7 +72,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     // Fresh world: connect a Workspace so the composer scenarios start live.
-    await connectFreshWorkspace(page)
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)
 
   afterAll(async () => {

+ 20 - 10
apps/web/tests/support.ts

@@ -1,6 +1,7 @@
 // Shared plumbing for the web smoke tests (dist location, free port, failure shots).
 import { existsSync, mkdirSync } from 'node:fs'
 import { createServer } from 'node:net'
+import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
 
@@ -48,23 +49,32 @@ export function probeFreePort(): Promise<number> {
 }
 
 /**
- * Drive the hero's workspace picker through its create-by-name dialog until
- * the live composer unlocks. A fresh world has no Workspace, so the boot
+ * Drive the hero's workspace picker through the composed directory dialog
+ * until the live composer unlocks. A fresh world has no Workspace, so the boot
  * lands in the locked view state (startup auto-selection has nothing to
  * select); every scenario that types into the composer must connect one
- * first. The default name 'workspace' keeps the session header cwd at
- * <workspaceRoot>/workspace — the materialization proof several scenarios
+ * first. With nothing to list, the chip gesture raises the dialog directly —
+ * adding a workspace is the picker's only entry. The directory is staged here
+ * and adopted through the path editor, which is idempotent across the repeated
+ * connects a scenario may make; creating a folder from inside the dialog (the
+ * product's other half of the same route) is covered by
+ * workspace-management.e2e.ts. The default name 'workspace' keeps the session
+ * header cwd at <root>/workspace, the materialization proof several scenarios
  * assert.
  * @param page - the page under test.
- * @param name - workspace name typed into the create dialog.
+ * @param root - host directory the workspace folder is staged in (the scaffold's `workspaceCwd`).
+ * @param name - folder name staged and adopted as the workspace.
  */
-export async function connectFreshWorkspace(page: Page, name = 'workspace'): Promise<void> {
+export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise<void> {
+  mkdirSync(join(root, name), { recursive: true })
   await page.getByRole('button', { name: 'Choose workspace' }).click()
-  await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
-  const dialog = page.getByRole('dialog', { name: 'Create a new workspace' })
+  const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
   await dialog.waitFor({ timeout: 10_000 })
-  await dialog.getByLabel('New workspace name').fill(name)
-  await dialog.getByRole('button', { name: 'Create workspace' }).click()
+  await dialog.getByRole('button', { name: 'Edit path' }).click()
+  const pathInput = dialog.getByRole('textbox', { name: 'Edit path' })
+  await pathInput.fill(join(root, name))
+  await pathInput.press('Enter')
+  await dialog.getByRole('button', { name: 'Open', exact: true }).click()
   // The pick connected the workspace: the blank session's live composer
   // replaces the locked placeholder and enables.
   await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')

+ 59 - 48
apps/web/tests/workspace-management.e2e.ts

@@ -1,17 +1,18 @@
-// Web e2e scenarios: workspace management — the create-by-name dialog, the
-// rename round trip over the real wire (workspace.rename RPC + durable
-// registry), duplicate-name pre-check, the flat "In one list" view with its
-// persisted group-by preference, the session hover card, and the session
-// archive round trip (row menu → workspace.archiveSession RPC → durable
-// global set → row hidden across reload). Zero model calls:
-// workspace.create/rename/archiveSession are host RPCs with no model
-// involvement, and the one session row the flat/hover/archive scenarios need
-// comes from a seeded fixture (the seeded-history seed reused verbatim — no
-// new recording).
+// Web e2e scenarios: workspace management — adding a workspace through the
+// composed directory dialog (its own New folder affordance is the product's
+// one creation route), the rename round trip over the real wire
+// (workspace.rename RPC + durable registry), duplicate-name pre-check, the
+// flat "In one list" view with its persisted group-by preference, the session
+// hover card, and the session archive round trip (row menu →
+// workspace.archiveSession RPC → durable global set → row hidden across
+// reload). Zero model calls: workspace.create/rename/archiveSession are host
+// RPCs with no model involvement, and the one session row the
+// flat/hover/archive scenarios need comes from a seeded fixture (the
+// seeded-history seed reused verbatim — no new recording).
 import { mkdir, readFile, stat, writeFile } 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 { SessionId } from '@deepseek-ai/dsh-session'
@@ -36,21 +37,47 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
   let tripwire: ReturnType<typeof watchConsole>
 
   /**
-   * Drive the in-app browser to a directory via its path-edit affordance,
-   * confirm it, and wait for the adoption to settle host-side (workspace
-   * registered + the flow's New-Session agent up), so later test steps can't
-   * race the in-flight blank-session attach.
+   * Raise the region header's directory dialog and drive it to a directory via
+   * the path-edit affordance. Adding is the header button's only action, so
+   * the click lands in the dialog with no menu in between.
    */
-  async function openLocalFolder(path: string, options: { waitForAgent?: boolean } = {}): Promise<void> {
-    const agentsBefore = scaffold.ctx.agents.list().length
-    await page.getByRole('button', { name: 'Create workspace' }).click()
-    await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
+  async function browseTo(path: string): Promise<Locator> {
+    await page.getByRole('button', { name: 'Add workspace' }).click()
     const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
     await dialog.waitFor({ timeout: 10_000 })
     await dialog.getByRole('button', { name: 'Edit path' }).click()
     await dialog.getByLabel('Edit path').fill(path)
     await dialog.getByLabel('Edit path').press('Enter')
-    await dialog.getByRole('button', { name: 'Open' }).click()
+    return dialog
+  }
+
+  /**
+   * Create a folder inside `parent` through the dialog and adopt it — the
+   * product's only route to a brand-new workspace directory.
+   */
+  async function addNewFolderWorkspace(parent: string, name: string): Promise<void> {
+    const dialog = await browseTo(parent)
+    await dialog.getByRole('button', { name: 'New folder' }).click()
+    await page.getByLabel('Folder name').fill(name)
+    await page.getByRole('button', { name: 'Create', exact: true }).click()
+    // Creating selects the new folder in the listing; Open adopts it.
+    await dialog.getByRole('button', { name: 'Open', exact: true }).click()
+    await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
+    await expect.poll(
+      () => scaffold.ctx.workspace.resolveByPath(join(parent, name)),
+      { timeout: 10_000 },
+    ).not.toBeUndefined()
+  }
+
+  /**
+   * Adopt an existing directory, waiting for the adoption to settle host-side
+   * (workspace registered + the flow's New-Session agent up), so later test
+   * steps can't race the in-flight blank-session attach.
+   */
+  async function adoptDirectory(path: string, options: { waitForAgent?: boolean } = {}): Promise<void> {
+    const agentsBefore = scaffold.ctx.agents.list().length
+    const dialog = await browseTo(path)
+    await dialog.getByRole('button', { name: 'Open', exact: true }).click()
     await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
     await expect.poll(
       () => scaffold.ctx.workspace.resolveByPath(path),
@@ -86,22 +113,17 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
     await scaffold?.close()
   })
 
-  it('creates two workspaces by name through the region-header dialog', async () => {
+  it('adds two workspaces through the dialog, each on a folder it created', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-create'))
-    const createByName = async (name: string): Promise<void> => {
-      await page.getByRole('button', { name: 'Create workspace' }).click()
-      await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
-      const dialog = page.getByRole('dialog', { name: 'Create a new workspace' })
-      await dialog.waitFor({ timeout: 10_000 })
-      await dialog.getByLabel('New workspace name').fill(name)
-      await dialog.getByRole('button', { name: 'Create workspace' }).click()
-      await expect.poll(() => page.getByRole('dialog', { name: 'Create a new workspace' }).count(), { timeout: 10_000 }).toBe(0)
+    const add = async (name: string): Promise<void> => {
+      await addNewFolderWorkspace(scaffold.workspaceCwd, name)
       // The real workspace materializes in the tree as a group row.
       await expect.poll(() => page.getByText(name, { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
     }
-    await createByName('alpha-ws')
-    await createByName('beta-ws')
-    // Durable on the host: both registered, newest first (create prepends).
+    await add('alpha-ws')
+    await add('beta-ws')
+    // Durable on the host: both registered, newest first (create prepends),
+    // each titled after the folder the dialog made.
     const titles = scaffold.ctx.workspace.list().map(workspace => workspace.title)
     expect(titles.slice(0, 2)).toEqual(['beta-ws', 'alpha-ws'])
     expect(tripwire.pageErrors).toEqual([])
@@ -167,7 +189,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
       collect()
     })
     // Register the scaffold's existing project directory through the real UI.
-    await openLocalFolder(scaffold.workspaceCwd, { waitForAgent: true })
+    await adoptDirectory(scaffold.workspaceCwd, { waitForAgent: true })
     const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
     if (workspace === undefined) throw new Error('GUI did not register the existing project directory')
     await workspace.attachSession(SessionId(SEED_ID))
@@ -225,7 +247,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
     // Re-registering the exact deleted path immediately, without a reload, is
     // a supported reversible flow. It creates a fresh Workspace id without
     // re-adopting the retained Session.
-    await openLocalFolder(scaffold.workspaceCwd)
+    await adoptDirectory(scaffold.workspaceCwd)
     await expect.poll(
       () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
       { timeout: 10_000 },
@@ -295,7 +317,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
       collect()
     })
 
-    await openLocalFolder(oldPath)
+    await adoptDirectory(oldPath)
     await expect.poll(
       () => scaffold.ctx.workspace.resolveByPath(oldPath),
       { timeout: 10_000 },
@@ -311,12 +333,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
       .getByRole('button', { name: 'Delete workspace' }).click()
     await expect.poll(() => scaffold.ctx.workspace.get(oldWorkspace.id), { timeout: 10_000 }).toBeUndefined()
 
-    await page.getByRole('button', { name: 'Create workspace' }).click()
-    await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
-    const create = page.getByRole('dialog', { name: 'Create a new workspace' })
-    await create.getByLabel('New workspace name').fill(title)
-    await create.getByRole('button', { name: 'Create workspace' }).click()
-    await expect.poll(() => create.count(), { timeout: 10_000 }).toBe(0)
+    await addNewFolderWorkspace(scaffold.workspaceCwd, title)
     const fresh = scaffold.ctx.workspace.list().find(workspace => workspace.title === title)
     expect(fresh?.id).toBeDefined()
     expect(fresh?.id).not.toBe(oldWorkspace.id)
@@ -366,13 +383,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
     process.env.HOME = scaffold.workspaceCwd
     process.env.USERPROFILE = scaffold.workspaceCwd
     try {
-      await page.getByRole('button', { name: 'Create workspace' }).click()
-      await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
-      const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
-      await dialog.waitFor({ timeout: 10_000 })
-      await dialog.getByRole('button', { name: 'Edit path' }).click()
-      await dialog.getByLabel('Edit path').fill(staged)
-      await dialog.getByLabel('Edit path').press('Enter')
+      const dialog = await browseTo(staged)
       await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
       const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
       await compareOrRefreshGolden(BROWSER_EXPECTED, snapshot, MODE)

+ 2 - 2
packages/client/ui-workspace/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-workspace/README.md
-README.md: cc73214a281c6950acf8846f0bae3214c8726934
-README.zh.md: c0b6472c7db74dbfd4b0afd19a258e0132a7c534
+README.md: 4f12f888d5a18673601ea5a2579f7dd825ca3f5b
+README.zh.md: 11a56de5c19650e1d570c860d959a83f4a0681af

File diff suppressed because it is too large
+ 2 - 2
packages/client/ui-workspace/README.md


File diff suppressed because it is too large
+ 2 - 2
packages/client/ui-workspace/README.zh.md


+ 31 - 21
packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx

@@ -1,11 +1,13 @@
 /**
  * The workspace/session browsing region filling the sidebar shell's
- * `sidebar.workspaces` hole: section header (title + group-by + new
+ * `sidebar.workspaces` hole: section header (title + group-by + add
  * workspace), search, the grouped tree or flat list, and the workspace
  * dialogs. Wide state renders the full browser; rail state renders the two
- * region icons (search / new workspace), each requesting shell expansion
- * through the owner share. The picker menu and create dialogs live in
- * WorkspacePicker (same package — direct composition, no slot between them).
+ * region icons (search / add workspace), each requesting shell expansion
+ * through the owner share. Adding is the header button's one action, so it
+ * raises the directory flow with no menu in between; the flow and its error
+ * dialog live in WorkspacePicker (same package — direct composition, no slot
+ * between them).
  */
 import { useEffect, useMemo, useRef, useState } from 'react'
 import clsx from 'clsx'
@@ -20,7 +22,7 @@ import type { WorkspaceBrowserProps } from './contract/slots.ts'
 import type { SessionNode } from './tree.ts'
 import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts'
 import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx'
-import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
+import { WorkspacePickFlow } from './WorkspacePicker.tsx'
 import css from './WorkspaceBrowser.module.css'
 
 /**
@@ -358,6 +360,9 @@ export function WorkspaceBrowser({
 }: WorkspaceBrowserProps) {
   const workspaces = useWorkspaces(state => state.items)
   const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
+  // Live occupancy of this surface's directory-flow hole (the same source the
+  // flow reads): a composition without a picking affordance can add nothing.
+  const directoryFlowAvailable = useDirectoryFlow(occupied => occupied)
   const groupBy = useStore(s => s.groupBy)
   // The query outlives the tree and the input (both wide-only) so collapsing
   // does not silently drop an in-progress filter.
@@ -541,21 +546,26 @@ export function WorkspaceBrowser({
           </span>
         )}
         {wide && <GroupByMenu groupBy={groupBy} onPick={(mode) => { actions.setGroupBy(mode) }} t={t} />}
-        <Tooltip label={t('workspace.new')} disabled={wide}>
-          <button
-            ref={wsPlusRef}
-            type="button"
-            className={css.iconButton}
-            aria-label={t('create.confirm')}
-            onClick={() => {
-              setWsPickerOpen(v => !v)
-            }}
-          >
-            <IconProjectAddOutline16 size={wide ? 16 : 18} />
-          </button>
-        </Tooltip>
-        {/* Picker menu + create dialogs (same package — direct composition). */}
-        <WorkspaceCreateFlow
+        {/* Adding is the button's one action, so a composition with no
+            picking affordance has nothing to offer here: the region hides the
+            button rather than leaving a dead one in the header. */}
+        {directoryFlowAvailable && (
+          <Tooltip label={t('workspace.add')} disabled={wide}>
+            <button
+              ref={wsPlusRef}
+              type="button"
+              className={css.iconButton}
+              aria-label={t('workspace.add')}
+              onClick={() => {
+                setWsPickerOpen(v => !v)
+              }}
+            >
+              <IconProjectAddOutline16 size={wide ? 16 : 18} />
+            </button>
+          </Tooltip>
+        )}
+        {/* Add flow + its error dialog (same package — direct composition). */}
+        <WorkspacePickFlow
           t={t}
           open={wsPickerOpen}
           anchorRef={wsPlusRef}
@@ -563,7 +573,7 @@ export function WorkspaceBrowser({
           createWorkspace={createWorkspace}
           useDirectoryFlow={useDirectoryFlow}
           renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)}
-          createOnly
+          addOnly
           side="right"
           onPick={(workspaceId) => {
             setWsPickerOpen(false)

+ 2 - 28
packages/client/ui-workspace/src/client/WorkspacePicker.module.css

@@ -1,35 +1,10 @@
-/* Modal form styles mirror the empty state's path/create modals (same figma
- * dialog family: field h44, r22, hairline border, pad 14/7) so the two
- * entries stay visually identical. */
-.modalInput {
-  box-sizing: border-box;
-  width: 100%;
-  height: 44px;
-  padding: 7px 14px;
-  border: 1px solid var(--dsw-alias-border-l2);
-  border-radius: 22px;
-  outline: none;
-  background: transparent;
-  font-size: 14px;
-  font-weight: 400;
-  line-height: 22px;
-  color: var(--dsw-alias-label-primary);
-}
-
-.modalInput::placeholder {
-  color: var(--dsw-alias-label-caption);
-}
-
-.modalInput:disabled {
-  color: var(--dsw-alias-label-dimmed);
-}
-
+/* The adoption error dialog's footer and message styles; the dialog itself is
+ * the shared Modal (same figma dialog family as the browser's own dialogs). */
 .modalAction {
   min-width: 72px;
 }
 
 .modalError,
-.modalStatus,
 .menuStatus {
   margin-top: 8px;
   font-size: 12px;
@@ -40,7 +15,6 @@
   color: var(--dsw-alias-state-error-primary);
 }
 
-.modalStatus,
 .menuStatus {
   color: var(--dsw-alias-label-secondary);
 }

+ 58 - 126
packages/client/ui-workspace/src/client/WorkspacePicker.tsx

@@ -1,13 +1,15 @@
 /**
- * Workspace pick/create flow. WorkspaceCreateFlow is the reusable core
- * (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same
- * package) and wrapped by WorkspacePicker for the conversation empty-state
- * slot registration. Directory picking itself lives in the composed flow
- * package's slot occupant (see the contract module doc): this core only
- * opens the flow, adopts the picked path, and owns the error surface.
+ * Workspace pick/add flow. WorkspacePickFlow is the reusable core (menu +
+ * path error dialog) consumed directly by WorkspaceBrowser (same package) and
+ * wrapped by WorkspacePicker for the conversation empty-state slot
+ * registration. Directory picking itself lives in the composed flow package's
+ * slot occupant (see the contract module doc): this core only opens the flow,
+ * adopts the picked path, and owns the error surface. Adding a workspace has
+ * exactly one route — pick a host directory, new or existing — because the
+ * occupant's own create-folder affordance already covers creating one.
  */
 import type { ReactNode, RefObject } from 'react'
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
 import {
   Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -19,13 +21,10 @@ import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
 import css from './WorkspacePicker.module.css'
 
-const OPEN_LOCAL_FOLDER = '::open-local-folder'
-const CREATE_NEW = '::create-new'
-
-type ModalKind = 'create' | 'folder-error' | null
+const ADD_WORKSPACE = '::add-workspace'
 
 /** Core flow props: the owner supplies popover control and pick semantics. */
-export interface WorkspaceCreateFlowProps {
+export interface WorkspacePickFlowProps {
   /** The standard locale seat, forwarded by whichever slot entry hosts the flow. */
   t: WorkspacePickerProps['t']
   /** Popover visibility (anchor button toggle state, owner-local). */
@@ -34,9 +33,9 @@ export interface WorkspaceCreateFlowProps {
   anchorRef?: RefObject<HTMLElement | null> | undefined
   /** Selector hook over the workspace list (framework standard hook). */
   useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
-  /** Create or adopt a real Host Workspace. */
-  createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
-  /** Bound occupancy selector hook for this surface's directory-flow hole (empty hides the local-folder entry). */
+  /** Adopt a picked host directory as a real Workspace. */
+  createWorkspace: (input: { path: string }) => Promise<WorkspaceView>
+  /** Bound occupancy selector hook for this surface's directory-flow hole (empty leaves the surface with no add action). */
   useDirectoryFlow: SnapshotSelectorHook<boolean>
   /** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */
   renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode
@@ -44,8 +43,8 @@ export interface WorkspaceCreateFlowProps {
   onPick: (workspaceId: WorkspaceId) => void
   /** Close the popover (outside click / Escape / post-pick). */
   onClose: () => void
-  /** Only show create actions (open folder / create new), hide existing workspaces. */
-  createOnly?: boolean
+  /** Only offer the add action, hide existing workspaces. */
+  addOnly?: boolean
   /** Menu opening direction relative to the anchor. */
   side?: 'bottom' | 'top' | 'right'
   /** Currently active workspace (trailing check in the picker list). */
@@ -53,11 +52,11 @@ export interface WorkspaceCreateFlowProps {
 }
 
 /**
- * Render the pick menu plus the two create dialogs.
+ * Render the pick menu plus the adoption error dialog.
  * @param props - owner-controlled flow props.
  * @returns menu + dialog elements.
  */
-export function WorkspaceCreateFlow({
+export function WorkspacePickFlow({
   t,
   open,
   anchorRef,
@@ -67,32 +66,26 @@ export function WorkspaceCreateFlow({
   renderDirectoryFlow,
   onPick,
   onClose,
-  createOnly = false,
+  addOnly = false,
   side = 'bottom',
   selectedId,
-}: WorkspaceCreateFlowProps) {
+}: WorkspacePickFlowProps) {
   const workspaceSnapshot = useWorkspaces(state => state)
   const workspaces = workspaceSnapshot.items
   const getAnchorRect = useCallback(
     () => anchorRef?.current?.getBoundingClientRect() ?? null,
     [anchorRef],
   )
-  const [modalKind, setModalKind] = useState<ModalKind>(null)
-  const [workspaceName, setWorkspaceName] = useState('')
-  const [creating, setCreating] = useState(false)
+  const [errorOpen, setErrorOpen] = useState(false)
   const [modalError, setModalError] = useState<string | null>(null)
   const [flowOpen, setFlowOpen] = useState(false)
   const [pickingFolder, setPickingFolder] = useState(false)
   const [folderConflict, setFolderConflict] = useState(false)
-  const composingRef = useRef(false)
   // One picking interaction at a time: while the flow is open (native chooser
   // pending, browse dialog up) or its pick is being adopted, every other
   // menu action stays disabled — a late outcome must not race a concurrent
-  // selection or creation.
+  // selection or adoption.
   const flowBusy = flowOpen || pickingFolder
-  const normalizedWorkspaceName = workspaceName.trim()
-  const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
-    && workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
 
   // The occupied hole gates the picking affordance: with no composed flow the
   // entry simply is not there (the seam's documented no-flow default). The
@@ -107,27 +100,23 @@ export function WorkspaceCreateFlow({
   useEffect(() => {
     if (flowOpen && !flowAvailable) setFlowOpen(false)
   }, [flowOpen, flowAvailable])
-  const createEntries: MenuEntry[] = [
-    ...(flowAvailable
-      ? [{ id: OPEN_LOCAL_FOLDER, label: t('menu.openFolder'), icon: <IconFolderClose16 size={16} />, disabled: flowBusy }]
-      : []),
-    { id: CREATE_NEW, label: t('menu.createWorkspace'), icon: <IconPlusOutline16 size={16} />, disabled: flowBusy },
-  ]
-  // With workspaces listed, the create actions pin below the scroll region
-  // (divider + always visible); otherwise they ARE the menu.
-  const pinCreate = !createOnly && workspaces.length > 0
-  const items: MenuEntry[] = pinCreate
+  const addEntries: MenuEntry[] = flowAvailable
+    ? [{ id: ADD_WORKSPACE, label: t('menu.addWorkspace'), icon: <IconPlusOutline16 size={16} />, disabled: flowBusy }]
+    : []
+  // With workspaces listed, the add action pins below the scroll region
+  // (divider + always visible); otherwise it IS the menu.
+  const pinAdd = !addOnly && workspaces.length > 0
+  const items: MenuEntry[] = pinAdd
     ? workspaces.map(workspace => ({
       id: workspace.workspaceId,
       label: workspace.title,
       icon: <IconFolderClose16 size={16} />,
       disabled: flowBusy,
     }))
-    : createEntries
+    : addEntries
 
   const closeModal = (): void => {
-    if (creating) return
-    setModalKind(null)
+    setErrorOpen(false)
     setModalError(null)
   }
 
@@ -143,16 +132,30 @@ export function WorkspaceCreateFlow({
       )
       setModalError(reason instanceof Error ? reason.message : String(reason))
       setFlowOpen(false)
-      setModalKind('folder-error')
+      setErrorOpen(true)
     })
 
-  const openLocalFolder = (): void => {
+  const openDirectoryFlow = useCallback((): void => {
     onClose()
-    setModalKind(null)
+    setErrorOpen(false)
     setModalError(null)
     setFolderConflict(false)
     setFlowOpen(true)
-  }
+  }, [onClose])
+
+  // A menu exists to disambiguate between targets. With no workspaces listed
+  // and the add action the only entry left, the anchor gesture IS that action:
+  // a one-row popover would cost a click and offer nothing to choose between.
+  // The owner's open request is consumed the same way selecting the entry
+  // would consume it (close the popover, raise the flow). An empty list is
+  // only final once the baseline lands — until then the menu stays up with its
+  // loading status instead of jumping into a flow the arriving list would have
+  // made unnecessary; the add-only surface lists nothing and never waits.
+  const listSettled = addOnly || workspaceSnapshot.phase === 'ready'
+  const addIsTheOnlyEntry = !pinAdd && listSettled && addEntries.length === 1
+  useEffect(() => {
+    if (open && addIsTheOnlyEntry) openDirectoryFlow()
+  }, [open, addIsTheOnlyEntry, openDirectoryFlow])
 
   /** Owner side of the flow conversation: adopt keeps the flow open (busy) until the Host answers. */
   const flowOwner: DirectoryFlowOwnerProps = {
@@ -167,53 +170,25 @@ export function WorkspaceCreateFlow({
       setFlowOpen(false)
       setFolderConflict(false)
       setModalError(message)
-      setModalKind('folder-error')
+      setErrorOpen(true)
     },
   }
 
   const handleSelect = (id: string): void => {
-    if (id === OPEN_LOCAL_FOLDER) {
-      openLocalFolder()
-      return
-    }
-    if (id === CREATE_NEW) {
-      onClose()
-      setWorkspaceName('')
-      setModalError(null)
-      setModalKind('create')
+    if (id === ADD_WORKSPACE) {
+      openDirectoryFlow()
       return
     }
     onPick(id as WorkspaceId)
   }
 
-  const create = (input: { name: string } | { path: string }): void => {
-    if (creating) return
-    setCreating(true)
-    setModalError(null)
-    void createWorkspace(input).then((workspace) => {
-      setCreating(false)
-      setModalKind(null)
-      onPick(workspace.workspaceId)
-    }).catch((reason: unknown) => {
-      const message = reason instanceof Error ? reason.message : String(reason)
-      setModalError(`Workspace creation failed: ${message}`)
-      setCreating(false)
-    })
-  }
-
-  const confirmCreate = (): void => {
-    if (normalizedWorkspaceName !== '' && !duplicateWorkspaceName) {
-      create({ name: normalizedWorkspaceName })
-    }
-  }
-
   return (
     <>
       <Menu
-        open={open}
+        open={open && !addIsTheOnlyEntry}
         anchor={null}
         items={items}
-        {...pinCreate ? { footer: createEntries } : {}}
+        {...pinAdd ? { footer: addEntries } : {}}
         selectedId={selectedId}
         onSelect={handleSelect}
         onClose={onClose}
@@ -221,10 +196,10 @@ export function WorkspaceCreateFlow({
         portal
         getAnchorRect={getAnchorRect}
       />
-      {open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">{t('picker.loading')}</div>}
+      {open && !addIsTheOnlyEntry && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">{t('picker.loading')}</div>}
       {renderDirectoryFlow(flowOwner)}
       <Modal
-        open={modalKind === 'folder-error'}
+        open={errorOpen}
         onClose={closeModal}
         closeLabel={t('close')}
         title={folderConflict ? t('conflict.title') : t('folderError.title')}
@@ -233,7 +208,7 @@ export function WorkspaceCreateFlow({
             <Button variant="outline" className={css.modalAction} onClick={closeModal}>{t('cancel')}</Button>
             {/* Retrying needs an occupant to serve the flow; without one the
               * button would open a flow nobody can answer or cancel. */}
-            <Button variant="primary" className={css.modalAction} disabled={!flowAvailable} onClick={openLocalFolder}>{t('folderError.retry')}</Button>
+            <Button variant="primary" className={css.modalAction} disabled={!flowAvailable} onClick={openDirectoryFlow}>{t('folderError.retry')}</Button>
           </>
         )}
       >
@@ -243,49 +218,6 @@ export function WorkspaceCreateFlow({
             : modalError}
         </div>
       </Modal>
-      <Modal
-        open={modalKind === 'create'}
-        onClose={closeModal}
-        closeLabel={t('close')}
-        title={t('menu.createWorkspace')}
-        description={t('create.desc')}
-        footer={(
-          <>
-            <Button variant="outline" className={css.modalAction} disabled={creating} onClick={closeModal}>{t('cancel')}</Button>
-            <Button
-              variant="primary"
-              className={css.modalAction}
-              disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName}
-              onClick={confirmCreate}
-            >
-              {t('create.confirm')}
-            </Button>
-          </>
-        )}
-      >
-        <input
-          className={css.modalInput}
-          value={workspaceName}
-          placeholder={t('field.workspaceName')}
-          aria-label={t('create.name.aria')}
-          autoFocus
-          disabled={creating}
-          onChange={(event) => { setWorkspaceName(event.target.value); setModalError(null) }}
-          onCompositionStart={() => { composingRef.current = true }}
-          onCompositionEnd={() => { composingRef.current = false }}
-          onKeyDown={(event) => {
-            if (event.key === 'Enter' && !composingRef.current) {
-              event.preventDefault()
-              confirmCreate()
-            }
-          }}
-        />
-        {creating && <div className={css.modalStatus} role="status">{t('create.pending')}</div>}
-        {duplicateWorkspaceName && (
-          <div className={css.modalError} role="alert">{t('conflict.named', { name: normalizedWorkspaceName })}</div>
-        )}
-        {modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
-      </Modal>
     </>
   )
 }
@@ -309,7 +241,7 @@ export function WorkspacePicker({
   t,
 }: WorkspacePickerProps) {
   return (
-    <WorkspaceCreateFlow
+    <WorkspacePickFlow
       t={t}
       open={open}
       anchorRef={anchorRef}

+ 13 - 10
packages/client/ui-workspace/src/client/contract/slots.ts

@@ -5,16 +5,19 @@
  *   the whole browsing region (section header, search, grouped/flat session
  *   list, workspace dialogs). It registers this package's viewing store and
  *   consumes the shell's two-fact owner share (wide / expandSidebar).
- * - WorkspacePicker fills the conversation empty-state hole (menu +
- *   create dialogs shared with the browser).
+ * - WorkspacePicker fills the conversation empty-state hole (menu + error
+ *   dialog shared with the browser).
  *
  * Each registration also declares one **directory-flow hole** (`single`
  * kind): the slot a composed picker package's client half fills with its
  * picking interaction — a renderless native-chooser driver or an in-app
- * browsing dialog. ui-workspace owns the trigger (the "Open local folder…"
- * menu entry, shown only while the hole is occupied) and the adoption
+ * browsing dialog. ui-workspace owns the trigger (the "Add workspace…"
+ * entry, present only while the hole is occupied) and the adoption
  * semantics (`createWorkspace({ path })`, the conflict/error dialog, Choose
- * again); the occupant owns everything between `open` and the picked path.
+ * again); the occupant owns everything between `open` and the picked path,
+ * including creating a new directory to hand back. That occupant-owned
+ * creation is why adding a workspace has a single route: an unoccupied hole
+ * leaves the surface with no add affordance at all.
  * Two holes exist because the two menu surfaces are independent slot entries
  * and a hole has exactly one declaring entry — they carry the same owner
  * contract and the same occupant.
@@ -65,7 +68,7 @@ export type DirectoryFlowSlotName =
  * Directory-picking share both trigger surfaces consume. Occupancy rides the
  * inject face's reserved `hooks` compartment: the renderer binds the source
  * into the `useDirectoryFlow` selector hook, so an empty hole hides the
- * "Open local folder…" entry reactively and the surface withdraws an open
+ * "Add workspace…" entry reactively and the surface withdraws an open
  * flow whose occupant unloaded mid-interaction (nobody is left to cancel).
  */
 export type DirectoryPickingInjected = {
@@ -125,8 +128,8 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
    * the Host response/changed frame; failures leave the order unchanged.
    */
   insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise<void>
-  /** Explicitly create or adopt a real Workspace before targeting a Session. */
-  createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
+  /** Adopt a picked host directory as a real Workspace before targeting a Session. */
+  createWorkspace: (input: { path: string }) => Promise<WorkspaceView>
 }
 
 /** Full browser props: shell owner share + viewing store + injected actions + the locale seat. */
@@ -144,8 +147,8 @@ export type WorkspaceBrowserProps =
  * supplies the implicit index signature required by the registry.
  */
 export type WorkspacePickerInjected = DirectoryPickingInjected & {
-  /** Explicitly create or adopt a real Workspace before targeting a Session. */
-  createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
+  /** Adopt a picked host directory as a real Workspace before targeting a Session. */
+  createWorkspace: (input: { path: string }) => Promise<WorkspaceView>
 }
 
 /**

+ 5 - 15
packages/client/ui-workspace/src/client/locales.ts

@@ -1,6 +1,6 @@
 /**
  * `workspace` namespace dictionaries: the browsing region (section header,
- * search, tree rows, dialogs) and the pick/create flow. Runtime failure
+ * search, tree rows, dialogs) and the pick/add flow. Runtime failure
  * messages (wire error strings) pass through untranslated by policy.
  */
 
@@ -15,7 +15,7 @@ export const zh = {
   'groupBy.flat': '单列表',
   'empty.none': '暂无会话',
   'empty.noMatches': '无匹配结果',
-  'workspace.new': '新建工作区',
+  'workspace.add': '添加工作区',
   'search.sessions.aria': '搜索会话',
   'search.placeholder': '搜索名称、关键词…',
   'search.clear': '清除搜索',
@@ -24,18 +24,13 @@ export const zh = {
   'search.unavailable': '内容搜索暂不可用,仅显示名称匹配。',
   'search.noMatches': '无匹配会话',
   'search.hasMore': '仅显示前 {n} 条结果,请缩小搜索范围。',
-  'menu.openFolder': '打开本地文件夹…',
-  'menu.createWorkspace': '新建工作区',
+  'menu.addWorkspace': '添加工作区…',
   'picker.loading': '正在加载工作区…',
   'conflict.title': '已存在同名工作区',
   'conflict.hint': '请选择其他名称的文件夹。',
   'conflict.named': '已存在名为“{name}”的工作区。',
   'folderError.title': '无法打开文件夹',
   'folderError.retry': '重新选择',
-  'create.confirm': '创建工作区',
-  'create.desc': '该名称将同时用于工作区及其新文件夹。',
-  'create.name.aria': '新工作区名称',
-  'create.pending': '正在创建工作区…',
   'rename': '重命名',
   'rename.workspace.title': '重命名工作区',
   'rename.session.title': '重命名会话',
@@ -78,7 +73,7 @@ export const en = {
   'groupBy.flat': 'In one list',
   'empty.none': 'No sessions yet',
   'empty.noMatches': 'No matches',
-  'workspace.new': 'New Workspace',
+  'workspace.add': 'Add workspace',
   'search.sessions.aria': 'Search sessions',
   'search.placeholder': 'Search name, keywords...',
   'search.clear': 'Clear search',
@@ -87,18 +82,13 @@ export const en = {
   'search.unavailable': 'Content search is temporarily unavailable. Showing name matches.',
   'search.noMatches': 'No matching sessions',
   'search.hasMore': 'Showing the first {n} results. Narrow your search.',
-  'menu.openFolder': 'Open local folder…',
-  'menu.createWorkspace': 'Create a new workspace',
+  'menu.addWorkspace': 'Add workspace…',
   'picker.loading': 'Loading workspaces…',
   'conflict.title': 'A workspace with this name already exists',
   'conflict.hint': 'Choose a folder with a different name.',
   'conflict.named': 'A workspace named “{name}” already exists.',
   'folderError.title': 'Couldn’t open folder',
   'folderError.retry': 'Choose again',
-  'create.confirm': 'Create workspace',
-  'create.desc': 'The name is used for both the workspace and its new folder.',
-  'create.name.aria': 'New workspace name',
-  'create.pending': 'Creating workspace…',
   'rename': 'Rename',
   'rename.workspace.title': 'Rename workspace',
   'rename.session.title': 'Rename session',

+ 2 - 2
packages/client/ui-workspace/tests/apply.spec.ts

@@ -102,8 +102,8 @@ describe('ui-workspace apply', () => {
     expect(b.rename).toHaveBeenCalledWith('ws', 'renamed')
     await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never)
     expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2')
-    await browser.createWorkspace({ name: 'project' })
-    expect(b.create).toHaveBeenCalledWith({ name: 'project' })
+    await browser.createWorkspace({ path: '/tmp/browser-project' })
+    expect(b.create).toHaveBeenCalledWith({ path: '/tmp/browser-project' })
 
     const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
     await picker.createWorkspace({ path: '/tmp/project' })

+ 15 - 12
packages/client/ui-workspace/tests/workspace-browser.spec.tsx

@@ -500,23 +500,26 @@ describe('WorkspaceBrowser', () => {
     }
   })
 
-  it('rail create-workspace toggles the create-only picker in place, without expanding', () => {
+  it('rail add-workspace raises the directory flow in place, with no menu and no expansion', () => {
     const expandSidebar = vi.fn()
     mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
-    fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
+    fireEvent.click(screen.getByRole('button', { name: '添加工作区' }))
     expect(expandSidebar).not.toHaveBeenCalled()
-    // createOnly: existing workspaces are not listed, only the create actions.
-    expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull()
-    expect(screen.getByRole('menuitem', { name: '打开本地文件夹…' })).toBeTruthy()
-    // Toggle: open and close in place.
-    fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
+    // Adding is the header's only action, so the gesture IS that action: no
+    // one-row popover, and existing workspaces stay in the tree below.
     expect(screen.queryByRole('menu')).toBeNull()
+    expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull()
+    expect(screen.getByTestId('directory-flow')).toBeTruthy()
+  })
 
-    // Escape closes the picker through its own onClose.
-    fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
-    expect(screen.getByRole('menu')).toBeTruthy()
-    fireEvent.keyDown(document, { key: 'Escape' })
-    expect(screen.queryByRole('menu')).toBeNull()
+  it('hides the add button when no directory-flow occupant is composed', () => {
+    mount({
+      useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
+      useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => false, subscribe: () => () => {} }),
+    })
+    // Nothing to add with, so the header offers no dead button.
+    expect(screen.queryByRole('button', { name: '添加工作区' })).toBeNull()
+    expect(screen.getByText('alpha')).toBeTruthy()
   })
 
   it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => {

+ 60 - 104
packages/client/ui-workspace/tests/workspace-picker.spec.tsx

@@ -110,8 +110,8 @@ function mount(
   }
 }
 
-function chooseItem(name: '打开本地文件夹…' | '新建工作区'): void {
-  fireEvent.click(screen.getByRole('menuitem', { name }))
+function chooseAdd(): void {
+  fireEvent.click(screen.getByRole('menuitem', { name: '添加工作区…' }))
 }
 
 describe('WorkspacePicker', () => {
@@ -121,24 +121,12 @@ describe('WorkspacePicker', () => {
     expect(b.onPick).toHaveBeenCalledWith(wid('alpha'))
   })
 
-  it('creates a real Workspace from a name and focuses its frontend Session target', async () => {
-    const created = workspace('new', 'New')
-    const createWorkspace = vi.fn(async () => created)
-    const b = mount([], createWorkspace)
-    chooseItem('新建工作区')
-    const input = screen.getByLabelText('新工作区名称')
-    fireEvent.change(input, { target: { value: 'project-one' } })
-    fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
-    expect(createWorkspace).toHaveBeenCalledWith({ name: 'project-one' })
-    await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
-  })
-
   it('opens the composed directory flow, adopts its picked path, and selects the returned Workspace', async () => {
     const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' }
     const createWorkspace = vi.fn(async () => created)
-    const b = mount([], createWorkspace)
+    const b = mount([workspace('alpha', 'Alpha')], createWorkspace)
     expect(screen.queryByTestId('directory-flow')).toBeNull()
-    chooseItem('打开本地文件夹…')
+    chooseAdd()
     expect(b.onClose).toHaveBeenCalled()
     expect(screen.getByTestId('directory-flow')).toBeTruthy()
     await act(async () => { b.probe.owner!.onPicked('/tmp/project') })
@@ -148,9 +136,19 @@ describe('WorkspacePicker', () => {
     expect(screen.queryByTestId('directory-flow')).toBeNull()
   })
 
-  it('treats flow cancellation as a silent no-op', () => {
+  it('raises the flow straight from the anchor gesture when adding is the only entry', () => {
+    // Nothing to list and one action left: a one-row menu would offer no
+    // choice, so the owner's open request lands in the flow itself.
     const b = mount([])
-    chooseItem('打开本地文件夹…')
+    expect(screen.queryByRole('menu')).toBeNull()
+    expect(screen.queryByRole('menuitem', { name: '添加工作区…' })).toBeNull()
+    expect(b.onClose).toHaveBeenCalled()
+    expect(screen.getByTestId('directory-flow')).toBeTruthy()
+  })
+
+  it('treats flow cancellation as a silent no-op', () => {
+    const b = mount([workspace('alpha', 'Alpha')])
+    chooseAdd()
     act(() => { b.probe.owner!.onCancel() })
     expect(screen.queryByTestId('directory-flow')).toBeNull()
     expect(b.createWorkspace).not.toHaveBeenCalled()
@@ -164,8 +162,8 @@ describe('WorkspacePicker', () => {
         code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' },
       })
     })
-    const b = mount([], createWorkspace)
-    chooseItem('打开本地文件夹…')
+    const b = mount([workspace('alpha', 'Alpha')], createWorkspace)
+    chooseAdd()
     await act(async () => { b.probe.owner!.onPicked('/one/project') })
     await waitFor(() => {
       expect(screen.getByRole('dialog', { name: '已存在同名工作区' })).toBeTruthy()
@@ -178,103 +176,57 @@ describe('WorkspacePicker', () => {
     expect(b.onPick).not.toHaveBeenCalled()
   })
 
+  it('reports a non-Error adoption failure in the folder-error surface', async () => {
+    const b = mount([workspace('alpha', 'Alpha')], vi.fn(async () => { throw 'permission denied' }))
+    chooseAdd()
+    await act(async () => { b.probe.owner!.onPicked('/one/project') })
+    await waitFor(() => {
+      expect(screen.getByRole('dialog', { name: '无法打开文件夹' })).toBeTruthy()
+    })
+    expect(screen.getByRole('alert').textContent).toBe('permission denied')
+    expect(b.onPick).not.toHaveBeenCalled()
+  })
+
   it('disables every menu action from flow open through adoption, and reports busy to the flow', async () => {
     let resolve!: (workspace: WorkspaceView) => void
     const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
     const created = workspace('adopted')
     const b = mount([workspace('alpha', 'Alpha')], vi.fn(() => pending))
-    chooseItem('打开本地文件夹…')
+    chooseAdd()
     // The flow is open but nothing is picked yet: a chooser pending on the
     // host display must already block concurrent workspace actions.
     expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Alpha' }).disabled).toBe(true)
-    expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '新建工作区' }).disabled).toBe(true)
+    expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '添加工作区…' }).disabled).toBe(true)
     act(() => { b.probe.owner!.onPicked('/tmp/project') })
     expect(b.probe.owner!.busy).toBe(true)
-    expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '打开本地文件夹…' }).disabled).toBe(true)
-    expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '新建工作区' }).disabled).toBe(true)
+    expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Alpha' }).disabled).toBe(true)
+    expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '添加工作区…' }).disabled).toBe(true)
     await act(async () => { resolve(created); await pending })
     expect(b.probe.owner!.busy).toBe(false)
   })
 
   it('shows the flow-reported failure in the folder-error surface', () => {
-    const b = mount([])
-    chooseItem('打开本地文件夹…')
+    const b = mount([workspace('alpha', 'Alpha')])
+    chooseAdd()
     act(() => { b.probe.owner!.onError('no chooser installed') })
     expect(screen.getByRole('alert').textContent).toBe('no chooser installed')
     expect(screen.queryByTestId('directory-flow')).toBeNull()
     expect(b.createWorkspace).not.toHaveBeenCalled()
   })
 
-  it('closes a creation modal when the user cancels', () => {
-    mount([])
-    chooseItem('新建工作区')
+  it('closes the folder-error surface when the user cancels', () => {
+    const b = mount([workspace('alpha', 'Alpha')])
+    chooseAdd()
+    act(() => { b.probe.owner!.onError('no chooser installed') })
     fireEvent.click(screen.getByRole('button', { name: '取消' }))
     expect(screen.queryByRole('dialog')).toBeNull()
   })
 
-  it('blocks a create-new name already present in the Workspace list', () => {
-    const b = mount([workspace('alpha', 'Alpha')])
-    chooseItem('新建工作区')
-    fireEvent.change(screen.getByLabelText('新工作区名称'), { target: { value: ' Alpha ' } })
-    expect(screen.getByRole('alert').textContent).toBe('已存在名为“Alpha”的工作区。')
-    expect(screen.getByRole<HTMLButtonElement>('button', { name: '创建工作区' }).disabled).toBe(true)
-    fireEvent.keyDown(screen.getByLabelText('新工作区名称'), { key: 'Enter' })
-    expect(b.createWorkspace).not.toHaveBeenCalled()
-  })
-
-  it('does not flash a duplicate alert when the successful create frame arrives before its unary response', async () => {
-    let resolve!: (workspace: WorkspaceView) => void
-    const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
-    const created = workspace('fresh', 'same-name')
-    const b = mount([], vi.fn(() => pending))
-    chooseItem('新建工作区')
-    fireEvent.change(screen.getByLabelText('新工作区名称'), { target: { value: 'same-name' } })
-    fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
-
-    b.rerenderItems([created])
-    expect(screen.getByRole('status').textContent).toBe('正在创建工作区…')
-    expect(screen.queryByRole('alert')).toBeNull()
-    await act(async () => { resolve(created); await pending })
-    expect(b.onPick).toHaveBeenCalledWith(created.workspaceId)
-  })
-
-  it('exposes creation phase and error text while retaining the modal for retry', async () => {
-    let reject!: (reason: unknown) => void
-    const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
-    const createWorkspace = vi.fn(() => pending)
-    const b = mount([], createWorkspace)
-    chooseItem('新建工作区')
-    const input = screen.getByLabelText('新工作区名称')
-    fireEvent.keyDown(input, { key: 'ArrowRight' })
-    fireEvent.change(input, { target: { value: 'broken' } })
-    fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
-    expect(screen.getByRole('status').textContent).toBe('正在创建工作区…')
-    fireEvent.keyDown(input, { key: 'Enter' })
-    expect(createWorkspace).toHaveBeenCalledTimes(1)
-    fireEvent.keyDown(document, { key: 'Escape' })
-    expect(screen.getByRole('dialog')).toBeTruthy()
-    await act(async () => { reject(new Error('disk unavailable')); await pending.catch(() => {}) })
-    expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: disk unavailable')
-    expect(b.view.getByRole('dialog')).toBeTruthy()
-  })
-
-  it('reports non-Error creation failures', async () => {
-    const b = mount([], vi.fn(async () => { throw 'permission denied' }))
-    chooseItem('新建工作区')
-    // The name field starts empty (no prefill); a name is required to submit.
-    fireEvent.change(screen.getByLabelText('新工作区名称'), { target: { value: 'broken' } })
-    fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
-    await waitFor(() => {
-      expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')
-    })
-    expect(b.onPick).not.toHaveBeenCalled()
-  })
-
   it('waits to show its menu until an optional anchor is available', () => {
     const { renderSlot } = flowProbe()
     render(
       <WorkspacePicker
-        open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
+        open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([workspace('alpha', 'Alpha')]))}
         onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
         useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
       />,
@@ -282,7 +234,7 @@ describe('WorkspacePicker', () => {
     expect(screen.queryByRole('menu')).toBeNull()
   })
 
-  it('shows list loading through a stable status surface', () => {
+  it('keeps the menu up while the list baseline is still in flight', () => {
     const state: WorkspaceListState = {
       ...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false,
     }
@@ -294,26 +246,30 @@ describe('WorkspacePicker', () => {
         useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
       />,
     )
+    // An empty list is not final yet: jumping into the directory flow here
+    // would pre-empt the workspaces about to arrive.
     expect(screen.getByRole('status').textContent).toBe('正在加载工作区…')
+    expect(screen.queryByTestId('directory-flow')).toBeNull()
+    expect(screen.getByRole('menuitem', { name: '添加工作区…' })).toBeTruthy()
   })
 
-  it('hides the folder entry while the directory-flow hole is empty', () => {
-    mount([], vi.fn(), occupancySource(false))
-    expect(screen.getByRole('menuitem', { name: '新建工作区' })).toBeTruthy()
-    expect(screen.queryByRole('menuitem', { name: '打开本地文件夹…' })).toBeNull()
+  it('hides the add entry while the directory-flow hole is empty', () => {
+    mount([workspace('alpha', 'Alpha')], vi.fn(), occupancySource(false))
+    expect(screen.getByRole('menuitem', { name: 'Alpha' })).toBeTruthy()
+    expect(screen.queryByRole('menuitem', { name: '添加工作区…' })).toBeNull()
   })
 
-  it('shows the folder entry when a flow package activates after the first paint', () => {
-    const b = mount([], vi.fn(), occupancySource(false))
-    expect(screen.queryByRole('menuitem', { name: '打开本地文件夹…' })).toBeNull()
+  it('shows the add entry when a flow package activates after the first paint', () => {
+    const b = mount([workspace('alpha', 'Alpha')], vi.fn(), occupancySource(false))
+    expect(screen.queryByRole('menuitem', { name: '添加工作区…' })).toBeNull()
     // Registration changes flow through the subscription, no re-render needed.
     act(() => { b.occupancy.flip(true) })
-    expect(screen.getByRole('menuitem', { name: '打开本地文件夹…' })).toBeTruthy()
+    expect(screen.getByRole('menuitem', { name: '添加工作区…' })).toBeTruthy()
   })
 
   it('keeps Choose again inert while the flow occupant is gone, and snaps back a flow opened over an empty hole', async () => {
-    const b = mount([], vi.fn(async () => { throw new Error('adoption failed') }))
-    chooseItem('打开本地文件夹…')
+    const b = mount([workspace('alpha', 'Alpha')], vi.fn(async () => { throw new Error('adoption failed') }))
+    chooseAdd()
     await act(async () => { b.probe.owner!.onPicked('/one/project') })
     await waitFor(() => { expect(screen.getByRole('dialog', { name: '无法打开文件夹' })).toBeTruthy() })
     // The occupant unloads while the error dialog is up: retrying would open
@@ -322,18 +278,18 @@ describe('WorkspacePicker', () => {
     expect(screen.getByRole<HTMLButtonElement>('button', { name: '重新选择' }).disabled).toBe(true)
     // Cancel stays the way out, and the menu actions are usable again.
     fireEvent.click(screen.getByRole('button', { name: '取消' }))
-    expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '新建工作区' }).disabled).toBe(false)
+    expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Alpha' }).disabled).toBe(false)
   })
 
   it('withdraws an open flow when its occupant unloads, re-enabling the menu actions', () => {
-    const b = mount([])
-    chooseItem('打开本地文件夹…')
+    const b = mount([workspace('alpha', 'Alpha')])
+    chooseAdd()
     expect(screen.getByTestId('directory-flow')).toBeTruthy()
     // The flow plugin unloads mid-interaction (HMR): nobody is left to
     // cancel, so the owner withdraws and the actions come back.
     act(() => { b.occupancy.flip(false) })
     expect(b.probe.owner!.open).toBe(false)
-    expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '新建工作区' }).disabled).toBe(false)
-    expect(screen.queryByRole('menuitem', { name: '打开本地文件夹…' })).toBeNull()
+    expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Alpha' }).disabled).toBe(false)
+    expect(screen.queryByRole('menuitem', { name: '添加工作区…' })).toBeNull()
   })
 })

+ 5 - 0
packages/host/apiproxy/src/api-proxy.ts

@@ -1591,6 +1591,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
       // Exactly one of path/name arrives (schema refine). Existing-folder
       // adoption reuses its canonical path; create-by-name rejects a name
       // already present in the registry.
+      // TODO: the create-by-name branch lost its last product consumer when
+      // the Web picker collapsed onto the directory flow
+      // (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md).
+      // Delete it with the wire schema's `name` member, this `defaults.workspaceRoot`,
+      // and the `dsh web --workspace-root` flag that exists only to feed it.
       async create(request) {
         const { payload } = request
         let path: string

Some files were not shown because too many files changed in this diff