refactor(workspace): initialize the default workspace on startup

This commit is contained in:
_Kerman committed 2026-09-20 17:19:54 +08:00
1 parent 43372b28ce
commit 4d2e420ef8
64 files changed
+509 -832

No files matched your search

@@ -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/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
2026-07-25-web-client-session-scope-and-provide-channel.md: f1e83e8b38561d5edd3bf6a212aa2bd4ac5028f8
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 6eea0af064caf976774ae2bb2992fd6651daf695
2026-07-25-web-client-session-scope-and-provide-channel.md: 76561bc0e330b3e3a26607569ae327217d0a0b67
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: d5d92b2ca53ba78929dd8591a6ded7028e4fed24
@@ -26,8 +26,8 @@ The reference-owned lifetime and Provider targeting now follow [Client Session r
Host-side `session.create(workspaceId)` produces Session + Agent + cwd in one piece (an atomic bundle, never split); the client side is the mirror of that birth — the instant a session row enters the list mirror, the client mints its Agent scope (actx + provide + the full input surface mounted):
- Session identity is the host's true form from birth: the sessionId arrives via the `session.create` response / the `host/session-added` frame, and every client-side address (the scope tag, slot store keys, RPC addressing) uses that same id.
- A manual Workspace pick or successful [first-use default preparation](../feature/2026-09-20-default-workspace.md) establishes cwd before the client calls `session.create({workspaceId})` and receives the complete entity.
- "New Session with no workspace picked" has no Session or scope entity. The composer retains a local text draft; Session-scoped slash commands become available after connection.
- A manual Workspace pick or successful [first-use startup initialization](../feature/2026-09-20-default-workspace.md) establishes cwd before the client calls `session.create({workspaceId})` and receives the complete entity.
- "New Session with no workspace picked" is a **pure view state** (a navigation position) corresponding to no session/scope entity; until the pick, the composer is locked whole (no slash, no plain text).
- A "blank session" is an ordinary materialized session with no turn yet; to Agent-scope plugins on the host (goal/plan/skill/…) it remains an ordinary Session, so slash/plan are naturally live.
### Agent scope: the actx is the sole session carrier in the client-side cordis world
@@ -95,7 +95,7 @@ Slot scope is the closed set `root | session-maybe | session`:
- `session-maybe` inherits the nearest `SessionProvider` binding with ADOPTION identity: an incarnation born Session-less keeps its React instance when that Provider receives its first binding, then remounts when the Provider switches generation or returns to absence. Component-local per-Session state clears when the Provider switches generation. Across a switch, only persisted Store values survive generation retirement; binding-owned sources survive only when another reference keeps that generation alive. With no binding, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. Provider-roster changes rematerialize the mounted binding without changing its identity, while the per-entry adoption bookkeeping lives in the renderer's `SessionMaybeEntry`.
- `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store.
`conversation` is the resident `session-maybe` shell under its owning `SessionProvider`: `ConversationRoot`, HeroShell, the Workspace picker, the scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-Session → blank-Session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same Session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: before a Session exists, its injected editor retains the first-use text draft; after connection, the same InputBar instance receives the retained Session's input. The remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip.
`conversation` is the resident `session-maybe` shell under its owning `SessionProvider`: `ConversationRoot`, HeroShell, the Workspace picker, the scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-Session → blank-Session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same Session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no Session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a binding appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip.
Blank Sessions retain the header's leading and corner slots so navigation controls, including the right-sidebar opener, are available before the first message. Title, actions, utilities, and View tabs remain hidden in the blank phase. The header still requires a selected Session; the Files and Terminal entries use that Session's workspace and execution services without requiring a recorded Turn.
@@ -136,5 +136,6 @@ Blank Sessions retain the header's leading and corner slots so navigation contro
- Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter.
- The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly.
- Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reuse acquires the persisted writer, and the ordinary ceiling rides same-Workspace reuse. The accepted/running display memory that guards the conversion is Client-local.
- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. Session-scoped input remains unavailable until a Session exists; the [resident conversation shell](../../../../packages/client/ui-conversation/README.md) owns the local first-use draft during that interval.
- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the [resident conversation shell](../../../../packages/client/ui-conversation/README.md) routes activation to the Workspace picker during that interval.
- Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host `selectModel` trio is ready-made, its client consumer not yet built).
@@ -26,8 +26,8 @@ web client 只有一张全局会话面:slot 全部从根上下文渲染,插
host 侧 `session.create(workspaceId)` 一体产出 Session + Agent + cwd(作为不可拆分的原子整体);client 侧就是这次出生的镜像——会话行进入 list mirror 的瞬间,client 为它铸 Agent scope(actx + provide + 输入面全套挂上):
- 会话身份自出生即为 host 真身:sessionId 由 `session.create` 响应 / `host/session-added` 帧带来,client 侧一切寻址(scope tag、slot store 键、RPC 地址)用的都是同一个 id。
- 手动选择 Workspace 或成功[准备首次使用默认工作区](../feature/2026-09-20-default-workspace.zh.md)先确定 cwd,client 随后调用 `session.create({workspaceId})` 并取得完整实体。
- 「New Session 且未选 workspace」没有 Session 或 scope 实体。Composer 保留本地文本草稿;连接后才提供 Session scope 的 slash 命令。
- 手动选定 Workspace 或完成[首次使用的启动初始化](../feature/2026-09-20-default-workspace.zh.md) 后,cwd 已确定,client 再调 `session.create({workspaceId})`,拿到完整实体。
- 「New Session 且未选 workspace」是**纯视图态**(一个导航位置),不对应任何会话/scope 实体;选定之前 composer 整体锁死(无 slash、无纯文本)。
- 「空会话」是尚无轮次的普通实体化会话;对 host 上的 Agent-scope 插件(goal/plan/skill(技能)/…)而言,它仍是普通 Session,因此 slash/plan 自然可用。
### Agent scope:actx 是 client 侧 cordis 世界的唯一会话载体
@@ -95,7 +95,7 @@ slot scope 是闭集 `root | session-maybe | session`:
- `session-maybe` 以**收养(adoption)身份语义**继承最近 `SessionProvider` 的 binding:空态出生的化身在该 Provider 第一次收到 binding 时保持 React 实例,此后 Provider 切换 generation 或回到空态时重挂。Provider 切换 generation 时,组件本地的逐 Session 状态会清零。切换过程中,只有持久化 Store 值能活过 generation 退休;只有另一份 reference 保活该 generation 时,binding 自有 source 才能保留。无 binding 时,`sessionId`、`useSession`/`useInput` 的结果与 `inputActions` 均可缺省。Provider roster 变化会重新物化已挂载 binding,但不改变其 identity;逐 entry 的收养记账住在 renderer 的 `SessionMaybeEntry`。
- `session` 保证 `sessionId`、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换会话会重建该 entry 及其会话 store。
`conversation` 是其 owner `SessionProvider` 下的 `session-maybe` 常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 Session → blank Session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 Session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:Session 存在前,注入的编辑器保存首次使用文本草稿;连接后,同一 InputBar 实例接收已持有 Session 的输入。其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。
`conversation` 是其 owner `SessionProvider` 下的 `session-maybe` 常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 Session → blank Session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 Session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 Session 时,其 machine faces 和消息动作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。binding 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。
blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入口等导航控件在首条消息之前即可使用。标题、actions、utilities 和 View tabs 在 blank phase 中继续隐藏。header 仍要求已选中的 Session;Files 与 Terminal 入口使用该 Session 的工作区和执行服务,无需已有 Turn 记录。
@@ -136,5 +136,6 @@ blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入
- 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。
- client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。
- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),复用时获取持久化写锁,常规上限靠同 Workspace 复用。转正所依赖的受理/运行展示记忆仅保留在客户端。
- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。Session 存在前,其 scope 输入仍不可用;这段时间内,[常驻会话壳](../../../../packages/client/ui-conversation/README.zh.md)负责本地首次使用草稿。
- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face;这段时间内,[常驻会话壳](../../../../packages/client/ui-conversation/README.zh.md)会把激活操作转到 Workspace picker。
- 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,其 client 消费方尚未构建)。
@@ -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-09-20-default-workspace.md
2026-09-20-default-workspace.md: 376b2eaf7ac26c13e7483838700c88151b44135d
2026-09-20-default-workspace.zh.md: e16c5d47c6e1bceef293089edf882162812dfa1b
2026-09-20-default-workspace.md: 1674079403b17fb1bd984052a85975609179cad0
2026-09-20-default-workspace.zh.md: 81a7c96efa3d8b8d4673b684b59babe339706f29
@@ -10,17 +10,17 @@ A new installation requires a directory choice before the user can send a first
## Decision
The conversation shell retains an unsent local text draft without creating a Session. Its first send asks the Host to prepare a default Workspace, then connects a real Session and submits through the ordinary composer pipeline. The first-use draft awaits staged mode selection before automatic submission. This partially supersedes the locked no-Session composer in [Session scope and provisioning](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md); that note still owns blank-Session reuse and provisioning rationale.
Startup waits for complete Workspace and Session baselines. When both lists are empty, the Client asks the Host to prepare the default Workspace, then creates or reuses and selects its blank Session. Input stays unavailable until a Session exists and uses the ordinary composer pipeline thereafter. [Session scope and provisioning](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md) owns blank-Session reuse and provisioning rationale.
The [Workspace registry](../../../../packages/workspace/workspace/README.md#first-use-workspace) owns eligibility and directory preparation. All live Sessions, persisted headers, and archived identities count, including entries absent from the sidebar. The operation shares the registry mutation queue and rechecks Session history after directory preparation because Sessions can start independently.
The Client resolves the initial directory name and title from its language when sending. The Host controller resolves the Documents location; the registry receives a directory resolver with no locale dependency. The resolver runs inside the mutation queue only for eligible creation, so repeated requests reuse the durable Workspace without another OS lookup. A durable Workspace id records successful initialization independently of that name. Renaming, changing language, restarting, or deleting the registration cannot initialize another default. The marker commits with the registration, so a failed registration can retry. Directory contents remain subject to the existing [metadata-only deletion policy](2026-07-27-workspace-registration-deletion.md).
The Client resolves the initial directory name and title from its language at startup. The Host controller resolves the Documents location; the registry receives a directory resolver with no locale dependency. The resolver runs inside the mutation queue only for eligible creation, so repeated requests reuse the durable Workspace without another OS lookup. A durable Workspace id records successful initialization independently of that name. Renaming, changing language, restarting, or deleting the registration cannot initialize another default. The marker commits with the registration, so a failed registration can retry. Directory contents remain subject to the existing [metadata-only deletion policy](2026-07-27-workspace-registration-deletion.md).
Creation failure preserves the draft and offers the existing folder picker. A successful registration survives Session creation or prompt failure. Picking a folder after failure transfers the text without sending it; a later send remains the user's action.
Ineligible first use returns no Workspace without a failure dialog. Preparation failure offers the existing folder picker; startup does not retry on later list notifications. A successful registration survives Session creation or prompt failure. Selecting either the default or a manually picked Workspace never submits a message.
## Alternatives considered
- Creating on application startup would write directories for users who never send a message.
- Creating only when sending avoids unused directories but requires a separate local draft, transfer into a Session, and coordination of the first submission. Startup creation accepts the early filesystem effect and uses ordinary Session input throughout.
- Falling back to `<home>/Documents` when system lookup is unavailable would choose an unverified Documents location; deployments that need another directory use the explicit Host override.
- Inferring first use from visible sidebar rows would ignore archived, hidden, and cwd-less Sessions.
- Using the localized path as the initialization marker would allow language changes or deletion to create another default.
@@ -28,6 +28,6 @@ Creation failure preserves the draft and offers the existing folder picker. A su
## Consequences
First-use typing has no Host Session or filesystem effects. Preparation remains a Host responsibility for both Desktop and remote Web clients, so remote users use the Host account's Documents location. Unavailable Documents lookup fails with the same folder-selection recovery as directory creation failure.
Opening a new installation can create a directory and blank Session before the user sends a message. Directory authorization and preparation failures can appear during startup. Preparation remains a Host responsibility for both Desktop and remote Web clients, so remote users use the Host account's Documents location. Unavailable Documents lookup fails with the same folder-selection recovery as directory creation failure.
The feature preserves [reference-owned Client Session lifetimes](../architecture/2026-09-15-client-session-references.md) and requires no agent-loop or Session-event change. Registry and client tests cover retries, hidden history, concurrency, and draft recovery; the [browser scenario](../../../../apps/web/tests/default-workspace.e2e.ts) verifies the assembled first-send and picker paths with an isolated Documents directory and recorded Session replay.
The feature preserves [reference-owned Client Session lifetimes](../architecture/2026-09-15-client-session-references.md) and requires no agent-loop or Session-event change. Registry and client tests cover retries, hidden history, concurrency, and startup cancellation; the [browser scenario](../../../../apps/web/tests/default-workspace.e2e.ts) verifies the assembled startup, reload, first-send, and picker paths with an isolated Documents directory and recorded Session replay.
@@ -10,17 +10,17 @@ Status: implemented
## Decision
会话外壳保存未发送的本地文本草稿,不创建 Session。首次发送请求 Host 准备默认 Workspace,随后连接真实 Session,并通过常规 composer 流程提交。首次使用的草稿会在自动提交前等待暂存的模式选择完成。这部分取代[Session scope 与供给通道](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md)中无 Session 时锁定 composer 的决策;该记录仍负责 blank Session 复用与供给通道的理由。
启动时等待完整的 Workspace 和 Session 基线。两份列表均为空时,Client 请求 Host 准备默认 Workspace,随后创建或复用并选中其空白 Session。Session 存在前不可输入,之后使用常规 composer 流程。[Session scope 与供给通道](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md)负责 blank Session 复用与供给通道的理由。
[Workspace registry](../../../../packages/workspace/workspace/README.zh.md#first-use-workspace)负责资格判断和目录准备。所有 live Session、持久化 header 和已归档身份都计入判断,包括侧栏中不可见的条目。操作共用 registry 修改队列,并在目录准备后重新检查 Session 历史,因为 Session 可以独立启动。
Client 在发送时按其语言解析初始目录名和标题。Host 控制器解析 Documents 位置;注册表接收不依赖 locale 的目录解析器。解析器仅在允许创建时于变更队列内运行,因此重复请求直接复用持久化的 Workspace,无需再次查询操作系统目录。持久化 Workspace id 独立于名称记录初始化成功。改名、切换语言、重启或删除登记均不能再次初始化默认工作区。标记与登记一起提交,因此登记失败可以重试。目录内容仍遵循已有的[仅删除元数据策略](2026-07-27-workspace-registration-deletion.zh.md)。
Client 在启动时按其语言解析初始目录名和标题。Host 控制器解析 Documents 位置;注册表接收不依赖 locale 的目录解析器。解析器仅在允许创建时于变更队列内运行,因此重复请求直接复用持久化的 Workspace,无需再次查询操作系统目录。持久化 Workspace id 独立于名称记录初始化成功。改名、切换语言、重启或删除登记均不能再次初始化默认工作区。标记与登记一起提交,因此登记失败可以重试。目录内容仍遵循已有的[仅删除元数据策略](2026-07-27-workspace-registration-deletion.zh.md)。
创建失败时保留草稿,并提供现有文件夹选择器。成功的登记会在 Session 创建或提示词发送失败后保留。失败后选择文件夹只转移文本,不发送;随后发送仍由用户操作。
不符合首次使用条件时不返回 Workspace,也不显示失败弹窗。准备失败时提供现有文件夹选择器;后续列表通知不会触发启动流程重试。成功的登记会在 Session 创建或提示词发送失败后保留。选中默认或手动选择的 Workspace 均不会提交消息。
## Alternatives considered
- 在应用启动时创建,会为从未发送消息的用户写入目录。
- 仅在发送时创建可以避免未使用的目录,但需要独立的本地草稿、向 Session 转移草稿及首次提交协调逻辑。启动时创建接受提前写入文件系统的副作用,始终使用普通 Session 输入。
- 系统查询不可用时回退到 `<home>/Documents`,会选择未经确认的 Documents 位置;需要其他目录的部署使用显式 Host 覆盖配置。
- 根据侧栏可见行推断首次使用,会忽略已归档、隐藏和无 cwd 的 Session。
- 将本地化路径作为初始化标记,会允许切换语言或删除后再次创建默认工作区。
@@ -28,6 +28,6 @@ Client 在发送时按其语言解析初始目录名和标题。Host 控制器
## Consequences
首次使用时输入文本不会产生 Host Session 或文件系统副作用。Desktop 和远程 Web 客户端均由 Host 准备目录,因此远程用户使用 Host 账户的 Documents 位置。Documents 查询不可用时,采用与目录创建失败相同的文件夹选择恢复路径。
打开新安装的应用即可在用户发送消息前创建目录和空白 Session。目录授权与准备失败提示可能在启动期间出现。Desktop 和远程 Web 客户端均由 Host 准备目录,因此远程用户使用 Host 账户的 Documents 位置。Documents 查询不可用时,采用与目录创建失败相同的文件夹选择恢复路径。
该功能保留[由引用持有的 Client Session 生命周期](../architecture/2026-09-15-client-session-references.zh.md),无需修改 agent-loop 或 Session 事件。Registry 和客户端测试覆盖重试、隐藏历史、并发及草稿恢复;[浏览器场景](../../../../apps/web/tests/default-workspace.e2e.ts)通过隔离的 Documents 目录和已录制 Session 回放验证完整装配下的首次发送与选择器路径。
该功能保留[由引用持有的 Client Session 生命周期](../architecture/2026-09-15-client-session-references.zh.md),无需修改 agent-loop 或 Session 事件。Registry 和客户端测试覆盖重试、隐藏历史、并发及启动取消;[浏览器场景](../../../../apps/web/tests/default-workspace.e2e.ts)通过隔离的 Documents 目录和已录制 Session 回放验证完整装配下的启动、刷新、首次发送与选择器路径。
+2 -2
View File
@@ -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 apps/web/tests/README.md
README.md: b2fa27bb7a60ed78309bf81e77b9cd5217bbee2a
README.zh.md: 29ae2e86018257a4fc2379ea2563c8b5e5e9036f
README.md: 8b9c58ce9e4cf2f5f6978c0c6eb5517fe000d3d2
README.zh.md: 199ae3b56166aa2ffa1c0bed9a16b844ffd7b74f
+2
View File
@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
These tests boot the real web composition in-process and drive it with a real Chromium over real HTTP. The lane's mechanics — modes, fixtures, goldens, and the deliberate composition divergences from `dsh web` — are documented in [`scaffold.ts`](scaffold.ts) and the [browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
Ordinary scenarios begin with no registered Workspace or Session and a durable marker recording a removed default Workspace, so explicit folder-selection scenarios retain control of their cwd. `launchWebScaffold({ firstUse: true })` leaves initialization eligible for startup scenarios.
## Completion observations
State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. See the [CI fixture synchronization decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md).
+2
View File
@@ -4,6 +4,8 @@
这些测试在进程内启动真实的 web 组合,并用真实 Chromium 通过真实 HTTP 驱动它。该 lane 的运行机制——模式、fixture(测试前置数据)、golden,以及与 `dsh web` 之间刻意保留的组合差异——记录在 [`scaffold.ts`](scaffold.ts) 和 [浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md) 中。
普通场景以没有已登记 Workspace 或 Session、但持久化标记记录默认 Workspace 已被删除的状态启动,使显式文件夹选择场景自行决定 cwd。`launchWebScaffold({ firstUse: true })` 保留初始化资格,供启动场景使用。
## 完成状态观察
依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。参见 [CI fixture 同步决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。
+100 -99
View File
@@ -1,9 +1,9 @@
/** First-use messages prepare a durable Workspace through the shipped Web composition. */
/** First-use startup prepares a durable Workspace through the shipped Web composition. */
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, onTestFinished } from 'vitest'
import {
assertFinalWorkspaceSnapshot, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold,
selectedSessionFixture, watchConsole, webSnapshotMode,
@@ -13,124 +13,125 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url))
const EXPECTED = fileURLToPath(new URL('../../../snapshots/web/default-workspace/ui.expected.md', import.meta.url))
const FAILURE_EXPECTED = fileURLToPath(new URL('./expected/default-workspace/failure.expected.md', import.meta.url))
const PREPARING_EXPECTED = fileURLToPath(new URL('./expected/default-workspace/preparing.expected.md', import.meta.url))
const MODE = webSnapshotMode()
describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => {
it('sends the first draft in a newly selected default Workspace and keeps its identity across reload', async () => {
it('prepares a blank Session on startup, reuses it after reload, and sends through the ordinary composer', async () => {
const fixture = await selectedSessionFixture(FIXTURE)
const scaffold = await launchWebScaffold({
firstUse: true,
replayFixture: fixture,
// The shared recording remains read-only; replay compares the complete persisted Session.
compareReplaySession: MODE === 'replay',
paceMs: 5,
})
onTestFinished(() => scaffold.close())
const browser = await chromium.launch()
try {
const browser = await chromium.launch()
const page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
try {
const page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
try {
await page.goto(scaffold.authenticatedUrl)
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
await input.waitFor()
const prompt = fixtureUserPrompts(await readFile(fixture, 'utf8'))[0]!
await input.fill(prompt)
expect(scaffold.ctx.workspaceRegistry.list()).toEqual([])
expect(scaffold.ctx.sessions.list()).toEqual([])
const settled = scaffold.whenTurnSettled()
const preparing = Promise.withResolvers<undefined>()
const initialize = scaffold.ctx.workspaceRegistry.initializeDefault.bind(scaffold.ctx.workspaceRegistry)
vi.spyOn(scaffold.ctx.workspaceRegistry, 'initializeDefault').mockImplementationOnce(async (resolve) => {
await preparing.promise
return initialize(resolve)
})
try {
await input.press('Enter')
await page.getByRole('textbox', { name: 'Preparing workspace…', exact: true }).waitFor()
await compareOrRefreshGolden(PREPARING_EXPECTED, await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE)
} finally {
preparing.resolve(undefined)
}
const sessionId = await settled
const workspace = scaffold.ctx.workspaceRegistry.list()[0]!
expect(workspace.title).toBe('Default workspace')
expect(workspace.path).toBe(join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness', 'Default workspace'))
expect((await stat(workspace.path)).isDirectory()).toBe(true)
expect(workspace.sessionIds).toContain(sessionId)
expect(scaffold.ctx.sessions.get(sessionId)?.header.cwd).toBe(workspace.path)
await assertFinalWorkspaceSnapshot(fileURLToPath(new URL('../../../snapshots/web/default-workspace', import.meta.url)), workspace.path)
await page.getByText('DONE', { exact: true }).waitFor()
await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE)
await page.reload()
await page.getByText('DONE', { exact: true }).waitFor()
expect(scaffold.ctx.workspaceRegistry.list().map(item => item.id)).toEqual([workspace.id])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} catch (error) {
await saveFailureShot(page, 'web-e2e-default-workspace')
throw error
}
} finally {
await browser.close()
await page.goto(scaffold.authenticatedUrl)
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
await input.waitFor()
expect(scaffold.ctx.workspaceRegistry.list()).toHaveLength(1)
const initialSession = scaffold.ctx.sessions.list()[0]!
expect(scaffold.ctx.sessions.list()).toHaveLength(1)
expect(initialSession.snapshotEvents().some(event => event.type === 'user/message')).toBe(false)
await page.reload()
await input.waitFor()
expect(scaffold.ctx.sessions.list().map(session => session.id)).toEqual([initialSession.id])
const prompt = fixtureUserPrompts(await readFile(fixture, 'utf8'))[0]!
await input.fill(prompt)
const settled = scaffold.whenTurnSettled()
await input.press('Enter')
const sessionId = await settled
const workspace = scaffold.ctx.workspaceRegistry.list()[0]!
expect(workspace.title).toBe('Default workspace')
expect(workspace.path).toBe(join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness', 'Default workspace'))
expect((await stat(workspace.path)).isDirectory()).toBe(true)
expect(workspace.sessionIds).toContain(sessionId)
expect(scaffold.ctx.sessions.get(sessionId)?.header.cwd).toBe(workspace.path)
await assertFinalWorkspaceSnapshot(fileURLToPath(new URL('../../../snapshots/web/default-workspace', import.meta.url)), workspace.path)
await page.getByText('DONE', { exact: true }).waitFor()
await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} catch (error) {
await saveFailureShot(page, 'web-e2e-default-workspace')
throw error
}
} finally {
await scaffold.close()
await browser.close()
}
})
it('keeps the draft on a directory conflict and opens the composed folder picker for recovery', async () => {
const scaffold = await launchWebScaffold()
it('reports a startup directory conflict and opens the composed folder picker for recovery', async () => {
const scaffold = await launchWebScaffold({ firstUse: true })
onTestFinished(() => scaffold.close())
const parent = join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness')
await mkdir(parent, { recursive: true })
await writeFile(join(parent, '默认工作区'), 'occupied')
const chosen = join(scaffold.workspaceCwd, 'chosen')
await mkdir(chosen)
const browser = await chromium.launch()
try {
const parent = join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness')
await mkdir(parent, { recursive: true })
await writeFile(join(parent, '默认工作区'), 'occupied')
const chosen = join(scaffold.workspaceCwd, 'chosen')
await mkdir(chosen)
const browser = await chromium.launch()
const page = await browser.newPage({ locale: 'zh-CN', timezoneId: 'Asia/Shanghai' })
const tripwire = watchConsole(page)
try {
const page = await browser.newPage({ locale: 'zh-CN', timezoneId: 'Asia/Shanghai' })
const tripwire = watchConsole(page)
try {
await page.goto(scaffold.authenticatedUrl)
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
await input.fill('保留这条消息')
await input.press('Enter')
const modal = page.getByRole('dialog', { name: '无法创建默认工作区' })
await modal.waitFor()
await expect.poll(() => input.textContent()).toBe('保留这条消息')
expect(scaffold.ctx.workspaceRegistry.list()).toEqual([])
expect(scaffold.ctx.sessions.list()).toEqual([])
await compareOrRefreshGolden(FAILURE_EXPECTED, await modal.ariaSnapshot(), MODE)
await modal.getByRole('button', { name: '取消', exact: true }).click()
expect(await input.textContent()).toBe('保留这条消息')
await input.press('Enter')
await modal.getByRole('button', { name: '选择文件夹', exact: true }).click()
const picker = page.getByRole('dialog', { name: '选择工作区目录' })
await picker.waitFor()
await picker.getByRole('button', { name: '编辑路径', exact: true }).click()
await picker.getByRole('textbox', { name: '编辑路径' }).fill(chosen)
await page.keyboard.press('Enter')
await picker.getByRole('button', { name: '打开', exact: true }).click()
await picker.waitFor({ state: 'hidden' })
await expect.poll(() => scaffold.ctx.workspaceRegistry.list().length).toBe(1)
await page.getByRole('treeitem', { name: '新会话', exact: true }).waitFor()
await expect.poll(() => input.textContent()).toBe('保留这条消息')
expect(scaffold.ctx.workspaceRegistry.list()[0]?.path).toBe(chosen)
expect(scaffold.ctx.sessions.list()).toHaveLength(1)
expect(scaffold.ctx.sessions.list().every(session =>
session.snapshotEvents().every(event => event.type !== 'user/message'))).toBe(true)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} catch (error) {
await saveFailureShot(page, 'web-e2e-default-workspace-failure')
throw error
}
} finally {
await browser.close()
await page.goto(scaffold.authenticatedUrl)
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
const modal = page.getByRole('dialog', { name: '无法创建默认工作区' })
await modal.waitFor()
expect(scaffold.ctx.workspaceRegistry.list()).toEqual([])
expect(scaffold.ctx.sessions.list()).toEqual([])
await compareOrRefreshGolden(FAILURE_EXPECTED, await modal.ariaSnapshot(), MODE)
await modal.getByRole('button', { name: '取消', exact: true }).click()
expect(await input.count()).toBe(0)
await page.reload()
await modal.waitFor()
await modal.getByRole('button', { name: '选择文件夹', exact: true }).click()
const picker = page.getByRole('dialog', { name: '选择工作区目录' })
await picker.waitFor()
await picker.getByRole('button', { name: '编辑路径', exact: true }).click()
await picker.getByRole('textbox', { name: '编辑路径' }).fill(chosen)
await page.keyboard.press('Enter')
await picker.getByRole('button', { name: '打开', exact: true }).click()
await picker.waitFor({ state: 'hidden' })
await expect.poll(() => scaffold.ctx.workspaceRegistry.list().length).toBe(1)
await input.waitFor()
expect(await input.textContent()).toBe('')
expect(scaffold.ctx.workspaceRegistry.list()[0]?.path).toBe(chosen)
expect(scaffold.ctx.sessions.list()).toHaveLength(1)
expect(scaffold.ctx.sessions.list().every(session =>
session.snapshotEvents().every(event => event.type !== 'user/message'))).toBe(true)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} catch (error) {
await saveFailureShot(page, 'web-e2e-default-workspace-failure')
throw error
}
} finally {
await scaffold.close()
await browser.close()
}
})
it('keeps an explicitly deleted default absent on startup', async () => {
const scaffold = await launchWebScaffold()
onTestFinished(() => scaffold.close())
const browser = await chromium.launch()
try {
const page = await newEnglishPage(browser)
const attempted = page.waitForResponse(response => response.url().includes('/api/workspace/initializeDefault'))
await page.goto(scaffold.authenticatedUrl)
expect((await attempted).ok()).toBe(true)
await page.getByRole('button', { name: 'Choose workspace', exact: true }).waitFor()
expect(await page.getByRole('dialog', { name: 'Unable to create default workspace' }).count()).toBe(0)
expect(await page.locator('[data-composer-input][contenteditable="true"]').count()).toBe(0)
expect(scaffold.ctx.workspaceRegistry.list()).toEqual([])
expect(scaffold.ctx.sessions.list()).toEqual([])
} finally {
await browser.close()
}
})
})
@@ -1,7 +0,0 @@
- banner
- text: Into the Unknown Preview
- button "Choose workspace"
- button "Standard mode"
- textbox "Preparing workspace…" [disabled]
- button "Add files or run commands" [disabled]
- button "Send message" [disabled]
+7 -1
View File
@@ -395,6 +395,8 @@ export interface LaunchOptions {
deepSeekMessages?: boolean
/** Leave the current welcome notice pending; ordinary scenarios pre-acknowledge it before browser boot. */
welcomeNoticePending?: boolean
/** Leave first-use Workspace initialization eligible; ordinary scenarios start after the default was removed. */
firstUse?: boolean
/**
* Patch the shipped DeepSeek search row to a deterministic endpoint and
* credential reference. Browser search scenarios keep the real provider and
@@ -600,7 +602,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// to an absolute temp root (removed with the workspace at close) so tests
// never write the user's harness home.
{ id: 'storage-json', config: { root: join(workspaceCwd, '.dsh-storages') } },
// First-use sends must create directories only inside this scaffold's temporary world.
// First-use initialization must create directories only inside this scaffold's temporary world.
{ id: 'workspace-controller', config: { documentsDirectory: join(workspaceCwd, 'Documents') } },
// Skill discovery is model-visible input. Pin every host-level root inside
// the owned temp world so ~/.dsh, ~/.agents, and a bundled-root env setting
@@ -828,6 +830,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,
}])
}
if (options.firstUse !== true && ctx.workspaceRegistry.list().length === 0) {
const initial = await ctx.workspaceRegistry.initializeDefault(async () => ({ path: workspaceCwd, title: 'Workspace' }))
if (initial !== undefined) await ctx.workspaceRegistry.delete(initial.id)
}
const boundPort = ctx.get('webServer')?.port
if (boundPort === undefined) {
throw new Error('web e2e scaffold: webServer service missing after settled boot')
+2 -2
View File
@@ -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 docs/subsystems/workspace.md
workspace.md: 43a631ad0b04dd272436e272c931c2a16b7bbe88
workspace.zh.md: c4571083f3e445828b4b3b41a27ed8abee9ee04d
workspace.md: 323323946ac92bbc03962b83c5e77a81a960d89a
workspace.zh.md: aedd1134305aaa7e97ccd449631ff72446bf28b4
+3 -3
View File
@@ -306,12 +306,12 @@ Host service backing the generated `ctx.remote.workspace` namespace.
@Remote('create') create(request: WorkspaceCreateRequest): Promise<WorkspaceCreateValue>
/**
* Initialize or reuse the default Workspace before the first user message.
* Initialize or reuse the default Workspace during first-use startup.
* @param request - initial directory name and title; never rename an existing default.
* @param signal - caller lifetime; cancels native directory lookup.
* @returns the durable Workspace without creating a Session or sending a message.
* @returns the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message.
*/
@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise<WorkspaceValue>
@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise<WorkspaceValue | undefined>
/**
* Rename one Workspace to a unique non-blank title.
+3 -3
View File
@@ -306,12 +306,12 @@ Host service backing the generated `ctx.remote.workspace` namespace.
@Remote('create') create(request: WorkspaceCreateRequest): Promise<WorkspaceCreateValue>
/**
* Initialize or reuse the default Workspace before the first user message.
* Initialize or reuse the default Workspace during first-use startup.
* @param request - initial directory name and title; never rename an existing default.
* @param signal - caller lifetime; cancels native directory lookup.
* @returns the durable Workspace without creating a Session or sending a message.
* @returns the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message.
*/
@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise<WorkspaceValue>
@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise<WorkspaceValue | undefined>
/**
* Rename one Workspace to a unique non-blank title.
@@ -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/api/workspace-controller/README.md
README.md: 24bf0610689bfb857b24e7c8298b1b0494f1ca02
README.zh.md: ca140abfdda246b62b0485e63ebfd05ab2efe6f9
README.md: 27053331007681eb373b4537514bbe701dad660d
README.zh.md: d0726496de5dcf623fedb531ad988b3c608fc2ac
+2 -2
View File
@@ -29,9 +29,9 @@ The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream
<a id="first-use-workspace"></a>
### First-use Workspace
`workspace.initializeDefault({ directoryName, title })` returns the durable default Workspace; the Client service exposes the same request as `workspaces.initializeDefault(request, signal?)`. The [Conversation Client](../../client/ui-conversation/README.md) resolves these names from its send-time language. The Host places the directory under its account's `<Documents>/deepseek-harness`, including on remote Web hosts. The directory name must be one non-blank segment without separators, colon, NUL, surrounding whitespace, or a trailing dot; the title must be non-blank. OS filename restrictions also apply. Linux system lookup requires `xdg-user-dir` with an enabled Documents directory; hosts without it must configure `documentsDirectory` or use the folder picker.
`workspace.initializeDefault({ directoryName, title })` returns the durable default Workspace; the Client service exposes the same request as `workspaces.initializeDefault(request, signal?)`. The [Workspace Client](../../client/ui-workspace/README.md) resolves these names from its startup language. The Host places the directory under its account's `<Documents>/deepseek-harness`, including on remote Web hosts. The directory name must be one non-blank segment without separators, colon, NUL, surrounding whitespace, or a trailing dot; the title must be non-blank. OS filename restrictions also apply. Linux system lookup requires `xdg-user-dir` with an enabled Documents directory; hosts without it must configure `documentsDirectory` or use the folder picker.
The [Workspace registry](../../workspace/workspace/README.md#first-use-workspace) owns eligibility, directory creation, and durable initialization. An existing default Workspace is returned without another Documents lookup; request names do not rename it. Invalid names or ineligible first use reject with `gateway/bad-request`; lookup and creation failures use standard Remote error handling. Initialization creates no Session and sends no message.
The [Workspace registry](../../workspace/workspace/README.md#first-use-workspace) owns eligibility, directory creation, and durable initialization. An existing default Workspace is returned without another Documents lookup; request names do not rename it. Ineligible first use returns `undefined`, so startup can leave directory selection to the user. Invalid names reject with `gateway/bad-request`; lookup and creation failures use standard Remote error handling. Initialization creates no Session and sends no message.
| Configuration | Default | Purpose |
| --- | --- | --- |
@@ -29,9 +29,9 @@ Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。
<a id="first-use-workspace"></a>
### 首次使用工作区
`workspace.initializeDefault({ directoryName, title })` 返回持久化的默认工作区;Client service 通过 `workspaces.initializeDefault(request, signal?)` 提供同一请求。[Conversation Client](../../client/ui-conversation/README.zh.md)按发送时的语言解析这些名称。Host 将目录放在其账户的 `<Documents>/deepseek-harness` 下,远程 Web Host 也遵循此规则。目录名必须是非空的单个片段,不能含分隔符、冒号、NUL、首尾空白或末尾句点;标题不能为空。操作系统的文件名限制同样适用。Linux 系统查询要求存在 `xdg-user-dir` 且启用了 Documents 目录;不具备该条件的 Host 必须配置 `documentsDirectory` 或使用文件夹选择器。
`workspace.initializeDefault({ directoryName, title })` 返回持久化的默认工作区;Client service 通过 `workspaces.initializeDefault(request, signal?)` 提供同一请求。[Workspace Client](../../client/ui-workspace/README.zh.md)按启动时的语言解析这些名称。Host 将目录放在其账户的 `<Documents>/deepseek-harness` 下,远程 Web Host 也遵循此规则。目录名必须是非空的单个片段,不能含分隔符、冒号、NUL、首尾空白或末尾句点;标题不能为空。操作系统的文件名限制同样适用。Linux 系统查询要求存在 `xdg-user-dir` 且启用了 Documents 目录;不具备该条件的 Host 必须配置 `documentsDirectory` 或使用文件夹选择器。
[Workspace 注册表](../../workspace/workspace/README.zh.md#first-use-workspace)负责资格判断、目录创建和持久化初始化。已有默认工作区直接返回,不再查询 Documents;请求中的名称不会将其重命名。名称无效或不满足首次使用条件时,以 `gateway/bad-request` 拒绝;查询和创建失败遵循标准 Remote 错误处理。初始化不创建 Session,也不发送消息。
[Workspace 注册表](../../workspace/workspace/README.zh.md#first-use-workspace)负责资格判断、目录创建和持久化初始化。已有默认工作区直接返回,不再查询 Documents;请求中的名称不会将其重命名。不满足首次使用条件时返回 `undefined`,启动流程可将目录选择留给用户。名称无效时以 `gateway/bad-request` 拒绝;查询和创建失败遵循标准 Remote 错误处理。初始化不创建 Session,也不发送消息。
| 配置 | 默认值 | 用途 |
| --- | --- | --- |
@@ -108,9 +108,11 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
* @param signal - caller lifetime.
* @returns generated Remote result.
*/
async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise<RemoteResult<WorkspaceValue>> {
async initializeDefault(
request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal,
): Promise<RemoteResult<WorkspaceValue | undefined>> {
const result = await this.remote.initializeDefault(request, signal)
if (result.ok) this.upsert(result.value.workspace)
if (result.ok && result.value !== undefined) this.upsert(result.value.workspace)
return result
}
@@ -43,9 +43,9 @@ export interface IWorkspaces {
* Initialize or reuse the default Workspace.
* @param request - initial directory name and title.
* @param signal - caller lifetime.
* @returns the prepared Workspace; rejects when directory selection is required.
* @returns the prepared Workspace, or undefined when first-use initialization is ineligible; rejects on preparation failure.
*/
initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise<WorkspaceView>
initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise<WorkspaceView | undefined>
/**
* Rename a Workspace.
* @param workspaceId - target Workspace.
@@ -117,10 +117,10 @@ export class WorkspaceController extends Service implements IWorkspaces {
return result.value.workspace
}
async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise<WorkspaceView> {
async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise<WorkspaceView | undefined> {
const result = await this.model.initializeDefault(request, signal)
if (!result.ok) throw new WorkspaceCreateError(result.error)
return result.value.workspace
return result.value?.workspace
}
async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
@@ -86,13 +86,13 @@ export class WorkspaceController extends TypertRemoteService {
}
/**
* Initialize or reuse the default Workspace before the first user message.
* Initialize or reuse the default Workspace during first-use startup.
* @param request - initial directory name and title; never rename an existing default.
* @param signal - caller lifetime; cancels native directory lookup.
* @returns the durable Workspace without creating a Session or sending a message.
* @returns the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message.
*/
@Remote('initializeDefault')
async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise<WorkspaceValue> {
async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise<WorkspaceValue | undefined> {
const { directoryName, title } = request
if (directoryName.trim() === '' || directoryName !== directoryName.trim()
|| directoryName.endsWith('.') || /[/\\:\0]/.test(directoryName) || title.trim() === '') {
@@ -105,10 +105,7 @@ export class WorkspaceController extends TypertRemoteService {
)
return { path, title }
})
if (workspace === undefined) {
throw new RemoteError('gateway/bad-request', 'choose a directory to create a Workspace', {})
}
return { workspace: workspaceView(workspace) }
return workspace === undefined ? undefined : { workspace: workspaceView(workspace) }
}
/**
@@ -177,6 +177,10 @@ describe('ClientWorkspaceModel', () => {
expect(remote.initializeDefault).toHaveBeenCalledWith({ directoryName: '默认工作区', title: '默认工作区' }, signal)
expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['default'])
const before = model.getSnapshot()
remote.initializeDefault.mockResolvedValueOnce(remoteOk(undefined))
await expect(model.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }))
.resolves.toEqual({ ok: true, value: undefined })
expect(model.getSnapshot()).toBe(before)
remote.initializeDefault.mockResolvedValueOnce(workspaceError(
new RemoteError('gateway/bad-request', 'choose a folder', {}),
))
@@ -310,6 +310,18 @@ describe('Workspace state stream', () => {
})
describe('WorkspaceController', () => {
it('returns no Workspace when startup is ineligible without changing the list', async ({ mock, start }) => {
const { remote, client } = await gatewayClient(mock, start)
const model = new ClientWorkspaceModel(remote.workspace)
model.replaceBaseline({ items: [], archivedSessionIds: [], pinnedSessionIds: [] })
const controller = new WorkspaceController(client.ctx, model)
const before = model.getSnapshot()
mock.remote.workspace.initializeDefault.mockResolvedValueOnce({ ok: true, value: undefined })
await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }))
.resolves.toBeUndefined()
expect(model.getSnapshot()).toBe(before)
})
it('publishes the model source and exposes successful Workspace commands', async ({ mock, start }) => {
const { remote, client } = await gatewayClient(mock, start)
const model = new ClientWorkspaceModel(remote.workspace)
@@ -447,24 +447,24 @@ describe('first-use Remote', () => {
it('uses the requested display title independently of the directory name', async () => {
const { controller, root } = await harness()
const result = await controller.initializeDefault({ directoryName: 'default-workspace', title: 'Default workspace' }, new AbortController().signal)
expect(result.workspace).toMatchObject({ path: join(root, 'deepseek-harness', 'default-workspace'), title: 'Default workspace' })
expect(result?.workspace).toMatchObject({ path: join(root, 'deepseek-harness', 'default-workspace'), title: 'Default workspace' })
})
it('returns a durable Workspace without allocating a Session', async () => {
const { controller, ctx, root } = await harness()
const signal = new AbortController().signal
const result = await controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, signal)
expect(result.workspace.path).toBe(join(root, 'deepseek-harness', 'Default workspace'))
expect(existsSync(result.workspace.path)).toBe(true)
expect(result!.workspace.path).toBe(join(root, 'deepseek-harness', 'Default workspace'))
expect(existsSync(result!.workspace.path)).toBe(true)
expect(ctx.sessions.list()).toEqual([])
expect(await controller.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, signal)).toEqual(result)
})
it('rejects ineligible first use and propagates preparation failures', async () => {
it('skips ineligible first use and propagates preparation failures', async () => {
const { controller, ctx, root } = await harness()
await ctx.workspaceRegistry.create(root)
await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, new AbortController().signal))
.rejects.toMatchObject({ code: 'gateway/bad-request' })
.resolves.toBeUndefined()
vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockRejectedValueOnce(new Error('permission denied'))
await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, new AbortController().signal))
.rejects.toThrow('permission denied')
@@ -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-agent-preset/README.md
README.md: 5545dbbddf18d6f181d5e8c17e194043b3e0bf9d
README.zh.md: 4ef159280dd5e72c9d12aa75905855a2b9bf8d27
README.md: 5f2c9acaa5840e71d6ca237c5fc5eccb52724cda
README.zh.md: 5c61823c25f145c0e516f3f2a16c2de05c250ef1
@@ -49,8 +49,6 @@ The settings section writes the Host's existing `agent-presets` namespace throug
The chip follows the preset projection of its bound Session while its plugin and Provider generation remain active. Settings synchronization creates the same bound controller when needed, even before the chip mounts. A late projection or another client's preset update refreshes the display without submitting a selection; list notifications do not repeat an in-flight selection. Settings changes wait for that selection and recheck the captured blank Session before applying. A choice staged during a pending request remains available afterward.
After preparing a default Workspace, the first-use draft waits for the staged preset through `conversation/prepare-first-send`. Concurrent `apply()` calls wait for the active selection without repeating it. A refusal keeps the unsent Session draft.
</details>
-----
@@ -49,8 +49,6 @@ kind: "package-reference"
只要插件及 Provider generation 仍有效,选择芯片就会跟随其绑定 Session 的预设投影。即使选择芯片尚未挂载,设置同步也会按需创建同一个绑定控制器。延后到达的投影或其他客户端的预设更新会刷新显示,而不会提交选择;列表通知不会重复仍在进行中的选择操作。设置修改会等待该操作结束,并在应用之前重新检查已捕获的空白 Session。请求期间暂存的新选择会保留。
默认工作区准备完成后,首次使用的草稿通过 `conversation/prepare-first-send` 等待暂存的模式选择。并发的 `apply()` 调用等待已有选择操作,不会重复提交。选择被拒绝时保留未发送的 Session 草稿。
</details>
-----
@@ -143,14 +143,6 @@ export function apply(ctx: ClientContext): void {
// render and simply hides the button while no flow exists.
let creatorDraft: (() => void) | undefined
ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => {
scope.on('conversation/prepare-first-send', async (sessionId) => {
const binding = scope.sessions.binding(sessionId)
if (binding === undefined) return
const seat = seatFor(binding)
if (staged.id === undefined && !seat.store.getSnapshot().busy) return
const error = await seat.apply()
if (error !== undefined) throw new Error(error)
})
const seatInjected = (sessionId: SessionId | undefined): AgentPresetSeatInjected => {
const binding = sessionId === undefined ? undefined : ctx.sessions.binding(sessionId)
const seat = binding === undefined ? unboundSeat : seatFor(binding)
@@ -193,18 +193,11 @@ export class AgentPresetSeatController {
*
* Called both by `select()` and by whoever observes the current session
* changing, because the session may appear either before or after the pick.
* Concurrent callers wait for the active selection without repeating it.
* @returns the Host refusal, or undefined when successful or no switch starts.
* List updates do not repeat a selection while its response is pending.
* @returns this attempt's Host refusal, or undefined when successful or no switch starts.
*/
async apply(): Promise<string | undefined> {
let pending = this.pendingSelection
while (pending !== undefined) {
await pending
const error = this.store.getSnapshot().error
if (error !== null) return error
pending = this.pendingSelection
if (pending === undefined && this.staged.id === undefined) return
}
if (this.store.getSnapshot().busy) return
const staged = this.staged.id
const session = this.currentSession()
if (staged === undefined) {
@@ -6,7 +6,7 @@
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
@@ -76,7 +76,6 @@ async function bench(options: {
failSettingsUpdate?: boolean
selectGate?: Promise<void>
settingsRosterGate?: Promise<undefined>
refuseSelect?: boolean
} = {}) {
const ctx = new Context()
// The host's answer, mutable so a spec can move the default the way the
@@ -154,9 +153,7 @@ async function bench(options: {
deletePreset: () => Promise.resolve({ ok: true as const, value: undefined }),
select: (_agentId: SessionId, agentPreset: string) => {
calls.push(`select:${agentPreset}`)
return Promise.resolve(options.selectGate).then(() => options.refuseSelect === true
? { ok: false as const, error: new RemoteError('gateway/internal', 'preset refused', {}) }
: { ok: true as const, value: agentPreset })
return Promise.resolve(options.selectGate).then(() => ({ ok: true as const, value: agentPreset }))
},
}
ctx.provide('remote.agentPresets', agentPresets as never)
@@ -718,10 +715,8 @@ describe('ui-agent-preset apply', () => {
conversation()
})
it.each([false, true])('awaits the staged choice before first send and propagates refusal (%s)', async (refuseSelect) => {
const selection = Promise.withResolvers<undefined>()
const { ctx, slots, calls } = await bench({ refuseSelect, selectGate: selection.promise })
onTestFinished(async () => { selection.resolve(undefined); await ctx.fiber.dispose() })
it('applies the staged choice to the blank session the flow lands on', async () => {
const { ctx, slots, calls } = await bench()
declareRoot(slots)
declareConversation(slots)
ctx.provide('conversation', {} as never)
@@ -741,8 +736,6 @@ describe('ui-agent-preset apply', () => {
.inject as unknown as (sessionId?: SessionId) => AgentPresetSeatInjected
const chip = injectSeat()
await ctx.serial('conversation/prepare-first-send', SessionId('absent'))
await chip.load()
// Picked on the hero screen, where there is no session yet.
await chip.select('minimal')
@@ -754,16 +747,10 @@ describe('ui-agent-preset apply', () => {
}
sessions.notify()
const bound = injectSeat(SessionId('s1'))
const preparing = ctx.serial('conversation/prepare-first-send', SessionId('s1'))
const settled = refuseSelect ? expect(preparing).rejects.toThrow('preset refused') : expect(preparing).resolves.toBeUndefined()
selection.resolve(undefined)
await settled
await ctx.serial('conversation/prepare-first-send', SessionId('s1'))
await bound.load()
// Connecting a workspace produced the session; the stage reaches it there.
await vi.waitFor(() => { expect(calls).toContain('select:minimal') })
expect(calls.filter(call => call === 'select:minimal')).toHaveLength(1)
})
it('applies the stage to a session that records no preset of its own', async () => {
@@ -959,28 +946,6 @@ describe('ui-agent-preset apply', () => {
})
describe('AgentPresetSeatController reconciliation', () => {
it.each([false, true])('shares the result of an active preset selection (refused: %s)', async (refused) => {
const refusal = { ok: false as const, error: new RemoteError('gateway/internal', 'selection refused', {}) }
const reply = Promise.withResolvers<{ ok: true; value: string } | typeof refusal>()
const select = vi.fn(() => reply.promise)
const controller = new AgentPresetSeatController({ remote: { agentPresets: { select } } } as never,
() => ({ id: SessionId('blank'), blank: true, projectionValues: { agentPreset: 'standard' } }))
const selecting = controller.select('minimal')
let prepared = false
const preparation = controller.apply().finally(() => { prepared = true })
try {
expect(prepared).toBe(false)
expect(select).toHaveBeenCalledOnce()
reply.resolve(refused ? refusal : { ok: true, value: 'minimal' })
await expect(preparation).resolves.toBe(refused ? 'selection refused' : undefined)
await selecting
expect(select).toHaveBeenCalledOnce()
} finally {
reply.resolve({ ok: true, value: 'minimal' })
await Promise.allSettled([selecting, preparation])
}
})
it.each([
{ refuseFirst: false, refuseLatest: false },
{ refuseFirst: false, refuseLatest: true },
@@ -1001,17 +966,16 @@ describe('AgentPresetSeatController reconciliation', () => {
const first = controller.select('minimal')
let settingsSettled = false
const settings = controller.syncBlankSession(session.id, 'cordis').finally(() => { settingsSettled = true })
const repeatedFirst = controller.apply()
await controller.apply()
expect(requests.map(request => request.preset)).toEqual(['minimal'])
requests[0]!.outcome.resolve(refuseFirst ? refusal : { ok: true, value: 'minimal' })
await first
expect(requests.map(request => request.preset)).toEqual(['minimal', 'cordis'])
expect(settingsSettled).toBe(false)
const repeatedLatest = controller.apply()
await controller.apply()
expect(requests).toHaveLength(2)
requests[1]!.outcome.resolve(refuseLatest ? refusal : { ok: true, value: 'cordis' })
await expect(settings).resolves.toBe(refuseLatest ? 'selection refused' : undefined)
await Promise.all([repeatedFirst, repeatedLatest])
expect(controller.store.getSnapshot()).toMatchObject({
current: refuseLatest ? 'standard' : 'cordis', busy: false,
error: refuseLatest ? 'selection refused' : null,
@@ -1028,10 +992,10 @@ describe('AgentPresetSeatController reconciliation', () => {
() => ({ id: SessionId('blank'), blank: true, projectionValues: { agentPreset: 'standard' } }))
const first = controller.select('minimal')
controller.stage('cordis', true)
const repeated = controller.apply()
await controller.apply()
expect(select).toHaveBeenCalledOnce()
firstReply.resolve(refused ? refusal : { ok: true, value: 'minimal' })
await Promise.all([first, repeated])
await first
expect(controller.store.getSnapshot().current).toBe('cordis')
await controller.apply()
expect(select.mock.calls.map(call => call[1])).toEqual(['minimal', 'cordis'])
@@ -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-conversation/README.md
README.md: f921aa4a9aa7e9cea0a8a2c2c875b3f1d7c27bd8
README.zh.md: b432995b1e42292a65558f37f153920cc7ecbbf2
README.md: 16e821ed4caabae491d552a823bd69a2e976e2bd
README.zh.md: 7316b00c69ca74284b81678082d93e870dee6bbe
+1 -5
View File
@@ -50,10 +50,6 @@ Claimed commands retain their identity and highlight when only their arguments a
Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact.
The Client chooses the default directory name and initial title when sending: Chinese uses `默认工作区`, English uses `Default workspace`, and other languages use directory `default-workspace` with title `Default workspace`. The request retains those names during initialization. A retry after failed initialization uses the current language; a successfully initialized Workspace keeps its directory and title across language changes.
On first use, the same input accepts text before a Workspace is selected. The first send prepares the [default Workspace](../../workspace/workspace/README.md#first-use-workspace), selects its blank Session, transfers the draft, and uses ordinary message submission. Preparation locks further input. Failure preserves the draft and offers Cancel or Choose folder through the existing picker. A manually chosen folder receives the unsent draft; the user sends again. Once created, the Workspace survives a prompt failure. `conversation/prepare-first-send` awaits staged Session settings before automatically submitting the transferred first-use draft; a rejected listener keeps that draft and reports its error. If navigation releases the destination Session while preparation is pending, automatic submission stops and its persisted draft remains available on reopening.
The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the resident `conversation.header` outside the optional-Session `conversation.content` Component Factory. The header hosts root-scoped navigation without a selected Session; titles, actions, and View tabs remain in its strict Session child. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident container reserves a 40px drag band on macOS desktop and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered.
@@ -64,7 +60,7 @@ The shell reads the persisted View preference before rendering when a Session fi
Active transcripts in the main occurrence expose content-width drag handles in their uncovered side gutters; embedded occurrences omit them. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([handle styles](src/client/skeleton/ConversationRoot.module.css)).
The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. Before a Session exists, the composer holds a local text draft; sending waits for complete Workspace and Session baselines and then prepares the default Workspace. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id.
+1 -5
View File
@@ -50,10 +50,6 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。
Client 在发送时决定默认目录名和初始标题:中文使用 `默认工作区`,英文使用 `Default workspace`,其他语言使用目录 `default-workspace` 和标题 `Default workspace`。初始化期间,请求保留这些名称。初始化失败后的重试使用当前语言;初始化成功的 Workspace 在切换语言后仍保留原目录和标题。
首次使用时,同一个输入框允许在选择 Workspace 前输入文字。首条消息会准备[默认工作区](../../workspace/workspace/README.zh.md#first-use-workspace)、选中其 blank Session、转移草稿,再使用普通消息提交流程。准备期间锁定后续输入。失败时保留草稿,并提供“取消”或通过已有选择器“选择文件夹”。手动选择的文件夹接收未发送草稿,用户再次发送。Workspace 创建成功后,不会因提示词发送失败而消失。`conversation/prepare-first-send` 在自动提交已转移的首次使用草稿前等待暂存的 Session 设置完成;监听器拒绝时保留该草稿并显示错误。如果导航在准备期间释放了目标 Session,自动提交会停止,已持久化的草稿会在重新打开时保留。
本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` 外壳将常驻的 `conversation.header` 放在可选 Session 的 `conversation.content` Component Factory 外。未选中 Session 时,头部仍承载根作用域导航;标题、操作和 View 标签保留在严格 Session 子组件中。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻容器在 macOS 桌面保留 40px 拖拽区域,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。
@@ -64,7 +60,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
主 occurrence 的活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条;嵌入式 occurrence 省略这些拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([拖动手柄样式](src/client/skeleton/ConversationRoot.module.css))。
常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。Session 尚未存在时,composer 保存本地文字草稿;发送会等待 Workspace 与 Session 基线完整加载,然后准备默认工作区。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。
@@ -2,7 +2,6 @@
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceInitializeDefaultRequest } from '@deepseek-ai/dsh-api-workspace-controller/client'
import { IconPaperclipOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { createSnapshotStore, type BoundActions } from '@deepseek-ai/dsh-client-store'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
@@ -18,13 +17,13 @@ import type {
ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected,
ConversationSessionInjected, DraftFileUploads,
} from './contract/slots.ts'
import type { InputNotice } from './contract/input.ts'
import { createConversationStore, readConversationViewPreference } from './stores.ts'
import { ConversationController, UnsupportedImageMediaTypeError } from './service.ts'
import type { IConversation } from './service.ts'
import { ComposerBlockRegistry } from './input/blocks.ts'
import type { ComposerBlock } from './contract/composer-blocks.ts'
import { InputHub } from './input/hub.ts'
import { FirstDraft } from './input/first-draft.ts'
import { ComposerSubmissionPolicy } from './input/submission-policy.ts'
import { queueDockEntry } from './queue/QueueDock.tsx'
import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx'
@@ -65,10 +64,19 @@ export const Config: z<Config> = z.object({
// Stable no-session sources keep the renderer's observable-hook cache and
// hook order unchanged across current-Session transitions.
const ABSENT_NOTICES = {
getSnapshot: (): InputNotice | null => null,
subscribe: () => () => {},
}
const ABSENT_BLOCK = {
getSnapshot: (): ComposerBlock | undefined => undefined,
subscribe: () => () => {},
}
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
const ABSENT_LEXICON = {
getSnapshot: () => EMPTY_LEXICON,
subscribe: () => () => {},
}
const ABSENT_MENU_LAUNCHER = {
getSnapshot: (): string | null => null,
subscribe: () => () => {},
@@ -79,19 +87,12 @@ const ABSENT_FILE_UPLOADS = {
subscribe: () => () => {},
}
// ui-workspace consumes Conversation slots, so this reverse runtime dependency
// uses a local method projection to keep the TypeScript project graph acyclic.
interface WorkspaceNavigation {
openSession(sessionId: SessionId): void
openWorkspace(
workspaceId: Parameters<ConversationInjected['selectWorkspace']>[0],
beforeOpen: (sessionId: SessionId) => void,
): Promise<void>
openDefaultWorkspace(
request: WorkspaceInitializeDefaultRequest,
beforeOpen: (sessionId: SessionId) => void,
signal: AbortSignal,
): Promise<SessionId | undefined>
}
/** Action registration used by the composer without importing its command-UI consumer. */
@@ -205,19 +206,6 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}, 'ui-conversation: View selection')
const inputHub = new InputHub(ctx, t)
const firstDraft = new FirstDraft(ctx, {
open: (beforeOpen, signal) => {
const language = ctx.locale.getSnapshot().active.toLowerCase().split('-')[0]
// Persisted Workspace names follow the send-time language, including the English fallback.
const title = (language === 'zh' ? zh : en)['defaultWorkspace.title']
return workspaceNavigation.openDefaultWorkspace({
directoryName: language === 'zh' || language === 'en' ? title : 'default-workspace',
title,
}, beforeOpen, signal)
},
shell: id => inputHub.shell(id),
isCurrent: (id, shell) => sessions.binding(id) !== undefined && inputHub.shell(id) === shell,
})
const composerBlocks = new ComposerBlockRegistry()
ctx.inject(['commandUi'], (scope) => {
@@ -278,11 +266,8 @@ export function apply(ctx: Context, config: Config = Config({})): void {
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
hooks: {
composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId),
firstDraft: firstDraft.state,
},
dismissDefaultFailure: () => { firstDraft.dismiss() },
selectWorkspace: workspaceId => workspaceNavigation.openWorkspace(workspaceId, (nextId) => {
if (sessionId === undefined) firstDraft.transfer(nextId)
if (sessionId !== undefined && nextId !== sessionId) {
const from = inputHub.shell(sessionId)
const draft = from.snapshot.draft
@@ -388,7 +373,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
inject: (sessionId: SessionId | undefined): ComposerBarInjected => {
if (sessionId === undefined) {
return {
keyboard: firstDraft.shell,
keyboard: undefined,
addFiles: undefined,
removeAttachment: undefined,
resolveDraftAttachments: undefined,
@@ -396,11 +381,10 @@ export function apply(ctx: Context, config: Config = Config({})): void {
toggleCommandMenu: undefined,
stop: undefined,
hooks: {
composerInput: firstDraft.shell.state,
busyEnter: submissionPolicy.busyEnter,
fileUploads: ABSENT_FILE_UPLOADS,
notices: firstDraft.shell.notices,
lexicon: firstDraft.shell.lexicon,
notices: ABSENT_NOTICES,
lexicon: ABSENT_LEXICON,
menuLauncher: ABSENT_MENU_LAUNCHER,
},
}
@@ -449,7 +433,6 @@ export function apply(ctx: Context, config: Config = Config({})): void {
})
},
hooks: {
composerInput: shell.state,
busyEnter: submissionPolicy.busyEnter,
fileUploads: conversation.fileUploads,
notices: shell.notices,
@@ -34,12 +34,6 @@ export interface SubmitOutcome {
readonly text?: string
}
/** State of first-use Workspace preparation. */
export interface FirstDraftState {
readonly busy: boolean
readonly failed: boolean
}
/** Command-mode credential supplied by one input-trigger source. */
export interface CommandClaim {
/** Catalog command name without the leading slash (the key of per-command composer copy such as `hint.*`). */
@@ -16,7 +16,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import type { ComposerBlock } from './composer-blocks.ts'
import type { DraftAttachmentId, FirstDraftState, InputActions, InputNotice, InputState } from './input.ts'
import type { DraftAttachmentId, InputActions, InputNotice, InputState } from './input.ts'
import type { ComposerKeyboard, EditSelection } from './draft-editor.ts'
import type { createConversationStore } from '../stores.ts'
import type { BusyEnterBehavior } from './composer-submission.ts'
@@ -319,13 +319,8 @@ export type ConvViewProps = PropsRuntime<'conversation.view'>
export interface ConversationInjected {
/** Connect and open a blank Session in the selected Workspace. */
selectWorkspace: (workspaceId: WorkspaceId) => Promise<void>
/** Dismiss default Workspace creation failure without discarding input. */
dismissDefaultFailure: () => void
/** Session-addressed composer block source, or the stable absent source. */
hooks: {
composerBlock: ObservableSnapshot<ComposerBlock | undefined>
firstDraft: ObservableSnapshot<FirstDraftState>
}
hooks: { composerBlock: ObservableSnapshot<ComposerBlock | undefined> }
}
/** Business callbacks injected into the strict Session body. */
@@ -370,7 +365,7 @@ export interface ComposerBarOwnerProps {
/** Package-private operations injected into the resident composer bar. */
export interface ComposerBarInjected {
keyboard: ComposerKeyboard
keyboard: ComposerKeyboard | undefined
addFiles: ((files: readonly File[]) => string | null) | undefined
removeAttachment: ((id: DraftAttachmentId) => void) | undefined
resolveDraftAttachments: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined
@@ -379,8 +374,6 @@ export interface ComposerBarInjected {
toggleCommandMenu: ((selection: EditSelection) => void) | undefined
stop: (() => void) | undefined
hooks: {
/** Resident editor state, including input before a Session exists. */
composerInput: ObservableSnapshot<InputState>
/**
* Live busy-state submission preference: the delivery mode plain Enter
* and the primary Send button use while the addressed agent is busy.
@@ -72,14 +72,6 @@ export type { ArbitrateKey, ArbitrateOutcome, ReferenceInsert, TokenSpan } from
export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts'
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* Finish staged settings before automatically submitting the first-use draft to its new Session.
* @mode serial
* @param sessionId - destination Session.
*/
'conversation/prepare-first-send'(sessionId: import('@deepseek-ai/dsh-session/types').SessionId): void | Promise<void>
}
interface Context {
/** Scope-addressed Conversation actions and per-Session input registry. */
conversation: import('./service.ts').IConversation
@@ -5,8 +5,8 @@
* scoped-event application verbs, the submit transaction plumbing
* (adjudicate via the session's InputTriggerController; claim.submit; default
* sink), the notice channel, and the draft persistence mirror.
* Package-private; InputHub binds Session events, while FirstDraft owns the
* local editor used before a Session exists.
* Package-private; the hub alone constructs it and wires the scoped event
* listeners onto it.
*/
import type { Context } from '@deepseek-ai/cordis'
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
@@ -1,82 +0,0 @@
/** Local text draft retained until the first Workspace and Session can accept it. */
import type { Context } from '@deepseek-ai/cordis'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { FirstDraftState, SubmitOutcome } from '../contract/input.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
import { SessionInputShell } from './facade.ts'
/** First-use navigation and the resident destination input. */
interface FirstDraftDeps {
open(beforeOpen: (id: SessionId) => void, signal: AbortSignal): Promise<SessionId | undefined>
shell(id: SessionId): SessionInputShell
/** Whether the borrowed input still belongs to the retained Session generation. */
isCurrent(id: SessionId, shell: SessionInputShell): boolean
}
/** Owns a root-lifetime text editor without allocating a Host Session. */
export class FirstDraft {
/** Preparation progress and the recoverable directory failure. */
readonly state = createSnapshotStore<FirstDraftState>({ busy: false, failed: false })
/** Local editor owns failed-submit restoration before transfer to a Host Session. */
readonly shell: SessionInputShell
private pending: { text: string; transferred: boolean } | undefined
/** @param ctx - Client plugin lifetime. @param deps - Workspace navigation and Session input access. */
constructor(private readonly ctx: Context, private readonly deps: FirstDraftDeps) {
this.shell = new SessionInputShell({
actx: ctx,
defaultSink: (text, _attachments, mode, signal) => this.send(text, mode, signal),
commandAttachments: {
serialize: () => Promise.resolve([]),
release: () => {},
unsupportedNotice: () => '',
},
})
ctx.effect(() => () => { this.shell.dispose() }, 'conversation.input: first-use draft')
}
/** Dismiss the preparation failure while retaining the draft. */
dismiss(): void {
this.state.set({ ...this.state.getSnapshot(), failed: false })
}
/**
* Move the unsent text to a selected Session.
* @param id - retained destination Session receiving the unsent text.
*/
transfer(id: SessionId): void {
const text = this.pending?.text ?? this.shell.snapshot.draft
if (text !== '') this.deps.shell(id).setDraft(text)
if (this.pending !== undefined) this.pending.transferred = true
this.shell.setDraft('')
this.dismiss()
}
private async send(text: string, mode: InputSubmitMode, signal: AbortSignal): Promise<SubmitOutcome> {
if (this.pending !== undefined) return { kind: 'error' }
const pending = { text, transferred: false }
this.pending = pending
this.state.set({ busy: true, failed: false })
let target: SessionInputShell | undefined
try {
const id = await this.deps.open((nextId) => { this.transfer(nextId) }, signal)
if (id === undefined) return { kind: pending.transferred ? 'success' : 'error' }
target = this.deps.shell(id)
await this.ctx.serial('conversation/prepare-first-send', id)
signal.throwIfAborted()
if (!this.deps.isCurrent(id, target)) return { kind: 'success' }
target.submit(mode)
return { kind: 'success' }
} catch (error) {
if (!signal.aborted) {
if (target !== undefined) target.notify('error', error instanceof Error ? error.message : String(error))
else if (!pending.transferred) this.state.set({ busy: true, failed: true })
}
return { kind: pending.transferred ? 'success' : 'error' }
} finally {
this.pending = undefined
this.state.set({ ...this.state.getSnapshot(), busy: false })
}
}
}
@@ -9,10 +9,6 @@ const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'defaultWorkspace.failed': '无法创建默认工作区',
'defaultWorkspace.title': '默认工作区',
'defaultWorkspace.retry': '请选择一个文件夹后重试',
'defaultWorkspace.choose': '选择文件夹',
'hint.plan': PLAN_NEXT_ACTION_ZH,
'hint.goal': '输入目标,智能体将持续执行',
'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除',
@@ -22,7 +18,6 @@ export const zh = {
'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行',
'placeholder.hero': '描述你想要构建的内容, / 调用指令, @ 文件或对话',
'placeholder.workspace': '选择一个工作区开始',
'placeholder.preparing': '正在准备工作区…',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
'input.commands': '添加文件或调用指令',
'input.file': '文件',
@@ -365,10 +360,6 @@ export type ConversationKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'defaultWorkspace.failed': 'Unable to create default workspace',
'defaultWorkspace.title': 'Default workspace',
'defaultWorkspace.retry': 'Please choose a folder and try again',
'defaultWorkspace.choose': 'Choose folder',
'hint.plan': PLAN_NEXT_ACTION_EN,
'hint.goal': 'describe the objective for a long-running task',
'hint.goal.active': 'goal active — edit / pause / resume / clear',
@@ -378,7 +369,6 @@ export const en = {
'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run',
'placeholder.hero': 'Describe what you want to build, / commands, @ files or sessions',
'placeholder.workspace': 'Choose a workspace to start',
'placeholder.preparing': 'Preparing workspace…',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
'input.commands': 'Add files or run commands',
'input.file': 'File',
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import type { ConversationContentProps, ConversationViewsProps, InputZone } from '../contract/slots.ts'
import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
@@ -23,7 +22,7 @@ export function ConversationContent(props: ConversationContentProps) {
const {
sessionId, phase, hero, useSession, useSessions, useSessionStatus,
useWorkspaces, useInput, useComposerBlock, renderSlot, renderSlotChain,
selectWorkspace, dismissDefaultFailure, useFirstDraft, t, useFactorySlot,
selectWorkspace, t, useFactorySlot,
} = props
const session = useSession(snapshot => snapshot)
const Views = useFactorySlot('views', ConversationSessionView)
@@ -34,8 +33,6 @@ export function ConversationContent(props: ConversationContentProps) {
const inputState = useInput(s => s)
const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
const workspaces = useWorkspaces(s => s)
const sessionsReady = useSessions(s => s.phase === 'ready')
const firstDraft = useFirstDraft(s => s)
// A plugin this package cannot import (ui-model-selection) says this session cannot
// send; its reason is already localized by whoever raised it.
const composerBlock = useComposerBlock(block => block)
@@ -130,22 +127,24 @@ export function ConversationContent(props: ConversationContentProps) {
</div>
)
// First-use sending waits for both baselines. A blank Session whose
// Workspace disappeared still requires explicit directory selection.
const needsWorkspace = sessionId !== undefined && hero && chipTitle === undefined
const inert = firstDraft.busy || (sessionId === undefined
? workspaces.phase !== 'ready' || !sessionsReady
: needsWorkspace)
// Owner availability takes precedence over a Session-specific model block.
// The placeholder chip ("Choose workspace") and the Workspace-trigger input travel
// together: no workspace picked yet (cold start, no session at all), or a
// blank session whose workspace vanished (deleted from the sidebar). The
// bar is ONE session-maybe slot rendered unconditionally — inert is a prop,
// not a different tree, so the textarea DOM survives the transition.
const inert = sessionId === undefined || (hero && chipTitle === undefined)
// A raised block is the same inert posture with the blocker's own reason:
// one disabled textarea, never a second tree. The no-workspace state wins
// when both hold — picking a workspace is the earlier prerequisite.
const blocked = !inert && composerBlock !== undefined
const inputBar = renderSlot('conversation.composer.bar', {
variant: hero ? 'hero' : 'composer',
...(inert
? {
disabled: true,
placeholder: t(firstDraft.busy ? 'placeholder.preparing' : 'placeholder.workspace'),
placeholder: t('placeholder.workspace'),
workspacePickerOpen: pickerOpen,
...(needsWorkspace ? { onRequestWorkspace: () => { setPickerOpen(true) } } : {}),
onRequestWorkspace: () => { setPickerOpen(true) },
}
: blocked
// `blocked`, not `disabled`: the bar refuses input either way, but a
@@ -192,23 +191,6 @@ export function ConversationContent(props: ConversationContentProps) {
{composerSeat}
</div>
<WidthControls container={body} phase={phase} />
<Modal
open={sessionId === undefined && firstDraft.failed}
title={t('defaultWorkspace.failed')}
closeLabel={t('close')}
onClose={dismissDefaultFailure}
footer={(
<>
<Button variant="outline" onClick={dismissDefaultFailure}>{t('cancel')}</Button>
<Button variant="primary" onClick={() => {
dismissDefaultFailure()
setPickerOpen(true)
}}>{t('defaultWorkspace.choose')}</Button>
</>
)}
>
{t('defaultWorkspace.retry')}
</Modal>
</div>
)
}
@@ -1,5 +1,6 @@
// Resident conversation shell keeps the composer mounted across local-draft,
// blank-Session, and active-conversation views.
// Resident conversation skeleton. Hero chrome, composer positioning, the
// chain, AND the composer bar (session-maybe slot) stay mounted across
// no-session/session transitions — the bar renders inert via owner props.
import type { ConversationSlotProps } from '../contract/slots.ts'
import { ConversationMainPanel } from './ConversationMainPanel.tsx'
@@ -1,12 +1,16 @@
/** The default composer body: the 'conversation.composer.bar' slot entry.
* The injected input state and keyboard address the current Session or the
* first-use local draft. Standard Session props supply attachment actions;
* owner props control layout and availability.
* Machine state arrives through the standard provide channel
* (useInput + inputActions); the keyboard/DOM command face and stop arrive
* through this entry's own inject, whose hooks compartment binds
* useNotices/useLexicon; layout-phase inputs (variant and placeholder) ride
* the owner props. Session facts
* (running/removed/promptError) are self-selected via useSession.
*
* The text surface is the shell-owned Lexical editor bound here through
* ComposerContentEditable; chips render as decorator portals, and the
* keymap registers submit/menu/paste gestures on the editor command layer.
* The contenteditable remains mounted when the first Session arrives.
* The no-session state renders the SAME div inert as the Workspace-picker
* trigger instead of a parallel tree.
*/
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -38,7 +42,7 @@ import css from './InputBar.module.css'
export type InputBarProps = ComposerBarProps
export const InputBar = memo(function InputBar({
useSession, useComposerInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments,
useSession, useInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments,
retryFileUpload,
toggleCommandMenu, stop, t,
renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher,
@@ -46,7 +50,7 @@ export const InputBar = memo(function InputBar({
workspacePickerOpen = false, onRequestWorkspace,
placeholder, accessory,
}: InputBarProps) {
const input = useComposerInput(s => s)
const input = useInput(s => s)
const notice = useNotices(s => s)
const busyEnter = useBusyEnter(s => s)
void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms
@@ -60,11 +64,14 @@ export const InputBar = memo(function InputBar({
const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
// Absent (undefined: no frame yet) and cleared (null) both mean no goal.
const hasGoal = useProjection('goal', goal => goal != null)
const draft = input.draft
const editor = keyboard.editor
// Session-maybe: the machine faces are absent together while no session is
// current; the bar renders the same DOM inert instead of a parallel tree.
const live = input !== undefined && keyboard !== undefined && inputActions !== undefined
const draft = input?.draft ?? ''
const editor = keyboard?.editor ?? null
const attachments = useMemo(
() => resolveDraftAttachments === undefined ? [] : resolveDraftAttachments(input.attachmentIds),
[resolveDraftAttachments, input.attachmentIds],
() => input === undefined || resolveDraftAttachments === undefined ? [] : resolveDraftAttachments(input.attachmentIds),
[resolveDraftAttachments, input?.attachmentIds],
)
const empty = draft.trim() === '' && attachments.length === 0
const uploads = useFileUploads(snapshot => snapshot)
@@ -86,7 +93,8 @@ export const InputBar = memo(function InputBar({
// The deployment's image-intake limits (absent while no attachment service
// is composed — the pre-check below then defers entirely to the host).
const imageLimits = useProjection('imageLimits')
// The toast announces promptError, the draft stays in the machine,
// Prompt failures are ordinary failures (no create/attach transaction exists
// anymore): the toast announces promptError, the draft stays in the machine,
// and the user resubmits. A remount over a session whose machine still holds
// an unresolved promptError deliberately re-announces it once — the failure
// is still pending, and a transient banner is its only surface. Attachment
@@ -114,30 +122,35 @@ export const InputBar = memo(function InputBar({
// but its independent Stop below stays available while it runs.
const continuable = subagent?.address.mode === 'continuable'
const parentOffline = continuable && subagent.parentAvailable !== true
// Preparation, removed Sessions, and unavailable parents lock editing.
// Adjudicating and submitting keep the draft visible and read-only.
const disabled = removed || inert || blocked !== undefined || parentOffline
// Running input stays free; locked = session removed, the
// inert no-workspace state, the machine faces absent (no session), or a
// parent-offline continuable child. An owner block also disables input;
// adjudicating and submitting render read-only so the draft stays visible.
const disabled = removed || inert || !live || blocked !== undefined || parentOffline
const locked = disabled
// The model seat is the ONE control a block leaves live: every block this
// contract has is cleared by choosing a model, so locking it too would leave
// the composer asking for the only thing it prevents. The other reasons to
// be disabled do lock it — there is no session to choose a model for.
const modelSeatLocked = removed || inert
const machineBusy = input.phase === 'adjudicating' || input.phase === 'submitting'
// A blank Session whose Workspace was deleted offers directory selection
// through the same resident input node.
const modelSeatLocked = removed || inert || !live
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
// The no-workspace surface remains the resident DOM node but acts as the
// existing picker trigger. Message controls stay locked until a Session
// exists; the trigger itself is read-only rather than disabled so pointer
// and keyboard users can reach the recovery action.
const workspaceTrigger = inert && !removed && onRequestWorkspace !== undefined
const editorDisabled = removed || (locked && !workspaceTrigger)
const editable = !locked && !machineBusy
const editable = live && !locked && !machineBusy
const steeringAvailable = subagent === null || subagent.address.mode === 'continuable'
const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && steeringAvailable
&& input.queue.length > 0
useEffect(() => {
if (inputActions !== undefined && attachments.length !== input.attachmentIds.length) {
if (input === undefined || inputActions === undefined) return
if (attachments.length !== input.attachmentIds.length) {
inputActions.pruneAttachments(attachments.map(attachment => attachment.id))
}
}, [attachments, input.attachmentIds, inputActions])
}, [attachments, input?.attachmentIds, inputActions])
// Scroll the draft scrollport the minimum that brings the selection focus
// into view — the browser's own behavior for typing, performed for the
@@ -154,7 +167,7 @@ export const InputBar = memo(function InputBar({
// is ours to perform — switching to a longer draft otherwise leaves the
// caret (restored at the draft's end) off screen.
useEffect(() => {
if (locked) return
if (locked || editor === null) return
focusDraftEditor(editor, revealSelection)
}, [locked, sessionId, editor])
@@ -231,10 +244,12 @@ export const InputBar = memo(function InputBar({
}
useEffect(() => {
if (keyboard === undefined) return
return installDraftFilePicker(keyboard, gate, fileInputRef)
}, [keyboard])
useEffect(() => {
if (editor === null || keyboard === undefined) return
return installDraftKeymap(editor, keyboard, gate)
}, [editor, keyboard])
@@ -247,16 +262,17 @@ export const InputBar = memo(function InputBar({
}
const onToggleCommandMenu = (): void => {
if (keyboard === undefined) return
// The menu is a combobox over the editor, so the keyboard has to be there
// before the launcher opens it: activating the button from the keyboard
// leaves focus on the button, and restoring it afterwards would re-track an
// empty draft and close the menu again.
focusDraftEditor(editor, revealSelection)
if (editor !== null) focusDraftEditor(editor, revealSelection)
toggleCommandMenu?.(keyboard.caretSpan())
}
// When a blank Session loses its Workspace, the resident editable div acts as the
// picker trigger for keyboard users.
// The no-session Workspace trigger: the resident editable div acts as the
// picker trigger for keyboard users (no editor is bound in this state).
const onWorkspaceKeyDown = (e: KeyboardEvent<HTMLDivElement>): void => {
if (!workspaceTrigger) return
if (e.key === 'Enter' || e.key === ' ') {
@@ -279,7 +295,7 @@ export const InputBar = memo(function InputBar({
const primaryDisabled = primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending
const interruptible = running && continuable
const primarySubmitMode = resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable)
const plainMessageDraft = !empty && input.phase === 'plain' && !draft.trimStart().startsWith('/')
const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/')
const primaryLabel = primaryStops
? t('input.stop')
: running && steeringAvailable && !disabled && !uploadsPending && plainMessageDraft
@@ -290,6 +306,7 @@ export const InputBar = memo(function InputBar({
stop?.()
return
}
if (keyboard === undefined) return // absent machine: the button is disabled
/* v8 ignore next -- defensive: the primary button is disabled for empty, disabled, and pending-upload states. */
if (!empty && !disabled && !machineBusy && !uploadsPending) keyboard.submit(primarySubmitMode)
}
@@ -297,7 +314,7 @@ export const InputBar = memo(function InputBar({
// Claim ghost hint: rendered by CSS as generated content after the last
// paragraph while the claim's args are blank (a hint implies a single-line
// token draft). The translated per-command hint wins over the claim's own.
const claimActive = (input.phase === 'claimed' || input.phase === 'submitting')
const claimActive = (input?.phase === 'claimed' || input?.phase === 'submitting')
&& input.claim !== undefined && draft.startsWith(input.claim.token)
const rawHint = claimActive && input.claim.hint !== undefined
&& draft.slice(input.claim.token.length).trim() === ''
@@ -305,7 +322,7 @@ export const InputBar = memo(function InputBar({
: null
const hint = ((): string | null => {
if (rawHint === null) return null
const commandName = input.claim?.name ?? ''
const commandName = input?.claim?.name ?? ''
const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}`
// Dynamic lookup by claimed command name: unknown commands miss the
// dictionary and keep the machine's own hint, so the call is wide.
@@ -379,7 +396,7 @@ export const InputBar = memo(function InputBar({
scrollRef={scrollRef}
editable={editable}
editorDisabled={editorDisabled}
phase={input.phase}
phase={input?.phase ?? 'inert'}
placeholderText={placeholderText}
ariaLabel={workspaceTrigger ? t('hero.chooseWorkspace') : placeholderText}
workspaceTrigger={workspaceTrigger}
@@ -416,12 +433,12 @@ export const InputBar = memo(function InputBar({
{sessionId === undefined ? null : renderSlot('conversation.input.permission', { locked })}
{sessionId === undefined ? null : renderSlot('conversation.input.plan', { locked })}
</div>
{sessionId === undefined
{input === undefined || sessionId === undefined
? null
: renderSlot('conversation.input.left', {})}
</div>
<div className={css.trailing}>
{sessionId === undefined
{input === undefined || sessionId === undefined
? null
: renderSlot('conversation.input.right', {})}
{sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })}
@@ -465,7 +482,7 @@ export const InputBar = memo(function InputBar({
</div>
</div>
<div className={css.dock}>
{variant === 'composer' && sessionId !== undefined
{variant === 'composer' && input !== undefined && sessionId !== undefined
? renderSlot('conversation.composer.dock', {})
: null}
<ContextMeter useProjection={useProjection} t={t} />
@@ -2,7 +2,6 @@
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/client'
import type { UiWorkspace } from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
@@ -74,14 +73,12 @@ async function bench() {
opened(id)
}
const openSession = vi.fn((id: SessionId) => { replaceMain(id) })
const openDefaultWorkspace = vi.fn<UiWorkspace['openDefaultWorkspace']>(async () => undefined)
runtime.ctx.provide('uiWorkspace', {
openWorkspace: async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
const id = await connectWorkspace()
replaceMain(id, beforeOpen)
},
openSession,
openDefaultWorkspace,
} as never)
const sessionFake = sessionFakeFor()
await runtime.sessions.add({
@@ -139,113 +136,10 @@ async function bench() {
return {
runtime, feature, slots: runtime.slots, entryOf, conversationApi, headerApi, residentApi, composerApi,
inputApi, viewSource, sessionFake, connectWorkspace, rootUpload, uploads, rootReference, references, opened,
locale, openDefaultWorkspace, replaceMain,
}
}
describe('Conversation inject API', () => {
it.each([false, true])('keeps the saved first draft after its Session retires (reopened during preparation: %s)', async (reopenEarly) => {
const b = await bench()
onTestFinished(() => b.runtime.dispose())
const settings = Promise.withResolvers<undefined>()
const started = Promise.withResolvers<undefined>()
b.runtime.ctx.on('conversation/prepare-first-send', () => {
started.resolve(undefined)
return settings.promise
})
b.openDefaultWorkspace.mockImplementation(async (_request, beforeOpen) => {
b.replaceMain(ROOT, beforeOpen)
return ROOT
})
const body = b.conversationApi(ROOT)
const unmirror = body.injected.bindDraftMirror(body.instance.actions.setDraft)
const composer = b.composerApi(undefined)
let reopened: SessionReference | undefined
try {
composer.keyboard.paste('keep this first draft')
composer.keyboard.submit('queue')
await started.promise
expect(body.instance.store.getSnapshot().draft).toBe('keep this first draft')
const other = 'other-first-send' as SessionId
await b.runtime.sessions.add({ id: other, session: sessionFakeFor() })
unmirror()
b.rootReference.release()
b.replaceMain(other)
await vi.waitFor(() => { expect(b.runtime.sessions.binding(ROOT)).toBeUndefined() })
if (reopenEarly) reopened = b.runtime.sessions.retain(ROOT)
settings.resolve(undefined)
await vi.waitFor(() => { expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) })
await b.runtime.flush()
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
reopened ??= b.runtime.sessions.retain(ROOT)
b.references.set(ROOT, reopened)
expect(b.conversationApi(ROOT).instance.store.getSnapshot().draft).toBe('keep this first draft')
expect(composer.hooks.composerInput.getSnapshot().draft).toBe('')
} finally {
settings.resolve(undefined)
reopened?.release()
unmirror()
}
})
it('submits the first draft through its retained destination input', async () => {
const b = await bench()
onTestFinished(() => b.runtime.dispose())
b.openDefaultWorkspace.mockImplementation(async (_request, beforeOpen) => {
b.replaceMain(ROOT, beforeOpen)
return ROOT
})
const composer = b.composerApi(undefined)
composer.keyboard.paste('first message')
composer.keyboard.submit('queue')
await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledOnce() })
expect(b.sessionFake.prompt.mock.calls[0]?.[0]).toEqual([{ type: 'text', text: 'first message' }])
expect(composer.hooks.composerInput.getSnapshot().draft).toBe('')
})
it.each([
['zh', '默认工作区', '默认工作区'],
['en', 'Default workspace', 'Default workspace'],
['ja', 'default-workspace', 'Default workspace'],
])('resolves first-use names in the Client using the send-time locale %s', async (language, directoryName, title) => {
const b = await bench()
if (language === 'ja') b.locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' })
const composer = b.composerApi(undefined)
composer.keyboard.paste('first message')
b.locale.setLocale(language)
composer.keyboard.submit('steer')
await vi.waitFor(() => {
expect(b.openDefaultWorkspace).toHaveBeenCalledWith({ directoryName, title }, expect.any(Function), expect.any(AbortSignal))
expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false)
})
})
it('keeps in-flight names fixed and resolves the new locale when retrying a failed first send', async () => {
const b = await bench()
const pending = Promise.withResolvers<SessionId | undefined>()
b.openDefaultWorkspace.mockReturnValueOnce(pending.promise)
const composer = b.composerApi(undefined)
b.locale.setLocale('en')
composer.keyboard.paste('keep this draft')
composer.keyboard.submit('steer')
try {
await vi.waitFor(() => { expect(b.openDefaultWorkspace).toHaveBeenCalledOnce() })
b.locale.setLocale('zh')
expect(b.openDefaultWorkspace.mock.calls[0]?.[0]).toEqual({ directoryName: 'Default workspace', title: 'Default workspace' })
pending.reject(new Error('directory unavailable'))
await vi.waitFor(() => { expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) })
expect(composer.hooks.composerInput.getSnapshot().draft).toBe('keep this draft')
composer.keyboard.submit('steer')
await vi.waitFor(() => {
expect(b.openDefaultWorkspace).toHaveBeenLastCalledWith({ directoryName: '默认工作区', title: '默认工作区' }, expect.any(Function), expect.any(AbortSignal))
expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false)
})
} finally {
pending.resolve(undefined)
await vi.waitFor(() => { expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) })
}
})
it('owns the File action, reads its mounted composer availability, and unregisters on disposal', async () => {
const b = await bench()
onTestFinished(() => b.runtime.dispose())
@@ -265,7 +159,7 @@ describe('Conversation inject API', () => {
expect(file.available({ sessionId: 'missing' as SessionId })).toBe(false)
if (file.ui.kind !== 'action') throw new Error('File must be an action')
file.ui.run({ sessionId: 'missing' as SessionId })
const keyboard = b.composerApi(ROOT).keyboard
const keyboard = b.composerApi(ROOT).keyboard!
const open = vi.fn()
let available = true
const unbind = keyboard.bindFilePicker({ open, available: () => available })
@@ -514,8 +408,7 @@ describe('Conversation inject API', () => {
expect(() => { injectBar('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/)
const absent = injectBar(undefined)
expect(absent.keyboard).toBeDefined()
expect(absent.hooks.composerInput.getSnapshot().draft).toBe('')
expect(absent.keyboard).toBeUndefined()
expect(absent.toggleCommandMenu).toBeUndefined()
expect(absent.stop).toBeUndefined()
expect(absent.hooks.notices.getSnapshot()).toBeNull()
@@ -100,10 +100,8 @@ async function bench(opts?: { blank?: boolean }) {
}
describe('resident composer', () => {
it('accepts a local draft before any Session exists and keeps the Workspace chip available', async () => {
it('renders the locked view state while no session exists at all', async () => {
const runtime = await SlotTestRuntime.create()
runtime.sessions.list.set({ ...runtime.sessions.list.getSnapshot(), phase: 'pending' })
await runtime.workspaces.update((draft) => { draft.phase = 'pending' })
provideWorkspaceNavigation(runtime)
runtime.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
@@ -115,21 +113,15 @@ describe('resident composer', () => {
const view = runtime.renderRoot()
const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')
expect(textarea).not.toBeNull()
expect(textarea!.getAttribute('contenteditable')).toBe('false')
await runtime.workspaces.update((draft) => { draft.phase = 'ready' })
expect(textarea!.getAttribute('contenteditable')).toBe('false')
await act(async () => {
runtime.sessions.list.set({ ...runtime.sessions.list.getSnapshot(), phase: 'ready' })
})
expect(textarea!.getAttribute('aria-disabled')).not.toBe('true')
expect(textarea!.getAttribute('contenteditable')).toBe('true')
expect(textarea!.getAttribute('aria-haspopup')).toBeNull()
expect(textarea!.getAttribute('contenteditable')).not.toBe('true')
expect(textarea!.getAttribute('aria-haspopup')).toBe('menu')
expect(view.getByTestId('workspace-probe').textContent).toBe('false:0')
fireEvent.click(view.getByRole('button', { name: '选择工作区' }))
fireEvent.click(textarea!)
expect(view.getByTestId('workspace-probe').textContent).toBe('true:0')
expect(textarea!.getAttribute('aria-expanded')).toBeNull()
fireEvent.click(view.getByRole('button', { name: '选择工作区' }))
expect(textarea!.getAttribute('aria-expanded')).toBe('true')
fireEvent.click(view.getByRole('button', { name: '选择工作区' }))
fireEvent.keyDown(textarea!, { key: 'Enter' })
expect(view.getByTestId('workspace-probe').textContent).toBe('true:0')
expect(view.getByRole('button', { name: '选择工作区' })).toBeTruthy()
await runtime.dispose()
@@ -157,7 +149,7 @@ describe('resident composer', () => {
const workspaceChip = view.getByRole('button', { name: '选择工作区' })
const workspaceProbe = view.getByTestId('workspace-probe')
expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
expect(textarea.getAttribute('contenteditable')).toBe('true')
expect(textarea.getAttribute('contenteditable')).not.toBe('true')
fireEvent.click(workspaceChip)
fireEvent.click(workspaceProbe)
@@ -1,141 +0,0 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { FirstDraft } from '../src/client/input/first-draft.ts'
import { SessionInputShell } from '../src/client/input/facade.ts'
import type { SubmitOutcome } from '../src/client/contract/input.ts'
const contexts: Context[] = []
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
})
function harness() {
const ctx = new Context()
contexts.push(ctx)
const opened = Promise.withResolvers<SessionId | undefined>()
ctx.effect(() => () => { opened.resolve(undefined) })
const submitted = vi.fn(async (): Promise<SubmitOutcome> => ({ kind: 'success' }))
const target = new SessionInputShell({
actx: ctx,
defaultSink: submitted,
commandAttachments: { serialize: async () => [], release: () => {}, unsupportedNotice: () => '' },
})
ctx.effect(() => () => { target.dispose() })
let beforeOpen: ((id: SessionId) => void) | undefined
const open = vi.fn((prepare: (id: SessionId) => void) => {
beforeOpen = prepare
return opened.promise
})
const first = new FirstDraft(ctx, { open, shell: () => target, isCurrent: () => true })
return {
ctx, first, target, submitted, open, opened,
complete: () => {
const id = SessionId('default')
beforeOpen!(id)
opened.resolve(id)
},
}
}
describe('first message draft', () => {
it('retains a local draft without opening a Session until send', async () => {
const h = harness()
h.first.shell.setDraft('first message')
expect(h.open).not.toHaveBeenCalled()
h.first.shell.submit('steer')
expect(h.open).toHaveBeenCalledTimes(1)
expect(h.first.state.getSnapshot().busy).toBe(true)
h.first.shell.submit('steer')
expect(h.open).toHaveBeenCalledTimes(1)
h.complete()
await vi.waitFor(() => { expect(h.submitted).toHaveBeenCalledTimes(1) })
expect(h.submitted.mock.calls[0]).toEqual(['first message', [], 'steer', expect.any(AbortSignal)])
expect(h.first.state.getSnapshot()).toEqual({ busy: false, failed: false })
})
it.each([false, true])('waits for first-use settings and keeps the Session draft on refusal (refused: %s)', async (refused) => {
const h = harness()
const settings = Promise.withResolvers<undefined>()
const started = Promise.withResolvers<SessionId>()
h.ctx.on('conversation/prepare-first-send', (id) => {
started.resolve(id)
return settings.promise
})
try {
h.first.shell.setDraft('use my selected mode')
h.first.shell.submit()
h.complete()
expect(await started.promise).toBe(SessionId('default'))
expect(h.submitted).not.toHaveBeenCalled()
if (refused) settings.reject(new Error('preset refused'))
else settings.resolve(undefined)
await vi.waitFor(() => { expect(h.first.state.getSnapshot().busy).toBe(false) })
expect(h.first.state.getSnapshot().failed).toBe(false)
if (refused) {
expect(h.submitted).not.toHaveBeenCalled()
expect(h.target.snapshot.draft).toBe('use my selected mode')
expect(h.target.notices.getSnapshot()?.text).toBe('preset refused')
} else {
expect(h.submitted).toHaveBeenCalledOnce()
}
} finally {
settings.resolve(undefined)
await vi.waitFor(() => { expect(h.first.state.getSnapshot().busy).toBe(false) })
}
})
it('restores input on preparation failure and dismisses the modal without losing it', async () => {
const h = harness()
h.first.shell.setDraft('preserve me')
h.first.shell.submit()
h.opened.reject(new Error('permission denied'))
await vi.waitFor(() => { expect(h.first.shell.snapshot.draft).toBe('preserve me') })
expect(h.first.state.getSnapshot()).toEqual({ busy: false, failed: true })
expect(h.submitted).not.toHaveBeenCalled()
h.first.dismiss()
expect(h.first.state.getSnapshot().failed).toBe(false)
expect(h.first.shell.snapshot.draft).toBe('preserve me')
})
it('hands a draft to a manual selection without sending it', async () => {
const h = harness()
h.first.shell.setDraft('manual folder')
h.first.transfer(SessionId('manual'))
expect(h.target.snapshot.draft).toBe('manual folder')
expect(h.first.shell.snapshot.draft).toBe('')
expect(h.submitted).not.toHaveBeenCalled()
})
it('lets manual navigation take the pending draft and cancels automatic submission', async () => {
const h = harness()
h.first.shell.setDraft('pending text')
h.first.shell.submit()
h.first.transfer(SessionId('manual'))
h.opened.resolve(undefined)
await vi.waitFor(() => { expect(h.first.state.getSnapshot().busy).toBe(false) })
expect(h.target.snapshot.draft).toBe('pending text')
expect(h.first.shell.snapshot.draft).toBe('')
expect(h.submitted).not.toHaveBeenCalled()
})
it('restores a superseded draft without a directory error', async () => {
const h = harness()
h.first.shell.setDraft('later')
h.first.shell.submit()
h.opened.resolve(undefined)
await vi.waitFor(() => { expect(h.first.shell.snapshot.draft).toBe('later') })
expect(h.first.state.getSnapshot().failed).toBe(false)
})
it('leaves ordinary send failures in the selected Session input', async () => {
const h = harness()
h.submitted.mockResolvedValue({ kind: 'error' })
h.first.shell.setDraft('retry the prompt')
h.first.shell.submit()
h.complete()
await vi.waitFor(() => { expect(h.target.snapshot.draft).toBe('retry the prompt') })
expect(h.first.shell.snapshot.draft).toBe('')
expect(h.first.state.getSnapshot().failed).toBe(false)
})
})
@@ -185,7 +185,6 @@ function bench(over?: BenchOptions) {
? over?.plan
: key === 'goal' ? over?.goal
: key === 'imageLimits' ? over?.imageLimits : undefined)),
useComposerInput: bindSnapshotSelector(shell.state),
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
@@ -67,7 +67,6 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
})),
useProjection: (() => undefined),
useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())),
useComposerInput: bindSnapshotSelector(shell.state),
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
@@ -161,7 +161,6 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
})),
useProjection: (() => undefined),
useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())),
useComposerInput: bindSnapshotSelector(shell.state),
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
@@ -137,8 +137,6 @@ function mount(
nestedSubagent?: boolean
/** A composer block another plugin raised for this session. */
composerBlock?: { reason: string }
/** First-use preparation remains active after selecting its Session. */
preparingFirstDraft?: boolean
/** Mutable view ledger used by registration-order regressions. */
viewTabs?: ViewTab[]
} = {},
@@ -282,7 +280,6 @@ function mount(
useProjection={(() => undefined)}
useInput={useInput}
inputActions={inputActions}
useComposerInput={useInput}
keyboard={wiring}
addFiles={() => null}
useFileUploads={bindSnapshotSelector(createSnapshotStore({}))}
@@ -339,8 +336,6 @@ function mount(
useWorkspaces: bindSnapshotSelector(workspaces),
useProjection: (() => undefined),
useComposerBlock: select => select(options.composerBlock),
useFirstDraft: select => select({ busy: options.preparingFirstDraft === true, failed: false }),
dismissDefaultFailure: () => {},
useInput,
inputActions,
renderSlot,
@@ -410,18 +405,6 @@ describe('ConversationRoot resident composer', () => {
expect(b.view.queryByTestId('view-conversation.session.header.corner')).toBeNull()
})
it('locks the selected Session draft until automatic first-use preparation settles', () => {
const b = mount(sessionSnapshotOf({ blank: true }), undefined, undefined, {
summaryBlank: true, preparingFirstDraft: true,
})
const box = b.view.getByRole('textbox')
expect(box.getAttribute('aria-disabled')).toBe('true')
expect(box.getAttribute('aria-label')).toBe('正在准备工作区…')
expect(box.getAttribute('aria-haspopup')).toBeNull()
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.sink).not.toHaveBeenCalled()
})
it('does not redispatch composer child slots for an unrelated Session publication', () => {
const b = mount(sessionSnapshotOf())
const childKeys = new Set([
@@ -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: 03dc8a9ff45fbc1fc1ab8585db15c19437807f5e
README.zh.md: ac8000f78d3a9394ec2c83790aa3e0349cac2ab0
README.md: 768c66503af70d0b587b25c09aa8412b0a647439
README.zh.md: 25df6f8e6a2ec0a001d784c7bde18e454e9d706f
+3 -1
View File
@@ -69,7 +69,9 @@ The value is intentionally best effort for cold Sessions. An identity-matching u
New Session tries to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request and are currently reported only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection.
`openDefaultWorkspace(request, beforeOpen, signal)` initializes the Host default Workspace from the requested directory name and title before connecting its Session. It captures navigation cancellation before directory preparation; superseded work cannot transfer the draft or select its Session. A successfully registered Workspace remains available when Session creation or later submission fails. [Conversation](../ui-conversation/README.md) owns the first-use draft and failure dialog.
Once both Workspace and Session startup baselines are ready, an empty installation calls `workspaces.initializeDefault` and creates or reuses its blank Session. The composer becomes editable when that Session is selected; no message is submitted automatically. Later navigation or owner disposal prevents startup from selecting its result. Ineligible first use leaves the folder picker available without an error. Preparation failure offers Cancel or Choose folder through the existing picker, and is not retried until the next startup. A successfully registered Workspace remains available when Session creation or later submission fails.
The Client chooses the initial directory name and title from its language at startup: Chinese uses `默认工作区`, English uses `Default workspace`, and other languages use directory `default-workspace` with title `Default workspace`. The request retains those names during initialization. A successfully initialized Workspace keeps its directory and title across language changes.
<a id="understand-the-implementation"></a>
## Understand the implementation
+3 -1
View File
@@ -69,7 +69,9 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
新建会话尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求,目前只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。
`openDefaultWorkspace(request, beforeOpen, signal)` 在连接 Session 前,按请求中的目录名和标题初始化 Host 默认工作区。它在目录准备之前捕获导航取消信号;被取代的操作不能转移草稿或选中 Session。登记成功的工作区在 Session 创建或后续提交失败时仍然保留。[Conversation](../ui-conversation/README.zh.md) 负责首次使用草稿和失败弹窗。
Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `workspaces.initializeDefault`,创建或复用其空白 Session。选中该 Session 后输入框才可编辑,不会自动提交消息。后续导航或所属上下文销毁会阻止启动流程选中其结果。不符合首次使用条件时仍可选择文件夹,不显示错误。准备失败时通过现有选择器提供取消或选择文件夹操作,直到下次启动才重试。登记成功的工作区在 Session 创建或后续提交失败时仍然保留。
Client 在启动时按其语言选择初始目录名和标题:中文使用 `默认工作区`,英文使用 `Default workspace`,其他语言使用目录 `default-workspace` 和标题 `Default workspace`。初始化过程中保留请求中的名称。成功初始化的工作区在切换语言后保留原目录和标题。
<a id="understand-the-implementation"></a>
## 理解实现
@@ -231,21 +231,45 @@ export function WorkspacePicker({
onClose,
createWorkspace,
useDirectoryFlow,
useDefaultFailure,
dismissDefaultFailure,
renderSlot,
t,
}: WorkspacePickerProps) {
const failed = useDefaultFailure(value => value)
const flowAvailable = useDirectoryFlow(value => value)
const [recovering, setRecovering] = useState(false)
return (
<WorkspacePickFlow
t={t}
open={open}
anchorRef={anchorRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
useDirectoryFlow={useDirectoryFlow}
renderDirectoryFlow={owner => renderSlot('conversation.hero.workspace.directoryFlow', owner)}
selectedId={selectedId}
onPick={onPick}
onClose={onClose}
/>
<>
<WorkspacePickFlow
t={t}
open={open || recovering}
anchorRef={anchorRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
useDirectoryFlow={useDirectoryFlow}
renderDirectoryFlow={owner => renderSlot('conversation.hero.workspace.directoryFlow', owner)}
selectedId={selectedId}
onPick={onPick}
onClose={() => { setRecovering(false); onClose() }}
/>
<Modal
open={failed}
title={t('defaultWorkspace.failed')}
closeLabel={t('close')}
onClose={dismissDefaultFailure}
footer={(
<>
<Button variant="outline" onClick={dismissDefaultFailure}>{t('cancel')}</Button>
<Button variant="primary" disabled={!flowAvailable} onClick={() => {
dismissDefaultFailure()
setRecovering(true)
}}>{t('defaultWorkspace.choose')}</Button>
</>
)}
>
{t('defaultWorkspace.retry')}
</Modal>
</>
)
}
@@ -364,6 +364,12 @@ export type WorkspaceBrowserProps =
* supplies the implicit index signature required by the registry.
*/
export type WorkspacePickerInjected = DirectoryPickingInjected & {
/** Dismiss the startup failure before choosing a directory manually. */
dismissDefaultFailure: () => void
hooks: {
/** Whether first-use startup failed to prepare a Workspace and Session. */
defaultFailure: HostObservable<boolean>
}
/** Adopt a picked host directory as a real Workspace before targeting a Session. */
createWorkspace: (input: { path: string }) => Promise<WorkspaceView>
}
@@ -377,5 +383,5 @@ export type WorkspacePickerProps =
PropsRuntime<'conversation.hero.workspace'>
& PropsRenderSlots<'conversation.hero.workspace.directoryFlow'>
& Omit<WorkspacePickerInjected, 'hooks'>
& DirectoryPickingHooks
& PropsHooks<WorkspacePickerInjected['hooks']>
& PropsLocale<'workspace'>
@@ -219,7 +219,8 @@ export function apply(ctx: Context): void {
})
const pickerInjected = (): WorkspacePickerInjected => ({
createWorkspace: input => workspaces.create(input),
hooks: { directoryFlow: pickerFlowSource },
dismissDefaultFailure: () => { uiWorkspace.defaultFailure.set(false) },
hooks: { directoryFlow: pickerFlowSource, defaultFailure: uiWorkspace.defaultFailure },
})
// Each registration declares its owned children in the same call; slot
// injection follows both the owner and declaration HMR lifetimes.
@@ -6,6 +6,10 @@
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'defaultWorkspace.failed': '无法创建默认工作区',
'defaultWorkspace.title': '默认工作区',
'defaultWorkspace.retry': '请选择一个文件夹后重试',
'defaultWorkspace.choose': '选择文件夹',
'group.ungrouped': '未分组',
'session.new': '新会话',
'section.workspaces': '工作区',
@@ -99,6 +103,10 @@ export type WorkspaceKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'defaultWorkspace.failed': 'Unable to create default workspace',
'defaultWorkspace.title': 'Default workspace',
'defaultWorkspace.retry': 'Please choose a folder and try again',
'defaultWorkspace.choose': 'Choose folder',
'group.ungrouped': 'Ungrouped',
'session.new': 'New Session',
'section.workspaces': 'Workspaces',
@@ -12,10 +12,12 @@ import type {
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import type {
IWorkspaces, WorkspaceId, WorkspaceInitializeDefaultRequest, WorkspaceSnapshot, WorkspaceView,
IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView,
} from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { en, zh } from './locales.ts'
import { pinOrderAccounts, pinOrderSource } from './pin-order.ts'
import type { WorkspaceViewStoreActions } from './stores.ts'
@@ -38,18 +40,6 @@ export interface UiWorkspace {
* @returns completion; a superseded request may create a Session but does not open it.
*/
openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise<void>
/**
* Prepare the first-use Workspace and select its blank Session.
* @param request - initial directory name and title.
* @param beforeOpen - synchronous draft transfer after retaining the Session.
* @param signal - caller cancellation.
* @returns selected Session, or undefined after navigation supersession.
*/
openDefaultWorkspace(
request: WorkspaceInitializeDefaultRequest,
beforeOpen: (sessionId: SessionId) => void,
signal: AbortSignal,
): Promise<SessionId | undefined>
/**
* Fork a Session without changing the current selection.
* @param sessionId - source Session.
@@ -130,6 +120,8 @@ export class DirectoryBrowseError extends Error {
/** Implements Workspace archive and directory UI operations. */
class UiWorkspaceService extends Service implements UiWorkspace {
/** Startup failure awaiting dismissal or explicit directory selection. */
readonly defaultFailure = createSnapshotStore(false)
private readonly connecting = new Map<WorkspaceId, Promise<SessionId>>()
private readonly lifetime = new AbortController()
private readonly selection = createSnapshotStore<MainSelection>(
@@ -213,25 +205,6 @@ class UiWorkspaceService extends Service implements UiWorkspace {
this.replaceMain(sessionId, navigation, 'reveal', beforeOpen)
}
async openDefaultWorkspace(
request: WorkspaceInitializeDefaultRequest,
beforeOpen: (sessionId: SessionId) => void,
signal: AbortSignal,
): Promise<SessionId | undefined> {
const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal, signal])
try {
const workspace = await this.workspaces.initializeDefault(request, navigation)
navigation.throwIfAborted()
const sessionId = await this.connectWorkspace(workspace.workspaceId)
navigation.throwIfAborted()
this.replaceMain(sessionId, navigation, 'reveal', beforeOpen)
return sessionId
} catch (error) {
if (navigation.aborted) return undefined
throw error
}
}
async forkSession(sessionId: SessionId): Promise<void> {
await this.sessions.fork({ sessionId, increaseTitle: true })
}
@@ -353,11 +326,30 @@ class UiWorkspaceService extends Service implements UiWorkspace {
}
const target = workspace?.workspaceId ?? recentWorkspace(workspaces.items, sessions.byId)
if (sessionId === undefined && target !== undefined) sessionId = await this.connectWorkspace(target)
if (target === undefined && workspaces.items.length === 0 && sessions.ids.length === 0) {
sessionId = await this.initializeDefaultWorkspace(navigation)
}
if (sessionId !== undefined && !navigation.aborted) {
this.replaceMain(sessionId, navigation, 'preserve')
}
}
private async initializeDefaultWorkspace(signal: AbortSignal): Promise<SessionId | undefined> {
const language = this.ctx.locale.getSnapshot().active.toLowerCase().split('-')[0]
const title = (language === 'zh' ? zh : en)['defaultWorkspace.title']
try {
const workspace = await this.workspaces.initializeDefault({
directoryName: language === 'zh' || language === 'en' ? title : 'default-workspace',
title,
}, signal)
if (workspace === undefined || signal.aborted) return undefined
return await this.connectWorkspace(workspace.workspaceId)
} catch (_error: unknown) {
if (!signal.aborted) this.defaultFailure.set(true)
return undefined
}
}
/** @returns true when an archived current selection was cleared. */
private clearArchivedCurrent(): boolean {
const current = this.mainReference?.sessionId
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type {
@@ -88,14 +89,19 @@ function mount(
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
createWorkspace = vi.fn(),
occupancy = occupancySource(),
options: { defaultFailure?: boolean; open?: boolean } = {},
) {
const failure = createSnapshotStore(options.defaultFailure ?? false)
const useDefaultFailure = bindSnapshotSelector(failure)
const dismissDefaultFailure = vi.fn(() => { failure.set(false) })
const onPick = vi.fn()
const onClose = vi.fn()
const anchorRef = anchor()
const { probe, renderSlot } = flowProbe()
const renderPicker = (nextItems: readonly WorkspaceView[]) => (
<WorkspacePicker
open
useDefaultFailure={useDefaultFailure} dismissDefaultFailure={dismissDefaultFailure}
open={options.open ?? true}
anchorRef={anchorRef}
useSessions={hook(sessions)}
useSessionStatus={hook(noPendingInteraction)}
@@ -114,7 +120,7 @@ function mount(
renderPicker(items),
)
return {
view, onPick, onClose, createWorkspace, probe, occupancy,
view, onPick, onClose, createWorkspace, probe, occupancy, dismissDefaultFailure,
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
}
}
@@ -220,6 +226,7 @@ describe('WorkspacePicker', () => {
const { renderSlot } = flowProbe()
render(
<WorkspacePicker
useDefaultFailure={hook(false)} dismissDefaultFailure={vi.fn()}
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([workspace('alpha', 'Alpha')]))}
useSessionStatus={hook(noPendingInteraction)}
useSessionRetainInfo={() => undefined}
@@ -238,6 +245,7 @@ describe('WorkspacePicker', () => {
const { renderSlot } = flowProbe()
render(
<WorkspacePicker
useDefaultFailure={hook(false)} dismissDefaultFailure={vi.fn()}
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
useSessionStatus={hook(noPendingInteraction)}
useSessionRetainInfo={() => undefined}
@@ -321,3 +329,26 @@ describe('WorkspacePicker', () => {
expect(screen.queryByRole('menuitem', { name: '添加工作区…' })).toBeNull()
})
})
describe('default Workspace startup recovery', () => {
it('dismisses the failure without opening the directory flow', () => {
const b = mount([], vi.fn(), occupancySource(), { defaultFailure: true, open: false })
expect(screen.getByRole('dialog', { name: '无法创建默认工作区' })).toBeTruthy()
expect(b.probe.owner?.open).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(b.dismissDefaultFailure).toHaveBeenCalledOnce()
expect(screen.queryByRole('dialog', { name: '无法创建默认工作区' })).toBeNull()
expect(b.probe.owner?.open).toBe(false)
})
it('opens the composed directory flow from the startup failure', async () => {
const create = vi.fn(async () => workspace('chosen'))
const b = mount([], create, occupancySource(), { defaultFailure: true, open: false })
fireEvent.click(screen.getByRole('button', { name: '选择文件夹' }))
expect(b.dismissDefaultFailure).toHaveBeenCalledOnce()
expect(b.probe.owner?.open).toBe(true)
await act(async () => { b.probe.owner!.onPicked('/chosen') })
expect(create).toHaveBeenCalledExactlyOnceWith({ path: '/chosen' })
expect(b.onPick).toHaveBeenCalledExactlyOnceWith(wid('chosen'))
})
})
@@ -1,4 +1,6 @@
import { setImmediate } from 'node:timers/promises'
import { Context } from '@deepseek-ai/cordis'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
ISessions, SessionListState, SessionReference, SessionSummary,
@@ -169,11 +171,7 @@ class FakeSessions implements ISessions {
}
class FakeWorkspaces implements IWorkspaces {
readonly initializeDefault = vi.fn<IWorkspaces['initializeDefault']>(async () => {
const item = workspace('default')
this.list.set(workspaceState([item]))
return item
})
readonly initializeDefault = vi.fn<IWorkspaces['initializeDefault']>(async () => undefined)
readonly list: MutableSource<WorkspaceSnapshot>
readonly archiveCalls: SessionId[] = []
readonly unarchiveCalls: SessionId[] = []
@@ -268,6 +266,8 @@ class FakeDirectoryPicker {
}
interface BenchOptions {
readonly language?: string
readonly configureWorkspaces?: (workspaces: FakeWorkspaces) => void
readonly workspaces?: WorkspaceSnapshot
readonly sessions?: SessionListState
readonly configureSessions?: (sessions: FakeSessions) => void
@@ -276,6 +276,10 @@ interface BenchOptions {
function bench(options: BenchOptions = {}) {
const ctx = new Context()
contexts.push(ctx)
const locale = new LocaleRuntime(ctx)
if (options.language === 'fr') locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' })
locale.setLocale(options.language ?? 'en')
ctx.provide('locale', locale)
const layout = new LayoutController({
selectPanel: vi.fn(), retainMainPanels: vi.fn(),
setSidebar: vi.fn(), toggleSidebar: vi.fn(), setViewportWidth: vi.fn(),
@@ -287,6 +291,7 @@ function bench(options: BenchOptions = {}) {
const directoryPicker = new FakeDirectoryPicker()
const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending'))
const sessions = new FakeSessions(options.sessions ?? sessionState([], 'pending'))
options.configureWorkspaces?.(workspaces)
options.configureSessions?.(sessions)
const view = createWorkspaceViewStore().create()
const uiWorkspace = new UiWorkspaceService(
@@ -300,39 +305,95 @@ function bench(options: BenchOptions = {}) {
}
describe('UiWorkspaceService', () => {
it('prepares the default Workspace before creating and retaining its Session', async () => {
const b = bench({ workspaces: workspaceState(), sessions: sessionState() })
const beforeOpen = vi.fn(() => {
expect(b.sessions.retain).toHaveBeenCalledWith(sid('created-default'), { source: 'mainView' })
it.each([
['zh', '默认工作区', '默认工作区'],
['en', 'Default workspace', 'Default workspace'],
['fr', 'default-workspace', 'Default workspace'],
])('prepares and selects the default Workspace after both startup baselines (%s)', async (language, directoryName, title) => {
const b = bench({ language, configureWorkspaces: (workspaces) => {
workspaces.initializeDefault.mockImplementation(async () => {
const item = workspace('default')
workspaces.list.set(workspaceState([item]))
return item
})
} })
expect(b.workspaces.initializeDefault).not.toHaveBeenCalled()
b.sessions.list.set(sessionState())
expect(b.workspaces.initializeDefault).not.toHaveBeenCalled()
b.workspaces.list.set(workspaceState())
await vi.waitFor(() => {
expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('created-default'), { source: 'mainView' })
})
await expect(b.uiWorkspace.openDefaultWorkspace({ directoryName: '默认工作区', title: '默认工作区' }, beforeOpen, new AbortController().signal))
.resolves.toBe(sid('created-default'))
expect(b.workspaces.initializeDefault).toHaveBeenCalledWith({ directoryName: '默认工作区', title: '默认工作区' }, expect.any(AbortSignal))
expect(b.workspaces.initializeDefault).toHaveBeenCalledExactlyOnceWith({ directoryName, title }, expect.any(AbortSignal))
expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('default') })
expect(beforeOpen).toHaveBeenCalledWith(sid('created-default'))
expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(false)
})
it('does not create a Session when default preparation fails or is superseded', async () => {
it('leaves an ineligible empty installation without a Session or failure dialog', async () => {
const b = bench({ workspaces: workspaceState(), sessions: sessionState() })
b.workspaces.initializeDefault.mockRejectedValueOnce(new Error('denied'))
await expect(b.uiWorkspace.openDefaultWorkspace({ directoryName: 'Default workspace', title: 'Default workspace' }, vi.fn(), new AbortController().signal)).rejects.toThrow('denied')
await setImmediate()
b.workspaces.list.set(workspaceState())
b.sessions.list.set(sessionState())
await setImmediate()
expect(b.workspaces.initializeDefault).toHaveBeenCalledOnce()
expect(b.sessions.create).not.toHaveBeenCalled()
expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(false)
})
it('requires explicit selection when the startup Session list contains history', async () => {
const b = bench({ workspaces: workspaceState(), sessions: sessionState([summary('history')]) })
await setImmediate()
expect(b.workspaces.initializeDefault).not.toHaveBeenCalled()
expect(b.sessions.create).not.toHaveBeenCalled()
})
it('publishes a startup directory failure once without creating a Session', async () => {
const b = bench({ workspaces: workspaceState(), sessions: sessionState(), configureWorkspaces: (workspaces) => {
workspaces.initializeDefault.mockRejectedValueOnce(new Error('denied'))
} })
await vi.waitFor(() => { expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(true) })
b.uiWorkspace.defaultFailure.set(false)
b.workspaces.list.set(workspaceState())
await setImmediate()
expect(b.workspaces.initializeDefault).toHaveBeenCalledOnce()
expect(b.sessions.create).not.toHaveBeenCalled()
expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(false)
})
it.each(['session', 'panel', 'disposal'] as const)('cancels startup directory preparation after %s navigation', async (kind) => {
const pending = Promise.withResolvers<WorkspaceView>()
b.workspaces.initializeDefault.mockReturnValueOnce(pending.promise)
const beforeOpen = vi.fn()
const opening = b.uiWorkspace.openDefaultWorkspace({ directoryName: 'Default workspace', title: 'Default workspace' }, beforeOpen, new AbortController().signal)
b.uiWorkspace.openSession(sid('manual'))
const b = bench({ workspaces: workspaceState(), sessions: sessionState(), configureWorkspaces: (workspaces) => {
workspaces.initializeDefault.mockReturnValueOnce(pending.promise)
} })
if (kind === 'session') b.uiWorkspace.openSession(sid('manual'))
else if (kind === 'panel') b.layout.selectPanel('other-panel' as MainPanelId)
else await b.ctx.fiber.dispose()
expect(b.workspaces.initializeDefault.mock.calls[0]![1]?.aborted).toBe(true)
pending.resolve(workspace('default'))
await expect(opening).resolves.toBeUndefined()
await setImmediate()
expect(b.sessions.create).not.toHaveBeenCalled()
expect(beforeOpen).not.toHaveBeenCalled()
expect(b.sessions.retain.mock.calls.map(args => args[0])).toEqual(kind === 'session' ? [sid('manual')] : [])
expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(false)
})
it('retains the prepared Workspace if creating its Session fails', async () => {
const b = bench({ workspaces: workspaceState(), sessions: sessionState() })
b.sessions.create.mockRejectedValueOnce(new Error('session failed'))
await expect(b.uiWorkspace.openDefaultWorkspace({ directoryName: 'Default workspace', title: 'Default workspace' }, vi.fn(), new AbortController().signal)).rejects.toThrow('session failed')
expect(b.workspaces.list.getSnapshot().items).toEqual([workspace('default')])
it('retains a prepared Workspace when its Session fails and suppresses a superseded failure', async () => {
for (const superseded of [false, true]) {
const created = Promise.withResolvers<SessionId>()
const b = bench({ workspaces: workspaceState(), sessions: sessionState(), configureWorkspaces: (workspaces) => {
workspaces.initializeDefault.mockImplementationOnce(async () => {
const item = workspace('default')
workspaces.list.set(workspaceState([item]))
return item
})
}, configureSessions: (sessions) => { sessions.create.mockReturnValueOnce(created.promise) } })
await vi.waitFor(() => { expect(b.sessions.create).toHaveBeenCalledOnce() })
if (superseded) b.layout.selectPanel('other-panel' as MainPanelId)
created.reject(new Error('session failed'))
await setImmediate()
expect(b.workspaces.list.getSnapshot().items).toEqual([workspace('default')])
expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(!superseded)
expect(b.sessions.retain).not.toHaveBeenCalled()
}
})
it('retains an explicit main target before revealing its Conversation', () => {
@@ -3251,10 +3251,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'the Workspace and whether this call created it.',
},
{
signature: '@Remote(\'initializeDefault\') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise<WorkspaceValue>',
description: 'Initialize or reuse the default Workspace before the first user message.',
signature: '@Remote(\'initializeDefault\') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise<WorkspaceValue | undefined>',
description: 'Initialize or reuse the default Workspace during first-use startup.',
parameters: [{ name: 'request', description: 'initial directory name and title; never rename an existing default.' }, { name: 'signal', description: 'caller lifetime; cancels native directory lookup.' }],
returns: 'the durable Workspace without creating a Session or sending a message.',
returns: 'the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message.',
},
{
signature: '@Remote(\'rename\') rename(request: WorkspaceRenameRequest): Promise<WorkspaceValue>',
@@ -81,16 +81,15 @@ export class TestWorkspaces implements IWorkspaces {
}
/**
* Initialize the default Workspace through an explicitly installed test stub.
* Initialize the default Workspace through a test stub; defaults to an ineligible first use.
* @param request - initial directory name and title.
* @param signal - caller lifetime.
* @returns the stubbed Workspace.
* @returns the stubbed Workspace, or undefined when initialization is ineligible.
*/
async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise<WorkspaceView> {
async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise<WorkspaceView | undefined> {
this.calls.push({ method: 'initializeDefault', args: [request, signal] })
const stub = this.stubs.get('initializeDefault')
if (stub === undefined) throw new Error('initializeDefault requires a Workspace fixture')
return await (stub(request, signal) as Promise<WorkspaceView>)
return await (stub?.(request, signal) as Promise<WorkspaceView | undefined> | undefined)
}
/**
@@ -557,12 +557,12 @@ describe('workspaces', () => {
expect(view.container.textContent).toContain('ws:pending')
await runtime.dispose()
})
it('requires an explicit default Workspace fixture and forwards its request and lifetime', async () => {
it('skips default initialization without a fixture and forwards a configured request and lifetime', async () => {
const runtime = await SlotTestRuntime.create()
try {
const request = { directoryName: 'default-workspace', title: 'Default workspace' }
const signal = new AbortController().signal
await expect(runtime.workspaces.initializeDefault(request, signal)).rejects.toThrow('requires a Workspace fixture')
await expect(runtime.workspaces.initializeDefault(request, signal)).resolves.toBeUndefined()
const workspace = {
workspaceId: 'default' as WorkspaceId, title: request.title, path: '/default', sessionIds: [],
createdAt: '2026-09-20T00:00:00Z', updatedAt: '2026-09-20T00:00:00Z',
-1
View File
@@ -246,7 +246,6 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
export const EVENT_WALK_EXEMPTIONS: Record<string, string> = {
'command/executed': 'client-face local command acknowledgment — packages/client/ui-commands/README.md owns the API',
'connection/reset': 'client-face transport signal — packages/api/session-controller/README.md owns the API',
'conversation/prepare-first-send': 'client-face first-use draft preparation — packages/client/ui-conversation/README.md owns the API',
'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the API',
'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API',
'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API',