# Agent Note: 添加 Workspace 的唯一路径

Status: implemented

[English](2026-07-31-one-route-to-add-a-workspace.md) | 中文

## 问题

两处 Workspace 表层——侧边栏区头的 `+` 与会话主视觉区的 chip——都提供了两条获得 Workspace 的路径：**打开本地文件夹…** 拉起组合的目录流程，**新建工作区** 接收一个名称并创建 `<workspaceRoot>/<name>`。两者重叠：浏览占用者自带 **新建文件夹** 能力，因此「选一个目录」本就覆盖了「建一个目录」。两个入口意味着同一结果有两套词汇、一个自带重名规则的名称对话框，以及一个操作者既看不到也选不了的创建位置。

删掉较弱的那个入口后，侧边栏区头只剩一个动作，于是引出了本决策一并裁定的展示问题：只有一行的浮层应该长什么样。

## 决策

添加 Workspace 只有一条路径：通过组合的目录流程选一个宿主机目录，新建的或已存在的都可以。入口是 `menu.addWorkspace`（「添加工作区…」/ "Add workspace…"）；按名称创建的对话框及其 `create.*`／`menu.createWorkspace`／`workspace.new` 文案全部删除。标签命名的是结果而非机制，因为它现在是通往该结果的唯一一扇门——找「新建」的用户必须能找到它。

**菜单的存在是为了在多个目标之间消歧。** 当只剩添加这一个入口时——仅添加的侧边栏表层，或列表为空的主视觉区——锚点手势*就是*该动作：直接拉起流程，不渲染浮层。只有一行的浮层多花一次点击，却没有任何可选项。这条规则是一个谓词（`addIsTheOnlyEntry`）覆盖两处表层，而不是逐表层特判。

由该规则派生出两条边界，它们同属这个决定：

- **列表为空只有在基线落地后才算最终结果。** `phase` 仍为 `pending` 时，主视觉区保留菜单与加载状态，而不是跳进一个即将到达的 workspace 会使其变得多余的流程。仅添加表层不列任何东西，因此从不等待。
- **目录流程的洞没有占用者时，就没有任何可添加的手段。** 此时侧边栏区头干脆不渲染按钮，而不是留下一个点了没反应的按钮；主视觉区的菜单则继续作为选择器工作，列出已有内容——而当同样没有内容可列时，它什么也不弹：空浮层会宣称一个并不存在的选择。这是 seam 文档化的无流程默认行为走到它的结论：占用者不在，唯一的创建能力也就不在。主视觉区的锚点 chip 归 ui-conversation 所有，因此本包能压掉浮层，却无法隐藏该 chip。

直接拉起的这条路径同样遵守菜单项声明的 busy 规则：某次选取仍在接纳中（`flowBusy`）时，锚点手势会被拦截，效果与禁用该菜单项完全相同，从而避免迟到的结果与第二个流程发生竞态。

`WorkspaceCreateFlow` 现更名为 `WorkspacePickFlow`，其 `createOnly` prop 更名为 `addOnly`；注入的 `createWorkspace` 从 `{ name } | { path }` 收窄为 `{ path }`。

## Wire 与 CLI（命令行界面）表层

`workspace.create` 只接受 `{ path }`；wire schema 与 `WorkspaceApi` 没有 `name` 成员。网关没有 `workspaceRoot` 配置；客户端约定只通过 `WorkspaceCreateInput`、`WorkspaceRuntime.create` 与 `intentName` 提供按路径接纳，`dsh web` 没有 `--workspace-root` flag。`workspace-name-conflict` 仍保留在 wire 上，作为 `workspace.rename` 的标题重名错误。

## 测试

`connectFreshWorkspace`——所有 web e2e 场景启动时都会走的辅助函数——会预先备好 `<root>/workspace`，再经对话框的路径编辑器接纳它，因此产出的会话 cwd 与按名称创建时完全一致，场景 golden 保持有效。选择预先备好而不是在对话框内新建，是为了让该辅助函数在一个场景可能发生的多次连接之间保持幂等（第二次创建同一个文件夹会失败，而创建对话框会在失败时把流程停在原地）。在选择器内新建文件夹——同一条路径的另一半——由 `workspace-management.e2e.ts` 覆盖，它承担针对性覆盖：在对话框自己创建的文件夹上添加两个 workspace、接纳 basename 相同的不同目录并保持彼此独立、在另一个目录上复用已删除的标题、以及浏览对话框的 aria golden。

`smoke-real.e2e.ts` 是唯一启动未打补丁的出厂配置树的场景，其中 `-auto` 行会按宿主机解析；它现在通过 `--config` overlay 钉死 `-browse`，使开发机的显示环境无法决定选择器是否可被驱动。

## 考虑过的替代方案

**保留 `打开本地文件夹…` 作为标签。** 否决：合并后该入口既能打开也能创建，用机制命名会恰好对那些入口被我们删掉的用户隐藏创建这一半。反方理由——「本地」二字有效区分了浏览器所在机器与 harness 所在机器——在下一步就由对话框自身的标题和面包屑回答了。

**保留双入口菜单，让 `新建工作区` 也打开同一个流程。** 否决：同一动作两个标签正是本次改动所消除的混淆，而不是它的缩小版。

**为了与主视觉区菜单保持一致而保留只有一行的浮层。** 否决：不提供选择的浮层是一次浪费的点击，读起来像半成品。这里要一致的是*规则*（有菜单 ⇔ 存在选择），不是控件。

**为将来可能新增的入口（克隆仓库、远程目录）保留菜单壳。** 否决，依据「require a current owner and need」：这样的入口目前并不存在，而等它到来时再恢复菜单，比现在就发一个空壳的改动更小。

**在同一改动中删除 wire 的按名称创建分支。** 否决，因为 UI 决定不依赖后端与 CLI 删除，后两者各自的约定和测试构成一项可独立评审的改动。

**在 e2e scaffold 中经 host 注册 workspace，而不驱动对话框。** 否决：那会让全部 15 个场景与选择器解耦，整条 lane 将无法证明幸存的这条路径能走到可用的 composer。现在每个场景都会走真实对话框来接纳自己的目录；只有「新建文件夹」那一半集中在一个场景里，因为处处重复只会让共享辅助函数失去幂等性，却换不来额外信号。

## 后果

- UI 只在操作者选择的目录下创建 Workspace 文件夹。没有服务端控制的配置约束其位置；需要该约束的部署必须明确加入该约束。
- picker 配置的可达范围决定剩余路径可用的宿主机文件系统；不存在另一个配置好的父目录。
- 挂载 `ui-workspace` 但没有 directory-picker 包的组合无法添加 Workspace，也不渲染按钮。
- 主视觉区的 chip 仍声明 `aria-haspopup="menu"`，而直接拉起的路径实际弹出的是对话框。要让该声明如实反映实际行为，需要把流程的展示方式经 `conversation.hero.workspace` 的 owner 约定上报——决定权在流程，播报权在 chip，两者分属不同的包——因此这被列为一项具名的后续工作，而不是一处无声的不一致。本次新增的侧边栏按钮完全不作任何 popup 声明。
