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.
This commit is contained in:
+6
@@ -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
|
||||
@@ -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,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;现在它通过不渲染按钮来说明这一点,而不是提供一个按名称创建的兜底。
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
- text: Workspaces
|
||||
- button "Group by":
|
||||
- img
|
||||
- button "Create workspace":
|
||||
- button "Add workspace":
|
||||
- img
|
||||
- button "Search sessions":
|
||||
- img
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
- text: Workspaces
|
||||
- button "Group by":
|
||||
- img
|
||||
- button "Create workspace":
|
||||
- button "Add workspace":
|
||||
- img
|
||||
- button "Search sessions":
|
||||
- img
|
||||
|
||||
@@ -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
@@ -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"]')
|
||||
|
||||
@@ -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,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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user