fix(web): address the review round on the one-route add flow
- lifecycle-chrome's second scaffold staged its workspace under the OUTER scaffold's temp root, coupling two supposedly independent worlds and leaving the aria scrub root wrong; it now uses its own workspaceCwd. - The direct-open path now carries the same `flowBusy` gate that disables the equivalent menu entry, so an occupant re-registering mid-adoption cannot raise a second flow. - A composition with no directory-picker no longer opens a zero-entry popover on the hero anchor: with nothing to pick and nothing to add, the gesture shows nothing. Both behaviors gain a unit test. - Brought three partially superseded Agent Notes current (the native picker, the workspace UI product flow, and the sidebar browsing split), cross-linked to this decision, both languages re-recorded. - Corrected this Note's own Testing section: the shared e2e helper stages and adopts its directory, it does not create one in-dialog — only workspace-management does. Named the client-seam and CLI-README residue in the follow-up TODO alongside the wire branch.
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md
|
||||
2026-07-25-session-list-browsing-and-manual-order.md: 831aa53e532a75392690c330837482bb0f9c32b1
|
||||
2026-07-25-session-list-browsing-and-manual-order.zh.md: 9ad074d59c13585aa4fca46ae4d40e2deb15cde6
|
||||
2026-07-25-session-list-browsing-and-manual-order.md: 5af9cf026c5a0e32837822b3d8cff827c052b07f
|
||||
2026-07-25-session-list-browsing-and-manual-order.zh.md: 2cfa4efe2f0e53b419b264c9af0aff878d321cac
|
||||
+1
-1
@@ -34,7 +34,7 @@ The UI is HTML5 drag on session rows inside a group (workspace grouping only, ou
|
||||
|
||||
### Shell/region split
|
||||
|
||||
ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, New Session, Settings, and one `sidebar.workspaces` hole; the shell↔region contract is two facts, `{ wide, expandSidebar }`. ui-workspace fully owns the browsing region (section header, search, grouped tree and flat list, every workspace dialog, drag) plus its groupBy store; the rail-state search/new-workspace icons belong to the region too and request shell expansion via `expandSidebar()`. The picker splits into the core `WorkspaceCreateFlow` (composed directly inside the region) and the thin `WorkspacePicker` wrapper (still filling ui-conversation's hero slot); the old `sidebar.workspace` picker slot and its declaration-aware deferral are deleted with it.
|
||||
ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, New Session, Settings, and one `sidebar.workspaces` hole; the shell↔region contract is two facts, `{ wide, expandSidebar }`. ui-workspace fully owns the browsing region (section header, search, grouped tree and flat list, every workspace dialog, drag) plus its groupBy store; the rail-state search/add-workspace icons belong to the region too and request shell expansion via `expandSidebar()`. The picker splits into the core `WorkspacePickFlow` (composed directly inside the region; named `WorkspaceCreateFlow` until the [one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)) and the thin `WorkspacePicker` wrapper (still filling ui-conversation's hero slot); the old `sidebar.workspace` picker slot and its declaration-aware deferral are deleted with it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ UI 为组内 session 行的 HTML5 拖拽(仅 workspace 分组、非搜索态;f
|
||||
|
||||
### 壳/区域切分
|
||||
|
||||
ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settings,以及一个 `sidebar.workspaces` 洞;壳与区域的契约只有两个事实 `{ wide, expandSidebar }`。ui-workspace 全权拥有浏览区域(section header、搜索、分组树与平铺、全部 workspace 对话框、拖拽)及其 groupBy store;rail 态的搜索/新建图标也归区域,经 `expandSidebar()` 请求壳展开。picker 拆为核心件 `WorkspaceCreateFlow`(区域内直接组件组合)与薄包装 `WorkspacePicker`(继续填 ui-conversation 的 hero 坑);原 `sidebar.workspace` picker 坑与声明感知延迟注册随之删除。
|
||||
ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settings,以及一个 `sidebar.workspaces` 洞;壳与区域的契约只有两个事实 `{ wide, expandSidebar }`。ui-workspace 全权拥有浏览区域(section header、搜索、分组树与平铺、全部 workspace 对话框、拖拽)及其 groupBy store;rail 态的搜索/添加工作区图标也归区域,经 `expandSidebar()` 请求壳展开。picker 拆为核心件 `WorkspacePickFlow`(区域内直接组件组合;在[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)之前名为 `WorkspaceCreateFlow`)与薄包装 `WorkspacePicker`(继续填 ui-conversation 的 hero 坑);原 `sidebar.workspace` picker 坑与声明感知延迟注册随之删除。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md
|
||||
2026-07-25-workspace-ui-product-flow.md: b8e1ec1efe19127cad8a12405dddeec38a4ff91e
|
||||
2026-07-25-workspace-ui-product-flow.zh.md: b80b75a80671e9aa2ab59ff72c44c18a8ec5c16e
|
||||
2026-07-25-workspace-ui-product-flow.md: 7e3338cfcb04c1588bbc97f6e8aa1cd2dac59141
|
||||
2026-07-25-workspace-ui-product-flow.zh.md: 8c092151f737b6bc10aa8424281eeda359f07c1d
|
||||
@@ -50,9 +50,9 @@ On initial entry, the application waits until both the Workspace and Session bas
|
||||
|
||||
When no Workspace exists, the page creates a frontend Workspace object named `workspace` and a frontend Session that targets it. Neither writes to the Host, and the composer always accepts input; the first send materializes the Workspace, attaches the Session, and sends the message in that order.
|
||||
|
||||
Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's Use an existing folder and Create a new workspace actions immediately create a real Workspace when the user confirms, then retarget the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message.
|
||||
Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message.
|
||||
|
||||
Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow.
|
||||
A new Workspace takes its display name from the directory it was created in, and the Host rejects a title already registered (the UI's own duplicate-name pre-check went with the create-by-name dialog). Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow.
|
||||
|
||||
### First send and recovery
|
||||
|
||||
|
||||
@@ -50,9 +50,9 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预
|
||||
|
||||
完全没有 Workspace 时,页面创建默认名为 `workspace` 的前端 Workspace 对象和指向它的前端 Session。两者不写 Host,composer 始终可输入;首次发送才依次 materialize Workspace、attach Session、发送消息。
|
||||
|
||||
顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的 Use an existing folder 与 Create a new workspace 会在用户确认时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。
|
||||
顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。
|
||||
|
||||
Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认,Host 继续拒绝绕过 UI 或并发产生的同名请求。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。
|
||||
新建 Workspace 的显示名取自其所在目录,Host 拒绝已注册的同名 title(UI 侧的重名预检随按名称创建的对话框一并删除)。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。
|
||||
|
||||
### 首次发送与恢复
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md
|
||||
2026-07-27-native-workspace-directory-picker.md: 847b7a7c7844a47e534183008acfdc32e2e08125
|
||||
2026-07-27-native-workspace-directory-picker.zh.md: e484a2ba5860d43925aba82be31c803716a33bc5
|
||||
2026-07-27-native-workspace-directory-picker.md: 8dd7084eb8ffa417d9366cbc54bcbc59170c64fe
|
||||
2026-07-27-native-workspace-directory-picker.zh.md: 9efc6571207b1f6a6996b9990c2a55afb70872bd
|
||||
@@ -10,7 +10,7 @@ The desktop GUI asks users to type an absolute path when they add an existing wo
|
||||
|
||||
## Decision
|
||||
|
||||
Add a single-folder `host.pickDirectory` RPC and expose it through `WorkspacesService`. The workspace menu presents two flat actions: **Open local folder...** and **Create a new workspace**. Selecting a folder reuses the existing `workspace.create({ path })` flow, selects the returned workspace, and starts a blank session.
|
||||
Add a single-folder `host.pickDirectory` RPC and expose it through `WorkspacesService`. The workspace menu presents the flat **Add workspace...** action (two actions when this was decided — **Open local folder...** beside a create-by-name entry the [one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md) later removed). Selecting a folder reuses the existing `workspace.create({ path })` flow, selects the returned workspace, and starts a blank session.
|
||||
|
||||
The workspace manager must upsert the returned workspace before the selection callback runs. A newly adopted directory therefore renders its basename immediately. Reopening an already registered path preserves its existing workspace title.
|
||||
|
||||
@@ -21,7 +21,7 @@ The workspace manager must upsert the returned workspace before the selection ca
|
||||
- A duplicate path selects the existing workspace.
|
||||
- A different path whose derived title conflicts with another workspace shows a focused error with **Choose again** and **Cancel** actions.
|
||||
- Other picker failures show a compact retryable error.
|
||||
- The existing create-by-name flow remains unchanged.
|
||||
- The create-by-name flow this decision left untouched is gone; picking a directory is now the whole of adding a workspace ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)).
|
||||
|
||||
## Host boundary
|
||||
|
||||
@@ -41,7 +41,7 @@ Platform adapters invoke native tools without a shell:
|
||||
|
||||
## Consequences
|
||||
|
||||
The current GUI opens one local folder through a native picker on macOS, Windows, and Linux. Cancelling changes no state, failures remain retryable, and duplicate paths are idempotent while title conflicts require an explicit new choice. The selected workspace and its displayed name refresh before a new blank session starts. Existing workspace creation by name remains available.
|
||||
The current GUI opens one local folder through a native picker on macOS, Windows, and Linux. Cancelling changes no state, failures remain retryable, and duplicate paths are idempotent while title conflicts require an explicit new choice. The selected workspace and its displayed name refresh before a new blank session starts. This picker is now the only route to a workspace ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)): the operator picks an existing directory, or creates one inside the chooser.
|
||||
|
||||
The added host, runtime, component, and GUI tests cover the native boundary, request trust checks, cancellation and failure handling, existing-path reuse, title conflicts, and the immediate visible-name update. The privileged RPC remains specific to the local desktop carrier; a remote Web directory browser is outside this decision.
|
||||
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
新增一个用于选择单个文件夹的 `host.pickDirectory` RPC,并通过 `WorkspacesService` 暴露该 RPC。工作区菜单提供两个平铺操作:**打开本地文件夹…** 和 **创建新工作区**。选定文件夹后,系统复用现有的 `workspace.create({ path })` 流程,选中返回的工作区,并启动一个空白会话。
|
||||
新增一个用于选择单个文件夹的 `host.pickDirectory` RPC,并通过 `WorkspacesService` 暴露该 RPC。工作区菜单提供平铺操作 **添加工作区…**(本决策做出时是两个操作:**打开本地文件夹…** 与一个按名称创建的入口,后者已被[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)删除)。选定文件夹后,系统复用现有的 `workspace.create({ path })` 流程,选中返回的工作区,并启动一个空白会话。
|
||||
|
||||
工作区管理器必须在选择回调运行前插入或更新返回的工作区。因此,新纳入的目录会立即显示其 basename。再次打开已注册的路径时,则保留该工作区现有的标题。
|
||||
|
||||
@@ -21,7 +21,7 @@ Status: implemented
|
||||
- 路径重复时,选中现有工作区。
|
||||
- 如果路径不同,但其派生标题与另一个工作区冲突,则显示明确指出该冲突的错误提示,其中包含 **重新选择** 和 **取消** 操作。
|
||||
- 选择器的其他故障会显示简洁且可重试的错误提示。
|
||||
- 现有的按名称创建流程保持不变。
|
||||
- 本决策当时未触碰的按名称创建流程现已删除;选择目录就是添加工作区的全部(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md))。
|
||||
|
||||
## 宿主边界
|
||||
|
||||
@@ -41,7 +41,7 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
当前 GUI 可以在 macOS、Windows 和 Linux 上通过原生选择器打开一个本地文件夹。取消操作不会改变任何状态,故障仍可重试;重复路径的处理具有幂等性,标题冲突则要求用户明确重新选择。选中的工作区及其显示名称会在启动新的空白会话前完成刷新。现有的按名称创建工作区功能仍可使用。
|
||||
当前 GUI 可以在 macOS、Windows 和 Linux 上通过原生选择器打开一个本地文件夹。取消操作不会改变任何状态,故障仍可重试;重复路径的处理具有幂等性,标题冲突则要求用户明确重新选择。选中的工作区及其显示名称会在启动新的空白会话前完成刷新。该选择器现已是获得工作区的唯一路径(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)):操作者要么选一个已有目录,要么在选择器内新建一个。
|
||||
|
||||
新增的宿主、运行时、组件和 GUI 测试覆盖原生边界、请求信任校验、取消与故障处理、已有路径复用、标题冲突和可见名称即时更新。该特权 RPC 仍仅面向本地桌面载体;远程 Web 目录浏览器不属于本次决策范围。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/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
|
||||
2026-07-31-one-route-to-add-a-workspace.md: 0d3f4096b11ec2d0bd1339268bfb8567380d3e1a
|
||||
2026-07-31-one-route-to-add-a-workspace.zh.md: b9b25b15680cb54ae980e217e6218a91cd1e366a
|
||||
@@ -19,17 +19,21 @@ Adding a Workspace has one route: pick a host directory through the composed dir
|
||||
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.
|
||||
- **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, and shows nothing when nothing is listed either — an empty popover would claim a choice that does not exist. This is the seam's documented no-flow default reaching its conclusion: with the occupant gone, so is the only creation affordance. The hero's anchor chip belongs to ui-conversation, so this package can suppress the popover but cannot hide the chip.
|
||||
|
||||
The direct-open path carries the busy rule the menu entry states: while a pick is still being adopted (`flowBusy`), the anchor gesture is held exactly as the entry is disabled, so a late outcome cannot race a second flow.
|
||||
|
||||
`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.
|
||||
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. The same is true of the client seam that carried the name to the wire: `WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm, `intentName`'s name branch, and the manager's "name under workspaceRoot" contract. `apps/cli/README.md` and its Chinese counterpart still document `--workspace-root` as creating named Workspaces. The whole set is marked for deletion at the call site in `packages/host/apiproxy/src/api-proxy.ts` and left to a follow-up change: it is backend, client-seam, and CLI surface with its own reviewer and its own test fallout (the api-proxy workspace suite, the runtime 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).
|
||||
`connectFreshWorkspace` — the helper every web e2e scenario boots through — stages `<root>/workspace` and adopts it through the dialog's path editor, so the produced session cwd stays identical to what create-by-name produced and scenario goldens stay valid. Staging rather than creating in-dialog keeps the helper idempotent across the repeated connects a scenario may make (a second create of the same folder fails, and the create dialog holds the flow open on that failure). Creating a folder from inside the chooser — the other half of the same route — is covered by `workspace-management.e2e.ts`, which owns the focused coverage: two workspaces added on folders the dialog creates, a deleted title reused on a different directory, and the browser-dialog aria golden.
|
||||
|
||||
`smoke-real.e2e.ts` is the one scenario booting the unpatched shipped tree, where the `-auto` row resolves per host; it now pins `-browse` through a `--config` overlay so the developer's display environment cannot decide whether the picker is drivable at all.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -43,10 +47,11 @@ The host's `workspace.create` still accepts `{ name }`, and `dsh web --workspace
|
||||
|
||||
**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.
|
||||
**Register the workspace through the host in the e2e scaffold instead of driving the dialog.** Rejected: it would have decoupled all 15 scenarios from the picker, so nothing in the lane would prove the surviving route reaches a live composer. Every scenario now walks the real dialog to adopt its directory; only the create-a-folder half is concentrated in one scenario, because repeating it everywhere makes the shared helper non-idempotent for no extra signal.
|
||||
|
||||
## 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.
|
||||
- The hero chip still announces `aria-haspopup="menu"` while the direct-open path raises a dialog instead. Making that truthful means routing the flow's presentation choice up through the `conversation.hero.workspace` owner contract — the flow owns the decision, the chip owns the announcement, and they sit in different packages — so it is a named follow-up rather than a silent inconsistency. The sidebar button this change added makes no popup claim at all.
|
||||
+9
-4
@@ -19,17 +19,21 @@ Status: implemented
|
||||
由该规则派生出两条边界,它们同属这个决定:
|
||||
|
||||
- **列表为空只有在基线落地后才算最终结果。** `phase` 仍为 `pending` 时,主视觉区保留菜单与加载状态,而不是跳进一个即将到达的 workspace 会使其变得多余的流程。仅添加表层不列任何东西,因此从不等待。
|
||||
- **目录流程的洞没有占用者时,就没有任何可添加的手段。** 此时侧边栏区头干脆不渲染按钮,而不是留下一个点了没反应的按钮;主视觉区的菜单则继续作为选择器工作,列出已有内容。这是 seam 文档化的无流程默认行为走到它的结论:占用者不在,唯一的创建能力也就不在。
|
||||
- **目录流程的洞没有占用者时,就没有任何可添加的手段。** 此时侧边栏区头干脆不渲染按钮,而不是留下一个点了没反应的按钮;主视觉区的菜单则继续作为选择器工作,列出已有内容——而当同样没有内容可列时,它什么也不弹:空浮层会宣称一个并不存在的选择。这是 seam 文档化的无流程默认行为走到它的结论:占用者不在,唯一的创建能力也就不在。主视觉区的锚点 chip 归 ui-conversation 所有,因此本包能压掉浮层,却无法隐藏该 chip。
|
||||
|
||||
直接拉起的这条路径同样遵守菜单项声明的 busy 规则:某次选取仍在接纳中(`flowBusy`)时,锚点手势会被按住,正如该菜单项会被禁用,从而使迟到的结果无法抢开第二个流程。
|
||||
|
||||
`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。
|
||||
Host 侧的 `workspace.create` 仍接受 `{ name }`,`dsh web --workspace-root` 也仍在为它提供目标目录,但已没有任何产品表层会走到它们。把名称送到 wire 的客户端一段同样如此:`WorkspaceCreateInput`、`WorkspacesService.create` 的 `{ name }` 分支、`intentName` 的名称分支,以及 manager 中"workspaceRoot 下的 name"这一契约。`apps/cli/README.md` 及其中文对照本也仍把 `--workspace-root` 记为"创建具名 Workspace"。这一整套都在 `packages/host/apiproxy/src/api-proxy.ts` 的调用点标记为待删除,并留给后续改动:它横跨 backend、客户端 seam 与 CLI 面,有各自的 reviewer 和各自的测试波及面(api-proxy workspace 套件、runtime workspace 套件、配置目录),而本决定中阻塞发布的部分是 UI。
|
||||
|
||||
## Testing
|
||||
|
||||
`connectFreshWorkspace`——所有 web e2e 场景启动时都会走的辅助函数——现在驱动真实对话框:路径编辑器 → **新建文件夹** → **创建** → **打开**。这让产出的路径(`<workspaceCwd>/workspace`)与按名称创建时完全一致,因此场景 golden 保持有效;同时它把幸存的创建路径放到了整条 lane 的每个场景之下,而不只是某一个场景。`workspace-management.e2e.ts` 承担针对性覆盖(在自己创建的文件夹上添加两个 workspace、在另一个目录上复用已删除的标题、浏览对话框的 aria golden)。
|
||||
`connectFreshWorkspace`——所有 web e2e 场景启动时都会走的辅助函数——会预先备好 `<root>/workspace`,再经对话框的路径编辑器接纳它,因此产出的会话 cwd 与按名称创建时完全一致,场景 golden 保持有效。选择预先备好而不是在对话框内新建,是为了让该辅助函数在一个场景可能发生的多次连接之间保持幂等(第二次创建同名文件夹会失败,而创建对话框会在失败时把流程停在原地)。在选择器内新建文件夹——同一条路径的另一半——由 `workspace-management.e2e.ts` 覆盖,它承担针对性覆盖:在对话框自己创建的文件夹上添加两个 workspace、在另一个目录上复用已删除的标题、以及浏览对话框的 aria golden。
|
||||
|
||||
`smoke-real.e2e.ts` 是唯一启动未打补丁的出厂配置树的场景,其中 `-auto` 行会按宿主机解析;它现在通过 `--config` overlay 钉死 `-browse`,使开发机的显示环境无法决定选择器是否可被驱动。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -43,10 +47,11 @@ Host 侧的 `workspace.create` 仍接受 `{ name }`,`dsh web --workspace-root`
|
||||
|
||||
**在同一改动中删除 wire 的按名称创建分支。** 本 PR 否决:那是 backend/CLI 面,reviewer 不同、测试波及面更广,而紧急的决定是 UI。见 residue 一节——它是被标记了,不是被遗忘了。
|
||||
|
||||
**在 e2e scaffold 中旁路种入 workspace,而不驱动对话框。** 否决:那会让 15 个场景彻底与选择器解耦,使产品唯一的创建路径只剩一个场景覆盖。驱动真实对话框每个场景多花几次交互,换来的是处处都有覆盖。
|
||||
**在 e2e scaffold 中经 host 注册 workspace,而不驱动对话框。** 否决:那会让全部 15 个场景与选择器解耦,整条 lane 将无法证明幸存的这条路径能走到可用的 composer。现在每个场景都会走真实对话框来接纳自己的目录;只有"新建文件夹"那一半集中在一个场景里,因为处处重复只会让共享辅助函数失去幂等性,却换不来额外信号。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 从 UI 已无法在操作者选定目录之外创建 Workspace;服务端控制的 `--workspace-root` 目标曾是约束新 workspace 文件夹落点的唯一手段,现在没有替代品。需要该约束的部署必须有意识地重新引入它。
|
||||
- 仅存的这条路径会浏览宿主机文件系统,因此选择器的可达范围现在是整台宿主机,而非一个配置好的父目录。这本就是浏览占用者的契约,本改动使它成为唯一的契约。
|
||||
- 挂载了 `ui-workspace` 但未挂任何 directory-picker 包的组合,已完全无法添加 Workspace;现在它通过不渲染按钮来说明这一点,而不是提供一个按名称创建的兜底。
|
||||
- 主视觉区的 chip 仍声明 `aria-haspopup="menu"`,而直接拉起的路径实际弹出的是对话框。要让它如实,需要把流程的展示方式经 `conversation.hero.workspace` 的 owner 契约上报——决定权在流程,播报权在 chip,两者分属不同的包——因此这被列为一项具名的后续工作,而不是一处无声的不一致。本次新增的侧边栏按钮完全不作任何 popup 声明。
|
||||
@@ -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, scaffold.workspaceCwd)
|
||||
await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd)
|
||||
const input = activePage.locator('textarea').first()
|
||||
await activePage.getByRole('button', { name: 'Commands' }).click()
|
||||
const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
|
||||
|
||||
@@ -114,6 +114,10 @@ export function WorkspacePickFlow({
|
||||
disabled: flowBusy,
|
||||
}))
|
||||
: addEntries
|
||||
// Nothing listed and nothing to add with (a composition that mounts this
|
||||
// package without any directory-picker): an empty popover would claim a
|
||||
// choice that does not exist, so the anchor gesture shows nothing at all.
|
||||
const menuIsEmpty = items.length === 0
|
||||
|
||||
const closeModal = (): void => {
|
||||
setErrorOpen(false)
|
||||
@@ -153,9 +157,11 @@ export function WorkspacePickFlow({
|
||||
// made unnecessary; the add-only surface lists nothing and never waits.
|
||||
const listSettled = addOnly || workspaceSnapshot.phase === 'ready'
|
||||
const addIsTheOnlyEntry = !pinAdd && listSettled && addEntries.length === 1
|
||||
// `flowBusy` gates this exactly as it disables the equivalent menu entry: a
|
||||
// pick still being adopted owns the surface until it settles.
|
||||
useEffect(() => {
|
||||
if (open && addIsTheOnlyEntry) openDirectoryFlow()
|
||||
}, [open, addIsTheOnlyEntry, openDirectoryFlow])
|
||||
if (open && addIsTheOnlyEntry && !flowBusy) openDirectoryFlow()
|
||||
}, [open, addIsTheOnlyEntry, flowBusy, openDirectoryFlow])
|
||||
|
||||
/** Owner side of the flow conversation: adopt keeps the flow open (busy) until the Host answers. */
|
||||
const flowOwner: DirectoryFlowOwnerProps = {
|
||||
@@ -185,7 +191,7 @@ export function WorkspacePickFlow({
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
open={open && !addIsTheOnlyEntry}
|
||||
open={open && !addIsTheOnlyEntry && !menuIsEmpty}
|
||||
anchor={null}
|
||||
items={items}
|
||||
{...pinAdd ? { footer: addEntries } : {}}
|
||||
@@ -196,7 +202,7 @@ export function WorkspacePickFlow({
|
||||
portal
|
||||
getAnchorRect={getAnchorRect}
|
||||
/>
|
||||
{open && !addIsTheOnlyEntry && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">{t('picker.loading')}</div>}
|
||||
{open && !addIsTheOnlyEntry && !menuIsEmpty && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">{t('picker.loading')}</div>}
|
||||
{renderDirectoryFlow(flowOwner)}
|
||||
<Modal
|
||||
open={errorOpen}
|
||||
|
||||
@@ -253,6 +253,34 @@ describe('WorkspacePicker', () => {
|
||||
expect(screen.getByRole('menuitem', { name: '添加工作区…' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows no popover at all when nothing is listed and nothing can be added', () => {
|
||||
// A composition mounting this package without any directory-picker: the
|
||||
// hero anchor has neither a Workspace to pick nor a way to add one, so it
|
||||
// must not claim a choice with an empty menu.
|
||||
const b = mount([], vi.fn(), occupancySource(false))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(screen.queryByTestId('directory-flow')).toBeNull()
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('holds the anchor gesture while an adoption is still settling', async () => {
|
||||
// The auto-open path obeys the same busy rule as the disabled menu entry:
|
||||
// an occupant that re-registers mid-adoption must not raise a second flow.
|
||||
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))
|
||||
chooseAdd()
|
||||
act(() => { b.probe.owner!.onPicked('/tmp/project') })
|
||||
expect(b.probe.owner!.busy).toBe(true)
|
||||
// The list empties under the still-settling adoption (the workspace was
|
||||
// deleted elsewhere), which would otherwise make add the only entry.
|
||||
act(() => { b.rerenderItems([]) })
|
||||
expect(b.createWorkspace).toHaveBeenCalledTimes(1)
|
||||
await act(async () => { resolve(created); await pending })
|
||||
expect(b.probe.owner!.busy).toBe(false)
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
@@ -1594,8 +1594,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// 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.
|
||||
// Delete it with the wire schema's `name` member, this
|
||||
// `defaults.workspaceRoot`, the client seam that carried the name
|
||||
// (`WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm,
|
||||
// `intentName`'s name branch, the manager's "name under workspaceRoot"
|
||||
// contract), and the `dsh web --workspace-root` flag plus its apps/cli
|
||||
// README lines, which exist only to feed it.
|
||||
async create(request) {
|
||||
const { payload } = request
|
||||
let path: string
|
||||
|
||||
Reference in New Issue
Block a user