diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml new file mode 100644 index 0000000000..5dd24aea64 --- /dev/null +++ b/.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 diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md new file mode 100644 index 0000000000..a236be35d1 --- /dev/null +++ b/.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 `/`. 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 (`/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. diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md new file mode 100644 index 0000000000..fdc5596490 --- /dev/null +++ b/.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 的路径:**打开本地文件夹…** 拉起组合的目录流程,**新建工作区** 接收一个名称并创建 `/`。两者重叠:浏览占用者自带 **新建文件夹** 能力,因此"选一个目录"本就覆盖了"建一个目录"。两个入口意味着同一结果有两套词汇、一个自带重名规则的名称对话框,以及一个操作者既看不到也选不了的创建位置。 + +删掉较弱的那个入口后,侧边栏区头只剩一个动作,于是引出了本 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 场景启动时都会走的辅助函数——现在驱动真实对话框:路径编辑器 → **新建文件夹** → **创建** → **打开**。这让产出的路径(`/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;现在它通过不渲染按钮来说明这一点,而不是提供一个按名称创建的兜底。 diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index 89b383da85..4da2769e46 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/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 { +async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise { + 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 () => { diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts index b66277e0b2..7a2a684765 100644 --- a/apps/web/tests/approval-composer.e2e.ts +++ b/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 () => { diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts index 78f27acaca..6a0379de94 100644 --- a/apps/web/tests/code-mode-round.e2e.ts +++ b/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 () => { diff --git a/apps/web/tests/composer-draft-scroll.e2e.ts b/apps/web/tests/composer-draft-scroll.e2e.ts index 34c552401e..e7dc1da238 100644 --- a/apps/web/tests/composer-draft-scroll.e2e.ts +++ b/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) diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts index 66bc9c6900..a806fcaa67 100644 --- a/apps/web/tests/cordis-tool-round.e2e.ts +++ b/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 () => { diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index ce97cfb2a1..cb6c9ba914 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/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 () => { diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 97ffefccf7..3ed79ebc2e 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/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 (/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 (/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') diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 1707a6478f..186c71be11 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/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) } /** diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 8ffeb25ad1..87e19a6915 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/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 /workspace; + // The workspace-aware flow runs sessions in /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') diff --git a/apps/web/tests/pin-browse-picker.overlay.yml b/apps/web/tests/pin-browse-picker.overlay.yml new file mode 100644 index 0000000000..266c35e94b --- /dev/null +++ b/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' diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 28272777fb..e37e3954ab 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/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 () => { diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index ac4be25299..37f6964c73 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/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 () => { diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 9993f18613..4110a9d657 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/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() diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 01544ad71f..cf1121599b 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/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 () => { diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 472efc178f..af487d76ca 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/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 /workspace + // The workspace-aware flow runs sessions in /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. diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index cb252c724b..143bc0d4db 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/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 () => { diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 8bcb8da23a..9300c06b42 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/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') diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index dc4cbca241..7b6432ef87 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/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 diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index 7f62e80503..4aad3112e1 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/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 diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 3cbae0b9ee..6bd369ea2c 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/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 () => { diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index aded1aa406..bdf3eb8dd4 100644 --- a/apps/web/tests/support.ts +++ b/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 { } /** - * 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 - * /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 /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 { +export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise { + 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"]') diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 48483e512d..e18664614a 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/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 /** - * 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 { - 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 { + 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 { + 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 { + 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 => { - 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 => { + 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) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index a21fd21697..c8cc4d5f0f 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/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 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index cc73214a28..4f12f888d5 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and creation flow. +Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and add flow. -The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace create/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. +The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace add/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index c0b6472c7d..11a56de5c1 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个表层使用同一套 Workspace 菜单和创建流程。 +共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个表层使用同一套 Workspace 菜单和添加流程。 -该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 +该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。 Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 97cc2116a4..93d432bd89 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/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({ )} {wide && { actions.setGroupBy(mode) }} t={t} />} - - - - {/* Picker menu + create dialogs (same package — direct composition). */} - + + + )} + {/* Add flow + its error dialog (same package — direct composition). */} + renderSlot('sidebar.workspaces.directoryFlow', owner)} - createOnly + addOnly side="right" onPick={(workspaceId) => { setWsPickerOpen(false) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.module.css b/packages/client/ui-workspace/src/client/WorkspacePicker.module.css index e439d30f27..c99164aacf 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.module.css +++ b/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); } diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 36fa9b0948..64b45944f9 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/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 | undefined /** Selector hook over the workspace list (framework standard hook). */ useWorkspaces: (selector: (state: WorkspaceListState) => S) => S - /** Create or adopt a real Host Workspace. */ - createWorkspace: (input: { name: string } | { path: string }) => Promise - /** 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 + /** Bound occupancy selector hook for this surface's directory-flow hole (empty leaves the surface with no add action). */ useDirectoryFlow: SnapshotSelectorHook /** 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(null) - const [workspaceName, setWorkspaceName] = useState('') - const [creating, setCreating] = useState(false) + const [errorOpen, setErrorOpen] = useState(false) const [modalError, setModalError] = useState(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: , disabled: flowBusy }] - : []), - { id: CREATE_NEW, label: t('menu.createWorkspace'), icon: , 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: , 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: , 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 ( <> - {open && workspaceSnapshot.phase === 'pending' &&
{t('picker.loading')}
} + {open && !addIsTheOnlyEntry && workspaceSnapshot.phase === 'pending' &&
{t('picker.loading')}
} {renderDirectoryFlow(flowOwner)} {t('cancel')} {/* Retrying needs an occupant to serve the flow; without one the * button would open a flow nobody can answer or cancel. */} - + )} > @@ -243,49 +218,6 @@ export function WorkspaceCreateFlow({ : modalError} - - - - - )} - > - { 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 &&
{t('create.pending')}
} - {duplicateWorkspaceName && ( -
{t('conflict.named', { name: normalizedWorkspaceName })}
- )} - {modalError !== null &&
{modalError}
} -
) } @@ -309,7 +241,7 @@ export function WorkspacePicker({ t, }: WorkspacePickerProps) { return ( - Promise - /** Explicitly create or adopt a real Workspace before targeting a Session. */ - createWorkspace: (input: { name: string } | { path: string }) => Promise + /** Adopt a picked host directory as a real Workspace before targeting a Session. */ + createWorkspace: (input: { path: string }) => Promise } /** 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 + /** Adopt a picked host directory as a real Workspace before targeting a Session. */ + createWorkspace: (input: { path: string }) => Promise } /** diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 1ecc244329..af8ccf10b7 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/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', diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 976bff655f..3ac61afdae 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/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' }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index e5c89ce0de..82284c1e47 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/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. + // 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.getByRole('menuitem', { name: '打开本地文件夹…' })).toBeTruthy() - // Toggle: open and close in place. - fireEvent.click(screen.getByRole('button', { name: '创建工作区' })) - expect(screen.queryByRole('menu')).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', () => { diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index b2d1479d17..620451ca77 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/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((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('menuitem', { name: 'Alpha' }).disabled).toBe(true) - expect(screen.getByRole('menuitem', { name: '新建工作区' }).disabled).toBe(true) + expect(screen.getByRole('menuitem', { name: '添加工作区…' }).disabled).toBe(true) act(() => { b.probe.owner!.onPicked('/tmp/project') }) expect(b.probe.owner!.busy).toBe(true) - expect(screen.getByRole('menuitem', { name: '打开本地文件夹…' }).disabled).toBe(true) - expect(screen.getByRole('menuitem', { name: '新建工作区' }).disabled).toBe(true) + expect(screen.getByRole('menuitem', { name: 'Alpha' }).disabled).toBe(true) + expect(screen.getByRole('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('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((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((_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( , @@ -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('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('menuitem', { name: '新建工作区' }).disabled).toBe(false) + expect(screen.getByRole('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('menuitem', { name: '新建工作区' }).disabled).toBe(false) - expect(screen.queryByRole('menuitem', { name: '打开本地文件夹…' })).toBeNull() + expect(screen.getByRole('menuitem', { name: 'Alpha' }).disabled).toBe(false) + expect(screen.queryByRole('menuitem', { name: '添加工作区…' })).toBeNull() }) }) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index b6365a4a52..a6b63f0b87 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/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