DSH / Atlas
2026-07-31implementedsimplification

One route to add a Workspace

添加 Workspace 的唯一路径

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.

English

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 decision 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, 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 surface

workspace.create accepts only { path }; the wire schema and WorkspaceApi have no name member. The gateway has no workspaceRoot config, the client contract exposes only path adoption through WorkspaceCreateInput, WorkspaceRuntime.create, and intentName, and dsh web has no --workspace-root flag. workspace-name-conflict remains on the wire as workspace.rename's duplicate-title error.

Testing

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, distinct same-basename directories adopted independently, 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

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 this change removes, 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 because the UI decision did not depend on the backend and CLI deletion, whose separate contracts and tests formed an independently reviewable change.

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

  • The UI creates Workspace folders only under a directory the operator chooses. No server-controlled configuration constrains that location; a deployment that needs such a constraint must add it deliberately.
  • The picker's configured reach defines the host filesystem available to the remaining route; there is no separate configured parent.
  • A composition that mounts ui-workspace without a directory-picker package cannot add a Workspace and omits the button.
  • 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.

中文

问题

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

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

决策

添加 Workspace 只有一条路径:通过组合的目录流程选一个宿主机目录,新建的或已存在的都可以。入口是 menu.addWorkspace(「添加工作区…」/ "Add workspace…");按名称创建的对话框及其 create.*menu.createWorkspaceworkspace.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 配置;客户端约定只通过 WorkspaceCreateInputWorkspaceRuntime.createintentName 提供按路径接纳,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 声明。