diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 612ab83bdd..d8ddbe592a 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -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-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 409c4347bca42dd96fd134e0133a1721fdaebd5d -2026-07-19-gui-web-client-architecture.zh.md: 58235981471eeb365f7416fcd2e5530468e1e3ff +2026-07-19-gui-web-client-architecture.md: 55421d1ad6df192d08c431af3633675036a4a857 +2026-07-19-gui-web-client-architecture.zh.md: 6fb3f9a512389710f6708b7f36f42e90eef11b28 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 409c4347bc..55421d1ad6 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -44,7 +44,7 @@ Implementation homes: registry core and the props-share types live in `packages/ A service is a plugin's only API toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (RPC transport + generation state), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer installation contract), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). -There is no component registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Final Chat business Nodes dispatch through the keyed/session `'conversation.chat.node'` slot; ui-tool owns its `tool-call` entry, recursively renders the supplied `subCalls`, and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '' }, Row))`; the declaration is the load and reload dependency ([decision](../../archived/architecture/2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components. The target-neutral event and view registries are data assembly seams rather than parallel component registries ([decision](2026-08-09-client-conversation-node-assembly.md)). +There is no component registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Final Chat business Nodes dispatch through the keyed/session `'conversation.chat.node'` slot; ui-tool owns its `tool-call` entry, recursively renders the supplied `subCalls`, and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '' }, Row))`; the declaration is the load and reload dependency ([decision](../../archived/architecture/2026-08-05-slot-declaration-injection.md)). The right column is the `rightbar` seat ui-sidebar-right fills with one docking surface per session; the former details column and its `'conversation.details.tool'` seat are gone ([decision](../feature/2026-09-04-right-sidebar-docking-infrastructure.md)). The target-neutral event and view registries are data assembly seams rather than parallel component registries ([decision](2026-08-09-client-conversation-node-assembly.md)). **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport). diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index 5823598147..6fb3f9a512 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -44,7 +44,7 @@ slot 体系有自己的笔记——[slot 体系标准](2026-07-22-slot-type-chai 服务是插件对其他插件的唯一 API(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图 slot 注册)。名册:`ctx.connection`(RPC 传输 + generation 状态)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装约定)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.zh.md) 住 entry 声明的 store。 -slot 之外不存在第二种组件注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list slot entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。最终 Chat 业务 Node 通过 keyed/session `'conversation.chat.node'` slot 分发;ui-tool 拥有其中的 `tool-call` entry,递归渲染传入的 `subCalls`,并声明 keyed/session `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '' }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](../../archived/architecture/2026-08-05-slot-declaration-injection.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托 selected call 的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。与 target 无关的事件注册表和视图注册表是数据组装 seam,不是平行组件注册表([决策](2026-08-09-client-conversation-node-assembly.zh.md))。 +slot 之外不存在第二种组件注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list slot entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。最终 Chat 业务 Node 通过 keyed/session `'conversation.chat.node'` slot 分发;ui-tool 拥有其中的 `tool-call` entry,递归渲染传入的 `subCalls`,并声明 keyed/session `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '' }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](../../archived/architecture/2026-08-05-slot-declaration-injection.md))。右列是 ui-sidebar-right 以每会话一个停靠面填充的 `rightbar` 坑位;原来的详情列及其 `'conversation.details.tool'` 坑位已删除([决策](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md))。与 target 无关的事件注册表和视图注册表是数据组装 seam,不是平行组件注册表([决策](2026-08-09-client-conversation-node-assembly.zh.md))。 **scope 寻址**与 host 侧 agent(智能体)scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。 diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml index 9b41e2e0cf..1b529c379b 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml @@ -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-31-claimed-pre-step-inbox-lifecycle.md -2026-07-31-claimed-pre-step-inbox-lifecycle.md: 73768e1eee8957f8976d40812b0a31a2961f0825 -2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 343816abaf394b8f64924cf36b753c6b1b2e34ca +2026-07-31-claimed-pre-step-inbox-lifecycle.md: 737e3835263a3215a0fd2e52dad4ee05402bd888 +2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: ecb731df663e0d48b374a3118d7db7f6a34bfc18 diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md index 73768e1eee..737e383526 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md @@ -12,17 +12,19 @@ Occurrence-local inbox wrappers also duplicated the identity already carried by ## Decision -Before every proposed step, `Inbox.claim(target)` atomically removes the complete batch: all `next-step` messages and, at a turn boundary, one `next-turn` message. At the initial boundary the loop first commits `turn/start`, so the claim and its single `agent/pre-step` decision have durable turn ownership. Claiming records normalized `agent/inbox/spliced` pure deletions with no outcome. The loop then emits `agent/inbox/claimed { message, turn }` once per claimed message and awaits the waterfall with that exclusive batch and `{ turn, step, signal }`. +Before every proposed step, the loop's package-internal `ReactLoopInbox` atomically claims the complete batch: all `next-step` messages and, at a turn boundary, one `next-turn` message. At the initial boundary the loop first commits `turn/start`, so the claim and its single `agent/pre-step` decision have durable turn ownership. Claiming records normalized `agent/inbox/spliced` pure deletions with no outcome, emits `agent/inbox/claimed { message, turn }` once per claimed message, and returns the exclusive batch for the loop's waterfall with `{ turn, step, signal }`. `PreStepDecision` is `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`. Reject opens no step, leaves the claimed batch removed, and closes the turn as blocked without any step events. Empty entry, cancellation, and failure before `step/start` likewise close a balanced no-step turn. Enter supplies the complete batch appended as `user/message` events after `step/start`. A listener wrapping `next()` preserves downstream changes unless it intentionally replaces them, so all message rewrites settle once in the final return value. There is no `agent/prompt-prepare`, `agent/prompt-submit`, or `agent/step` extension point. -The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming is the loop's internal step-boundary operation on the inbox and records pure deletions without notifications or an outcome, so the loop can publish claimed events itself. These live events add no placement, outcome, or batch fields. +The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming records pure deletions without an outcome and emits claimed events from `ReactLoopInbox`. These live events add no placement, outcome, or batch fields. -The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Whole-queue consumers, including the Web queue projection and reconnect baseline, use the durable `agent/inbox/spliced` stream; UI edits and removals route through `Inbox.splice()` or another Inbox mutation method so the same projection records every change. +`Agent.inbox` exposes only the structural `Inbox` interface for reading and mutating pending work; loop-only `hasPending` and claim operations are absent from that public face. dsh-agent-loop constructs one `ReactLoopInbox` and uses it for both structural commands and driver operations. The concrete constructor receives `SessionProjectionRegistry` directly instead of the wider Cordis `Context` and registers the standard definition on the agent scope before its first read. `AgentLoop` requires the registry service at activation, and the registry reference-counts the definition across live agent scopes. + +The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Each `ReactLoopInbox` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream from its agent scope; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue control consumers use the projection change feed: the Session controller publishes the projection frame, then derives the queue replacement from the same post-fold inbox value. Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses both paths: asynchronous filesystem projections stage one replaceable `next-step` item, while the next entering pre-step folds that item or a newly composed baseline into its final batch and removes the pending copy. Rejection keeps the item queued. -The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` now owns addressability, while the retained Host queue mirror derives its snapshots from the durable splice projection. +The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` owns addressability, while `ReactLoopInbox` contributes `inbox` as the standard session projection over durable splices. The generic projection carrier serves that fold for live updates, history-tail reconnect baselines, and cold process-restart recovery without a live Agent mirror. ## Alternatives considered @@ -34,7 +36,7 @@ The archived [addressable queue occurrence decision](../../archived/feature/2026 ## Verification -Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, and cancellation. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, and resumed durable projection. Generated event and type catalogs expose only the new waterfall and payloads. +Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, cancellation, and agent-scope projection removal after the last owner unloads. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and post-fold queue replacement when the controller registers before the projection registry. Consumer-domain tests use a process-local Inbox stub only when durability is outside the test subject; claiming, durable projection, recovery, validation, and live-notification tests create Agents through the production AgentLoop test harness, so test support never reimplements the projection. Generated event and type catalogs expose only the new waterfall and payloads. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md index 343816abaf..ecb731df66 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md @@ -12,17 +12,19 @@ Status: implemented ## 决策 -每个拟议步骤之前,`Inbox.claim(target)` 会原子移除完整批次:全部 `next-step` 消息,以及轮次边界上的一条 `next-turn` 消息。在首次边界,循环会先提交 `turn/start`,使领取及其唯一一次 `agent/pre-step` 决策拥有持久轮次归属。领取会记录规范化、不带 outcome 的纯删除 `agent/inbox/spliced`。随后,循环针对每条已领取消息发出一次 `agent/inbox/claimed { message, turn }`,并用该独占批次与 `{ turn, step, signal }` 等待 waterfall(瀑布式事件)。 +每个拟议步骤之前,循环包内部的 `ReactLoopInbox` 会原子领取完整批次:全部 `next-step` 消息,以及轮次边界上的一条 `next-turn` 消息。在首次边界,循环会先提交 `turn/start`,使领取及其唯一一次 `agent/pre-step` 决策拥有持久轮次归属。领取会记录规范化、不带 outcome 的纯删除 `agent/inbox/spliced`,针对每条已领取消息发出一次 `agent/inbox/claimed { message, turn }`,并把独占批次返回给循环,由后者用 `{ turn, step, signal }` 等待 waterfall(瀑布式事件)。 `PreStepDecision` 为 `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`。reject 不会打开步骤,会让已领取批次保持已删除,并将轮次关闭为 blocked,且不产生任何步骤事件。空的 enter、取消以及 `step/start` 前的失败同样会关闭一个边界平衡的无步骤轮次。enter 提供在 `step/start` 后以 `user/message` 追加的完整批次。包装 `next()` 的监听器会保留下游变更,除非有意替换,因此全部消息改写只在最终返回值中一次性结算。系统不再存在 `agent/prompt-prepare`、`agent/prompt-submit` 或 `agent/step` 扩展点。 -持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取是循环在 inbox 上的内部步骤边界操作,记录不带通知或 outcome 的纯删除,因此循环可以自行发布 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。 +持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取记录不带 outcome 的纯删除,并由 `ReactLoopInbox` 发出 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。 -两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。包括 Web 队列投影和重连基线在内的整体队列消费方使用持久 `agent/inbox/spliced` 流;UI 编辑与移除通过 `Inbox.splice()` 或其他 Inbox 变更方法处理,从而让同一投影记录所有变化。 +`Agent.inbox` 只暴露用于读取和变更待处理工作的结构化 `Inbox` 接口;仅供循环使用的 `hasPending` 与领取操作不在该公开接口上。dsh-agent-loop 只构造一个 `ReactLoopInbox`,同时用于结构化命令与驱动器操作。具体构造函数直接接收 `SessionProjectionRegistry`,而不是更宽泛的 Cordis `Context`,并在首次读取前从 agent 作用域注册标准定义。`AgentLoop` 激活时要求该注册表服务存在,注册表则对多个 live agent 作用域贡献的定义进行引用计数。 + +两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。每个 `ReactLoopInbox` 都从其 agent 作用域在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。整体队列的 control 消费方使用投影变更流:Session controller 先发布 projection frame,再从同一份折叠后的 inbox 值派生 queue replacement。 必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 同时使用两条路径:异步文件系统投影会暂存一条可替换的 `next-step` 消息,而下一次进入步骤的 pre-step 会把该消息或新组合的基线折入最终批次,并移除仍待处理的副本。reject 会让该条目继续排队。 -已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。现在由 `MessageId` 负责寻址,而保留的 Host 队列镜像根据持久 splice 投影派生快照。 +已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。`MessageId` 负责寻址,而 `ReactLoopInbox` 把 `inbox` 作为持久 splice 上的标准会话投影贡献给投影注册表。通用投影传输层会将该折叠结果用于实时更新、历史尾页的重连基线和冷进程重启恢复,无需 live Agent 镜像。 ## 曾考虑的替代方案 @@ -34,7 +36,7 @@ Status: implemented ## 验证 -agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败与取消。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点以及恢复后的持久投影。生成的事件与类型目录只公开新的 waterfall 与载荷。 +agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败、取消,以及最后一个所有者卸载后移除 agent 作用域投影。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及 controller 早于投影注册表注册时仍使用折叠后队列值。只有当持久性不属于测试对象时,消费方领域测试才使用进程内 Inbox 桩;领取、持久投影、恢复、校验与实时通知测试通过生产 AgentLoop 测试 harness 创建 Agent,因此测试支持代码不会重新实现该投影。生成的事件与类型目录只公开新的 waterfall 与载荷。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml index 21599ff5f7..6f20df4af6 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml @@ -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-08-23-client-derived-tool-presentation.md -2026-08-23-client-derived-tool-presentation.md: 58a8f23d717580355b852703f448c723d9c3a7ea -2026-08-23-client-derived-tool-presentation.zh.md: 8e1a02eff57cce7967985a22c5bdcec7c18e6059 +2026-08-23-client-derived-tool-presentation.md: 6b19dc881d572bfece345cbbd5eca688b2e05aab +2026-08-23-client-derived-tool-presentation.zh.md: 98ada31627398f317e4c41336af56e8c6cf0837e diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md index 58a8f23d71..6b19dc881d 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md @@ -248,7 +248,7 @@ Host presenter APIs describe top-level calls and results. Code Dispatch subcalls Code Dispatch start and result events already carry `parentCallId`. Conversation preserves that existing fact on each child `ToolCallBlock`; root Session calls omit it. The diff, read, search, and web models accept only blocks without `parentCallId`; the terminal model and existing renderers that intentionally support nested calls accept child blocks. -The Details panel delegates the selected block unchanged. Shared card models apply the same terminal eligibility and nonterminal child restrictions in rows and Details, so the Details slot needs no placement field. +Shared card models apply the same terminal eligibility and nonterminal child restrictions wherever a block renders, so no second presentation surface needs a placement field; the details panel that once delegated a selected block was removed with the right-hand details column ([decision](../feature/2026-09-04-right-sidebar-docking-infrastructure.md)). The keyed slot continues dispatching every subcall by its real tool name. `parentCallId` restricts only the diff, read, search, and web structured models covered by this decision. Existing specialized renderers such as Skill and Cordis, which already read raw blocks, remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md index 8e1a02eff5..98ada31627 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md @@ -248,7 +248,7 @@ Host presenter API 描述顶层 call/result。本决定覆盖的 diff、read、s Code Dispatch start 与 result event 已经携带 `parentCallId`。Conversation 在每个 child `ToolCallBlock` 上保留这项现有事实,root Session call 则不携带它。diff、read、search 和 web model 只接受没有 `parentCallId` 的 block;terminal model 与原本有意支持嵌套调用的 renderer 接受 child block。 -Details panel 原样委托选中的 block。共享 card model 在行与 Details 中应用相同的 terminal 适用规则和非 terminal 子调用限制,因此 Details slot 不需要 placement 字段。 +共享的 card model 在 block 渲染到哪里都施加同样的终端资格与非终端子调用限制,因此不需要第二个展示面带 placement 字段;曾经原样委托选中 block 的详情面板已随右侧详情列一并删除([决策](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md))。 keyed slot 仍按每个子调用的真实 tool name 分发;`parentCallId` 只限制本决定覆盖的 diff/read/search/web 结构化模型。Skill、Cordis 等已经直接读取 raw block 的专用 renderer 保持现状。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml new file mode 100644 index 0000000000..8aa7824f1f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +2026-08-25-electron-desktop-packaging-and-updates.md: 6d22777c8913911dbb1d89c09de9e891636873c8 +2026-08-25-electron-desktop-packaging-and-updates.zh.md: 5108492ba6f029847735f570aa77c2cb505f981c diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md new file mode 100644 index 0000000000..6d22777c89 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -0,0 +1,177 @@ +# Agent Note: Package and update the Electron desktop application + +Status: implemented + +English | [中文](2026-08-25-electron-desktop-packaging-and-updates.zh.md) + +## Problem + +DeepSeek Harness needs an Electron desktop application that reuses the Web UI, works without system Node.js or pnpm, installs dsh and desktop plugins through an application-bundled pnpm, and updates the complete desktop release through one user-facing flow. + +The desktop application and an npm-installed dsh share the `.dsh` data root, but they may have different dsh and plugin versions. They must share supported product data without sharing executable packages, lockfiles, `node_modules`, plugin activation, or package-manager configuration. + +The current GUI protocol binds the Web client and backend release. Independently versioning the Electron artifact and its pnpm-installed dsh would create unqualified shell, client, backend, and plugin combinations and make update availability ambiguous. + +## Decision + +Ship a small Electron shell with a bundled upstream Node.js executable and pinned pnpm. Electron starts the private Desktop Host package as an isolated child process; that package composes the installed dsh backend and matching client graph. Fetch metadata and bounded raw request and response chunks travel over two versioned framed byte pipes, Node IPC is reserved for readiness, fatal failure, and shutdown, and Electron serves validated assets through `dsh-app://`; it opens no listening port. Each frame carries a fixed marker, type, monotonic stream id, payload length, and validated payload. Serialized writers honor pipe drain, readers pause globally when a request or response stream applies backpressure, cancellation closes the matching stream, and late response frames for a retired stream stay inert. The Connection plugin provides its carrier-neutral RPC and Fetch registries without requiring `webServer`, while Client Modules provides the exact advertised combo-bundle responses to the shell-owned carrier; Web compositions attach their optional HTTP routes for both. The renderer keeps the same Fetch, RPC, and Remote-stream formats, while the child carrier avoids Base64 expansion and V8 serialization compatibility between Electron and the bundled upstream Node.js. Electron closes its request-pipe writer after sending shutdown, releasing an in-flight Windows pipe read before it waits for child exit. This follows the Electron reservation in the [GUI layering and RPC protocol note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). + +Electron owns the reserved profile at `.dsh/profiles/desktop`. Its exact `@deepseek-ai/dsh` dependency supplies the backend and matching Web UI, while the matching private `@deepseek-ai/dsh-desktop-host` dependency supplies only the Electron child-process entry and composition overlay. The dsh release, private Host, and their first-party dependency closures use local npm tarballs packed from the same source build; the profile manifest lists every core package as a local `file:` dependency, and `pnpm-workspace.yaml` repeats the mapping as overrides. The Host remains outside the public CLI package and is never published to npm. Desktop plugins are additional registry npm dependencies and ordered `dsh.profile.bundles` entries in the same profile, and resolve from its one `node_modules`. + +One Desktop release number identifies the Electron artifact and its exact `@deepseek-ai/dsh` and `@deepseek-ai/dsh-desktop-host` dependencies. A release cannot select a different core version at build or runtime. Updating dsh therefore requires a new Electron release even when shell code is unchanged. + +The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user pnpm cannot mutate this profile. The CLI reserves every case variant of the `desktop` name and rejects boot, config-dump, and plugin-management requests for it. Electron acquires its process-lifetime single-instance lock before project recovery or Host startup; later launches focus or recreate the primary window without touching profile state. An Electron-only GUI sends structured install, remove, and update requests through preload; Electron invokes only its bundled pnpm. + +## Ownership + +| Owner | Responsibility | +|---|---| +| Electron shell | Window and child lifecycle, framed byte pipes, lifecycle IPC, custom protocol, reserved desktop profile, plugin GUI, update coordination, rollback | +| Bundled Node.js and pnpm | Execute dsh and install exact desktop-project dependencies without consulting user `PATH` or pnpm state | +| Desktop profile | One dependency graph, ordered bundle list, and `node_modules` for the desktop dsh package and desktop plugins | +| Private Desktop Host package | Electron-only child-process entry and composition overlay installed with dsh but excluded from the public CLI package and npm publication | +| Installed dsh package | Backend, matching Web UI, boot manifest, client bundles, and product behavior | +| Shared `.dsh` owners | Sessions, settings, credentials, workspaces, and storage, guarded by their existing locks and format versions | +| npm-installed dsh | Its own executable installation and user-managed profiles; no access to the reserved desktop profile or package state | + +The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandbox: true`. Preload exposes typed RPC, lifecycle, update, locale, and desktop-plugin actions rather than raw `ipcRenderer`, filesystem access, shell commands, or pnpm arguments. Electron selects a typed English or Chinese dictionary from its application locale and falls back to English; menus, native dialogs, and the plugin-management renderer use that locale-owned copy. + +## Filesystem layout + +```text +~/.dsh/ + desktop/ + staging//profile/ + rollback/profile/ + pending.json + lock + pnpm/ + store/ + cache/ + state/ + config/ + profiles/ + desktop/ + package.json + pnpm-lock.yaml + pnpm-workspace.yaml + desktop-release.json + desktop-packages.json + desktop-packages/ + node_modules/ + sessions/ + storages/ +``` + +`.dsh/profiles/desktop` is the only active desktop profile. Its package manifest records the built-in and installed plugin bundle order; Electron alone mutates its dependencies, lockfile, and `node_modules`. Production startup rejects a bundle resolved outside this profile, including the CLI-maintained `.dsh/profiles/node_modules` fallback. Package content installed for the desktop profile uses `.dsh/desktop/pnpm/store`. + +## Installation and resolution + +The installer never mutates the active profile in place. It copies profile metadata into a transaction staging directory and applies an exact dependency change with the bundled pnpm. Before testing staging, Electron stops the active backend; it starts and stops the staged backend alone, then restores the active backend before activation, so two Desktop backends never concurrently share `.dsh` state. Activation stops the backend again, persists each next `pending.json` phase before its corresponding filesystem move, moves the active profile to `rollback/profile`, moves staging into `.dsh/profiles/desktop`, and restarts. Recovery combines the write-ahead phase with the actual active, rollback, and staging directories so either write-to-move interruption retains or restores a complete profile. + +The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing store or staging transaction; after that worker exits, the stale PID can be recovered. + +The packaged seed is an offline installation kit, not an executable dsh tree. It contains the release identity, initial desktop-project manifest, a descriptor and immutable tarballs for the union of the first-party package closures rooted at dsh and the private Desktop Host, lockfile, integrity inventory, and required store subset. Each `mac-arm64`, `mac-x64`, and `win-x64` build owns its packed packages, runtime, package set, seed, pnpm preparation state, unpacked application, update metadata, and final artifacts under `.desktop-build/targets/`; only the immutable, checksum-verified Node.js download cache is shared. The release build requires the Electron package, root dsh package, and private Host package to have the same version, creates final npm tarballs from the official source build, locally packs the private Host, selects the reachable dsh, Host, and vendored packages plus the Landlock entry, and verifies that the Host tarball contains `lib/index.js` and `config/desktop.cordis.patch.yml`. The Host `files` manifest contains only that runtime entry and overlay, and the package is never published to npm. Public package tarballs remain the official `pnpm pack` results governed by each package's publication manifest; Desktop does not remove published declarations or otherwise create a second package-content policy. The seed manifest lists every selected package as a local direct dependency, automatic peer installation is disabled, and the workspace file overrides every selected first-party name to its local tarball. The target Node.js executes bundled pnpm, so pnpm's operating-system and CPU selection makes the materialized dependency graph and seed target-specific. Bundled pnpm disables its global virtual store, materializes external production dependencies from npm without lifecycle scripts, deletes `node_modules` and every temporary pnpm cache, config, and state directory, then performs a clean offline installation from the final store alone and checks the private Host entry and overlay. The build rejects any lockfile that resolves one of the local first-party names by registry version. Inventory generation follows removal of that second `node_modules` tree and temporary pnpm project registrations. Requiring both Host files before copying the package set and after offline installation prevents a release whose process entry loads but cannot compose its required overlay from reaching application signing. + +The seed stores pnpm content in 16 deterministic uncompressed tar shards selected by normalized store path. Apple notarization inspects Mach-O code inside those archives, so macOS seed preparation stages every referenced Mach-O content-addressed object and runs at most four independent Developer ID signers concurrently with a secure timestamp and hardened runtime. A signer failure is observed only after every active signer exits and leaves the original CAS objects and package index unchanged. After all signers succeed, preparation writes each object at its new SHA-512 path and transactionally rewrites every base and side-effects file reference in pnpm's MessagePack SQLite index. A second offline installation proves that pnpm resolves the rewritten store; preparation then shards it, extracts the final archives, and verifies every embedded signature. Package paths and non-native bytes remain unchanged, and the seed retains bundled architecture variants because removing files would create a Desktop-specific package file set. Seed integrity covers the shard manifest and every archive before extraction. Startup validates archive paths, entry types, uniqueness, and counts, extracts every shard into a unique Desktop-owned staging directory, replaces matching immutable store files, and transactionally merges each pnpm store version's SQLite `package_index` into `.dsh/desktop/pnpm/store`. Seed records replace matching keys while records downloaded for Desktop plugins remain. An interrupted file merge may leave valid immutable cache content, but each SQLite merge is atomic, and profile installation and activation still require pnpm integrity and the complete health check. + +Startup requires the packaged release identity to equal Electron's application version, then compares `.dsh/profiles/desktop/desktop-release.json` plus the installed dsh and Desktop Host packages with that release before launching the backend. It installs the new seed manifest and lockfile with `pnpm install --offline --frozen-lockfile --trust-lockfile` in staging. After Electron replacement, it restores every plugin bundle recorded in the active profile at its exact installed version through one offline pnpm add from the existing desktop store and metadata cache. The complete graph must pass the same health check before activation. + +The plugin GUI performs registry npm-package operations equivalent to `pnpm add --save-exact`, `pnpm remove `, and exact-version update in staging. Every mutation retains the local core-package descriptor, tarballs, dsh and Desktop Host dependencies, and complete override map. Electron validates the installed package manifest and updates the profile's dependency and ordered bundle entries; no renderer request can choose the registry, install directory, lifecycle policy, or arbitrary pnpm flags. + +The backend and Loader use `.dsh/profiles/desktop/package.json` as their profile manifest and npm resolution anchor. The shared profile loader composes its ordered bundle entries, then the private Desktop Host applies its packaged overlay. The Host, dsh, Cordis, desktop plugins, plugin dependencies, and peer dependencies resolve through the ordinary pnpm `node_modules` graph. A desktop plugin contributing `dsh.client` code enters the boot manifest only after the complete profile passes health checking. + +## Updates and recovery + +Electron update uses one `electron-updater` release stream and signed `electron-builder` artifacts. Its version is the Desktop release version; there is no independent dsh manifest, compatibility range, or dsh-only update operation. A foreground install waits for an in-flight background check rather than reusing its result as an install result. The update dialog downloads and installs the Electron artifact, then restarts into the new release. + +Before the new release opens a window, startup reconciles dsh from its packaged seed while retaining installed desktop plugins. The health check covers dependency resolution, native modules, shell API compatibility, backend startup and shutdown, Web assets, and the client boot graph. An incompatible plugin blocks activation and leaves the previous project available for rollback. Startup fails visibly rather than launching a shell and dsh version that do not match. + +`DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm staging transaction remain separate operations. + +## Security and release policy + +Core dsh and the private Desktop Host come only from integrity-recorded local npm tarballs inside the signed Electron release; pnpm overrides prevent transitive core packages from falling back to a registry. Store archives are integrity-checked and fully validated in an isolated extraction directory before their files can enter writable package state. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, user-only directory permissions, redacted diagnostics, and health checking are required before activation. + +Electron artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Seed preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. + +Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and seed-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. + +Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the Node.js binary, pnpm entry, seed, or active project. + +The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compressed and 120–165 MB installed before the seed store subset. Architecture-specific builds must report actual component-level size deltas. + +## Implementation + +| Surface | Implementation | +|---|---| +| Shell | `apps/desktop` owns Electron windows, restricted preloads, the custom protocol, child lifecycle, project transactions, the plugin GUI, update coordination, and electron-builder configuration. | +| Installed runtime | Private `@deepseek-ai/dsh-desktop-host` boots the portless desktop composition from the active project and streams API and asset responses over validated framed byte pipes. | +| Package state | The release seed and every later mutation run through bundled Node.js and pnpm with desktop-owned store, config, cache, state, and home paths; core packages resolve from release tarballs while plugins resolve from the fixed npm registry. | +| Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native seed object after final archive extraction, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows packaging requires the configured public certificate, SafeNet private-key container, Token Password, and SignTool, and verifies every produced signature. Update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. | + +`dev:desktop` builds the current workspace, projects the built CLI and private Desktop Host packages plus their dependency links into a disposable project, uses an isolated Harness home, opens the Main, Renderer, and Host debuggers, and starts unpackaged Electron without preparing release resources. Package mutation is disabled in this mode because its linked dependency graph is not a pnpm-installed desktop project. Fixed macOS arm64, macOS x64, and Windows x64 package commands pass one target through runtime preparation, seed installation, and electron-builder; each also has an unpacked-directory variant for release-path verification before installer generation. + +## Alternatives considered + +**Use Electron's Node.js for dsh.** This saves package size but couples dsh to Electron's Node patches, fuses, native ABI, TLS behavior, and process lifecycle. A bundled upstream Node.js keeps dsh on its supported runtime. + +**Carry Fetch bodies through JSON IPC as Base64.** JSON IPC keeps one message mechanism but expands every request and response body, constructs large strings in both processes, buffers each request before dispatch, and double-encodes image bytes already represented as Base64 inside RPC JSON. Raw framed pipes retain an explicit versioned protocol without relying on Electron and upstream Node.js to share V8 serialization behavior. + +**Bake the product Web UI into Electron.** Independent UI and backend updates would require a new versioned compatibility program. Installing backend and Web UI from the same dsh package preserves the current release binding. + +**Reuse the existing CLI or browser plugin installer.** That crosses the desktop authorization and release scope and can use the user's package-manager state. Desktop package mutation remains exclusively Electron-owned. + +**Let the desktop profile use CLI-managed packages or plugins.** Either product could change the other's dependency graph, Cordis version, plugin version, or native module. The desktop profile therefore owns a complete `node_modules` and rejects bundle resolution through the CLI profile fallback. + +**Install dsh and plugins into separate desktop projects.** This creates a second resolution anchor and peer-dependency fallback. One ordinary npm project already provides the required installation and resolution model. + +**Remove non-target Mach-O files from registry packages.** Architecture pruning saves a small amount of seed space, but packages can deliberately ship several architecture variants and callers can observe their installed file set. Signing every shipped Mach-O object satisfies notarization without inventing a Desktop-specific package layout. + +**Export the Windows EV private key in a PFX file.** The externally supplied public leaf certificate lets SignTool construct the signature while `/csp` and `/kc` locate the hardware key. The EV private key remains non-exportable on the token. + +**Commit a credential-bearing signing script or persist the Token Password.** A credential-bearing CMD file, `.env`, or Windows user or system environment variable leaves the Token Password recoverable at rest. The checked-in CMD contains only environment-variable references, and the packaging step accepts the password as an ephemeral runner secret. + +**Let electron-builder or a general directory sync publish directly.** A direct publisher can expose channel metadata before every referenced artifact exists, mix stale or cross-target files into a release, and cannot prove that the completed signed build still matches the current dsh version. A target-specific validated upload keeps publication ordering and release identity explicit. + +## Consequences + +- A clean offline machine with no system Node.js or pnpm installs the seed into `.dsh/profiles/desktop` and starts a working dsh session. +- The signed application inventories a fixed small set of seed store shards instead of every pnpm cache file; every Mach-O object inside the macOS shards has the release Developer ID, secure timestamp, and hardened runtime, every Windows artifact has the configured hardware-backed EV signature, and the installed private store retains the ordinary pnpm layout. +- `.dsh/profiles/desktop/node_modules` contains and resolves the desktop dsh package and every GUI-installed desktop plugin. +- Every desktop pnpm operation uses the bundled executable and `.dsh/desktop/pnpm/store`; none reads user `PATH`, config, store, or profile `node_modules`. +- The Electron-only GUI installs, removes, and updates ordinary npm plugin packages without exposing raw pnpm arguments. +- The backend and browser application cannot mutate desktop packages. +- npm/CLI dsh and Electron never resolve or install plugins from each other's `node_modules`. +- The active backend and Web UI report the same dsh version and a compatible shell API before the product window opens. +- Failed installation, health checking, or update leaves the current profile usable or restores `rollback/profile` after restart. +- One Desktop version binds Electron and dsh; every dsh update arrives through one Electron update dialog and one user-visible restart. +- Shared `.dsh` data rejects incompatible readers before migration or mutation. +- No loopback listener is opened, and the sandboxed renderer cannot access arbitrary filesystem or Electron APIs. +- Workspace development runs current built code without downloading release resources, while unpacked-package verification retains the production installation path. +- Windows release packaging requires the validated SignTool, EV token, matching public leaf certificate, Token Password, and explicit key container; it never falls back to an unsigned artifact or an exportable key file. +- A target update cannot expose new channel metadata until the completed signed build and every referenced artifact pass release validation; retained historical artifacts remain available for differential updates. +- Signed installed artifacts update successfully from the previous supported release on each release-blocking platform. + +## Review decisions + +| Decision | Recommendation | +|---|---| +| First launch | Bundle an offline seed store subset and install it through pnpm | +| Desktop profile | One Electron-owned reserved profile containing exact dsh and plugin dependencies | +| Plugin management | Electron-only GUI and package service; no CLI, backend, or browser installation path | +| Activation | Staging project, complete health check, journaled directory replacement, one rollback copy | +| Initial platforms | macOS arm64/x64 and Windows x64; Linux has no supported release target | +| Update behavior | Background check, explicit confirmation before differential download and restart, startup dsh reconciliation | + +## Risks + +Plugin lifecycle scripts execute third-party code. The allowed registry, package policy, exact versions, integrity, `allowBuilds`, and diagnostics require security review before GUI installation ships. + +Updating the bound dsh can invalidate plugin peer dependencies or native modules. pnpm resolution and full-project health checking must reject the staged project before replacing the active one. + +An npm-installed dsh and desktop dsh may have different versions while sharing durable data. Each shared owner must enforce its format version and process lock before reading, migrating, or writing. + +Directory replacement differs across operating systems and can be interrupted. The activation journal and installed-artifact fault tests must prove recovery at every filesystem move. + +Code signing, notarization, and update hosting require production release infrastructure. Repository tests alone cannot complete that qualification. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md new file mode 100644 index 0000000000..5108492ba6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -0,0 +1,177 @@ +# Agent Note: 打包并更新 Electron 桌面应用 + +Status: implemented + +[English](2026-08-25-electron-desktop-packaging-and-updates.md) | 中文 + +## 问题 + +DeepSeek Harness 需要一个复用 Web UI 的 Electron 桌面应用。该应用无需系统 Node.js 或 pnpm 即可工作,通过应用内置 pnpm 安装 dsh 与桌面插件,并通过一个面向用户的流程更新完整桌面发布。 + +桌面应用与通过 npm 安装的 dsh 共享 `.dsh` 数据根目录,但两者可能使用不同的 dsh 与插件版本。它们必须共享受支持的产品数据,同时不得共享可执行包、lockfile、`node_modules`、插件激活状态或包管理器配置。 + +当前 GUI 协议绑定 Web 客户端与后端版本。Electron 产物与其中通过 pnpm 安装的 dsh 如果独立定版本,就会产生未经验证的壳、客户端、后端与插件组合,也无法明确判断更新是否可用。 + +## 决策 + +交付一个小型 Electron 壳,其中内置上游 Node.js 可执行文件和固定版本的 pnpm。Electron 把私有 Desktop Host 包作为隔离子进程启动;该包组合已安装的 dsh 后端与匹配的客户端图。Fetch 元数据及有界的原始请求与响应分块通过两条带版本的分帧字节管道传递,Node IPC 只承载就绪、致命失败和关闭,Electron 通过 `dsh-app://` 提供经过验证的资源;它不会打开监听端口。每个帧都包含固定标记、类型、单调 stream id、负载长度和经过验证的负载。串行 writer 遵守 pipe drain,请求或响应 stream 施加背压时 reader 会全局暂停,取消会关闭匹配的 stream,已退役 stream 的迟到响应帧保持无效。Connection 插件无需 `webServer` 即可提供与载体无关的 RPC 与 Fetch 注册表,Client Modules 则向 shell-owned carrier 提供与广告内容完全一致的组合 bundle 响应;Web 组合为两者挂载可选 HTTP route。渲染进程保留相同的 Fetch、RPC 与 Remote-stream 格式,子进程载体则避免 Base64 膨胀,也不依赖 Electron 与内置上游 Node.js 之间的 V8 序列化兼容性。发送 shutdown 后,Electron 会关闭自己持有的请求管道写端,以便在等待子进程退出前释放 Windows 上仍在进行的管道读取。该设计沿用 [GUI 分层与 RPC 协议 Agent Note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中的 Electron 预留。 + +Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepseek-ai/dsh` 依赖提供后端与匹配的 Web UI,匹配的私有 `@deepseek-ai/dsh-desktop-host` 依赖则只提供 Electron 子进程入口与组合 overlay。dsh 发布、私有 Host 及其第一方依赖闭包使用同一次源码构建生成的本地 npm tarball;profile manifest 把每个核心包列为本地 `file:` 依赖,`pnpm-workspace.yaml` 再通过 overrides 重复该映射。Host 不进入公共 CLI 包,也不会发布到 npm。桌面插件既是同一 profile 中来自 registry 的其他 npm 依赖,也是有序的 `dsh.profile.bundles` 条目,并从该 profile 唯一的 `node_modules` 解析。 + +一个 Desktop 发布号同时标识 Electron 产物及其精确的 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-desktop-host` 依赖。发布不能在构建或运行时选择不同的核心版本。因此,即使壳代码没有变化,更新 dsh 也必须产生新的 Electron 发布。 + +浏览器 Web UI、dsh 后端、现有 `dsh plugin` CLI、用户 npm 和用户 pnpm 都不能修改该 profile。CLI 保留 `desktop` 名称的所有大小写变体,并拒绝针对它的启动、配置 dump 和插件管理请求。Electron 在项目恢复或 Host 启动前获取进程生命周期单实例锁;后续启动只会聚焦或重建主窗口,不会接触 profile 状态。Electron-only GUI 通过 preload 发送结构化安装、删除和更新请求;Electron 只调用其内置 pnpm。 + +## 归属 + +| Owner | 职责 | +|---|---| +| Electron 壳 | 窗口与子进程生命周期、分帧字节管道、生命周期 IPC、自定义协议、保留 desktop profile、插件 GUI、更新协调、回滚 | +| 内置 Node.js 与 pnpm | 执行 dsh 并安装桌面项目的精确依赖,不读取用户 `PATH` 或 pnpm 状态 | +| Desktop profile | 为桌面 dsh 包与桌面插件提供一个依赖图、有序 bundle 列表和一个 `node_modules` | +| 私有 Desktop Host 包 | 与 dsh 一起安装、但不进入公共 CLI 包或 npm 发布的 Electron 专用子进程入口与组合 overlay | +| 已安装 dsh 包 | 后端、匹配的 Web UI、启动 manifest、客户端包和产品行为 | +| 共享 `.dsh` owner | 会话、设置、凭据、工作区和存储,由其现有锁与格式版本保护 | +| 通过 npm 安装的 dsh | 自己的可执行安装和用户管理的 profile;不能访问保留 desktop profile 或包状态 | + +渲染进程使用 `nodeIntegration: false`、`contextIsolation: true` 和 `sandbox: true`。Preload 暴露类型化 RPC、生命周期、更新、locale 与桌面插件操作,而不暴露原始 `ipcRenderer`、文件系统访问、shell 命令或 pnpm 参数。Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback;菜单、原生对话框与插件管理渲染进程使用这些由 locale 持有的文案。 + +## 文件系统布局 + +```text +~/.dsh/ + desktop/ + staging//profile/ + rollback/profile/ + pending.json + lock + pnpm/ + store/ + cache/ + state/ + config/ + profiles/ + desktop/ + package.json + pnpm-lock.yaml + pnpm-workspace.yaml + desktop-release.json + desktop-packages.json + desktop-packages/ + node_modules/ + sessions/ + storages/ +``` + +`.dsh/profiles/desktop` 是唯一活跃的 desktop profile。其 package manifest 记录内置与已安装插件 bundle 的顺序;只有 Electron 可以修改它的依赖、lockfile 和 `node_modules`。生产启动会拒绝解析到该 profile 之外的 bundle,包括 CLI 维护的 `.dsh/profiles/node_modules` fallback。desktop profile 安装的所有包内容都使用 `.dsh/desktop/pnpm/store`。 + +## 安装与解析 + +安装器绝不原地修改活跃 profile。它把 profile 元数据复制到事务暂存目录,并使用内置 pnpm 应用精确依赖变更。测试 staging 前,Electron 会停止活跃后端;它单独启动并停止 staging 后端,再在激活前恢复活跃后端,因此两个 Desktop 后端绝不会并发共享 `.dsh` 状态。激活过程再次停止后端,在对应目录移动前先持久化 `pending.json` 的每个下一阶段,把活跃 profile 移到 `rollback/profile`,把暂存 profile 移到 `.dsh/profiles/desktop`,然后重启。恢复过程会结合预写阶段与真实的 active、rollback 和 staging 目录,因此任一个写入与移动间隙中断后仍会保留或恢复一个完整 profile。 + +进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 store 或 staging 事务;该 worker 退出后,陈旧 PID 才可以恢复。 + +打包 seed 是离线安装包,而不是可执行 dsh 目录。它包含发布身份、初始桌面项目 manifest、分别以 dsh 和私有 Desktop Host 为根的第一方包闭包之并集的描述文件及不可变 tarball、lockfile、完整性清单和所需 store 子集。每个 `mac-arm64`、`mac-x64` 和 `win-x64` 构建都在 `.desktop-build/targets/` 下持有自己的打包输入、运行时、包集合、seed、pnpm 准备状态、未打包应用、更新元数据和最终产物;只有不可变且经过校验和验证的 Node.js 下载缓存会被共享。发布构建要求 Electron 包、根 dsh 包与私有 Host 包使用相同版本,从正式源码构建生成最终 npm tarball,在本地打包私有 Host,选择可达的 dsh、Host 与 vendored 包以及 Landlock 入口,并验证 Host tarball 中包含 `lib/index.js` 与 `config/desktop.cordis.patch.yml`。Host 的 `files` manifest 只包含该运行入口与 overlay,并且该包不会发布到 npm。公共包 tarball 仍是由各包发布 manifest 控制的正式 `pnpm pack` 结果;Desktop 不删除已发布的声明文件,也不建立第二套包内容策略。seed manifest 把每个选中的包列为本地直接依赖,关闭 peer dependency 自动安装,workspace 文件再把每个选中的第一方包 override 到对应本地 tarball。目标 Node.js 执行内置 pnpm,因此 pnpm 的操作系统和 CPU 选择会使物化的依赖图与 seed 成为目标专用内容。内置 pnpm 关闭全局 virtual store,在禁用生命周期脚本的情况下从 npm 物化外部生产依赖,删除 `node_modules` 以及所有临时 pnpm cache、config 和 state 目录,然后只使用最终 store 执行一次干净的离线安装,并检查私有 Host 的入口与 overlay。构建会拒绝任何通过 registry 版本解析本地第一方包名的 lockfile。生成清单前会删除第二次生成的 `node_modules` 和临时 pnpm 项目注册。在复制 package set 前与离线安装后都要求这两个 Host 文件,可防止进程入口能够加载、却无法组合所需 overlay 的发布进入应用签名阶段。 + +种子根据规范化 store 路径,把 pnpm 内容放入 16 个确定性的未压缩 tar 分片。Apple 公证会检查这些归档内的 Mach-O 代码,因此 macOS seed 会 staging 每个被引用的内容寻址 Mach-O 对象,最多并发四个独立的 Developer ID 签名进程,并带上安全时间戳与 hardened runtime。任一签名失败后,准备过程会等待已启动的签名进程全部退出,原始 CAS 对象与包索引保持不变。所有签名成功后,准备过程把每个对象写到新的 SHA-512 路径,并以事务方式重写 pnpm MessagePack SQLite 索引内全部基础文件和 side-effects 文件引用。第二次离线安装证明 pnpm 可以解析重写后的 store;准备过程随后完成分片、解包最终归档并验证每个内嵌签名。包路径和非原生字节保持不变;种子保留包内附带的架构变体,因为删除文件会创建 Desktop 专属的包文件集。种子完整性覆盖分片 manifest 和解包前的每个归档。启动时验证归档路径、条目类型、唯一性和数量,把所有分片解包到唯一且由 Desktop 拥有的 staging 目录,替换匹配的不可变 store 文件,并以事务方式把各 pnpm store 版本的 SQLite `package_index` 合并进 `.dsh/desktop/pnpm/store`。Seed 记录替换匹配的键,为 Desktop 插件下载的记录继续保留。中断的文件合并可能留下有效的不可变缓存内容,但每次 SQLite 合并都是原子的,profile 安装与激活仍必须通过 pnpm 完整性与完整健康检查。 + +启动过程先要求安装包内的发布身份等于 Electron 应用版本,再在启动后端前比较 `.dsh/profiles/desktop/desktop-release.json`、已安装 dsh 包、已安装 Desktop Host 包与该发布版本。它在 staging 中通过 `pnpm install --offline --frozen-lockfile --trust-lockfile` 安装新的 seed manifest 与 lockfile。Electron 替换后,启动过程再通过一次离线 pnpm add,从桌面端现有 store 与元数据缓存恢复活跃 profile 记录的每个插件 bundle 精确版本。完整依赖图必须通过同一套健康检查才能激活。 + +插件 GUI 执行等价于 `pnpm add --save-exact`、`pnpm remove ` 和精确版本更新的 registry npm 包操作。每次修改都保留本地核心包描述文件、tarball、dsh 与 Desktop Host 依赖和完整 override 映射。Electron 验证已安装包 manifest,并更新 profile 的依赖与有序 bundle 条目;任何渲染进程请求都不能选择 registry、安装目录、生命周期策略或任意 pnpm flag。 + +后端与 Loader 把 `.dsh/profiles/desktop/package.json` 作为 profile manifest 和 npm 解析锚点。公共 profile loader 先组合其中的有序 bundle 条目,再由私有 Desktop Host 应用其打包的 overlay。Host、dsh、Cordis、桌面插件、插件依赖和 peer dependency 均通过普通 pnpm `node_modules` 图解析。贡献 `dsh.client` 代码的桌面插件只有在完整 profile 通过健康检查后才进入启动 manifest。 + +## 更新与恢复 + +Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-builder` 产物。该版本就是 Desktop 发布版本;不存在独立 dsh manifest、兼容范围或仅更新 dsh 的操作。前台安装会等待正在进行的后台检查,而不会把检查结果复用成安装结果。更新弹窗下载并安装 Electron 产物,然后重启进入新发布。 + +新发布在打开窗口前从安装包种子校准 dsh,同时保留已安装桌面插件。健康检查覆盖依赖解析、原生模块、壳 API 兼容性、后端启停、Web 资源和客户端启动图。不兼容插件会阻止激活,并保留上一个项目用于回滚。启动过程会明确失败,而不会运行版本不匹配的壳与 dsh。 + +`DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm staging 事务仍是两个独立操作。 + +## 安全与发布策略 + +核心 dsh 与私有 Desktop Host 只能来自签名 Electron 发布内经过完整性记录的本地 npm tarball;pnpm overrides 防止传递核心包回退到 registry。Store 归档经过完整性检查,并在隔离的解包目录中完成全部验证,归档文件随后才能进入可写包状态。插件安装接受桌面策略允许的 registry 包 spec,但绝不接受原始 pnpm 命令。激活前必须具备精确版本、lockfile 完整性、经过评审的 `allowBuilds` 集合、仅限用户的目录权限、遮盖后的诊断和健康检查。 + +Electron 产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。Seed 准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 + +Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 seed 准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 + +打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、seed 或活跃项目。 + +在种子 store 子集之外,内置上游 Node.js 与 pnpm 预计增加约 35–50 MB 压缩体积和 120–165 MB 安装体积。分架构构建必须报告实际组件级体积增量。 + +## 实现 + +| 表面 | 实现 | +|---|---| +| 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、项目事务、插件 GUI、更新协调和 electron-builder 配置。 | +| 已安装运行时 | 私有 `@deepseek-ai/dsh-desktop-host` 从活跃项目启动无端口桌面组合,并通过经过验证的分帧字节管道流式传输 API 与资源响应。 | +| 包状态 | 发布种子和后续每次修改都通过内置 Node.js 与 pnpm 执行,并使用桌面端拥有的 store、config、cache、state 和 home 路径;核心包从发布 tarball 解析,插件从固定 npm registry 解析。 | +| 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在解包最终归档后验证每个原生 seed 对象,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 | + +`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、seed 安装和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。 + +## 考虑过的替代方案 + +**使用 Electron 的 Node.js 执行 dsh。** 这可以减小包体积,但会让 dsh 耦合到 Electron 的 Node 补丁、fuse、原生 ABI、TLS 行为和进程生命周期。内置上游 Node.js 可以让 dsh 继续使用其受支持运行时。 + +**通过 JSON IPC 以 Base64 承载 Fetch 消息体。** JSON IPC 可以只保留一种消息机制,但会膨胀每个请求与响应消息体、在两个进程中构造大字符串、在分派前缓冲完整请求,还会再次编码 RPC JSON 中已经表示为 Base64 的图片字节。原始分帧管道保留明确的带版本协议,同时不要求 Electron 与上游 Node.js 共享 V8 序列化行为。 + +**把产品 Web UI 永久打包进 Electron。** 独立 UI 与后端更新需要新的版本化兼容计划。从同一个 dsh 包安装后端与 Web UI 可以保持当前发布绑定。 + +**复用现有 CLI 或浏览器插件安装器。** 这会跨越桌面授权与发布 scope,并可能使用用户的包管理器状态。桌面包修改完全由 Electron 拥有。 + +**让 desktop profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一方的依赖图、Cordis 版本、插件版本或原生模块。因此 desktop profile 持有完整 `node_modules`,并拒绝通过 CLI profile fallback 解析 bundle。 + +**把 dsh 与插件安装到不同桌面项目。** 这会产生第二解析锚点和 peer dependency 回退。一个普通 npm 项目已经提供所需安装与解析模型。 + +**从 registry 包删除非目标 Mach-O 文件。** 架构裁剪可以节省少量 seed 空间,但包可能有意附带多个架构变体,调用方也可以观察安装后的文件集。签署每个实际携带的 Mach-O 对象,无需发明 Desktop 专属包布局就能满足公证要求。 + +**把 Windows EV 私钥导出到 PFX 文件。** 外部提供的公开叶证书让 SignTool 构造签名,`/csp` 与 `/kc` 则定位硬件密钥。EV 私钥保持不可导出,并留在 Token 上。 + +**提交包含凭据的签名脚本或持久保存 Token Password。** 包含凭据的 CMD 文件、`.env` 或 Windows 用户/系统环境变量都会让 Token Password 以静态形式被读取。已提交的 CMD 只包含环境变量引用,打包步骤则把密码作为 runner 临时 secret 接收。 + +**让 electron-builder 或通用目录同步直接发布。** 直接发布可能在所有引用产物就绪前暴露频道元数据,可能把陈旧或其他目标的文件混入发布,也无法证明已完成签名的构建仍与当前 dsh 版本一致。目标专用且经过校验的上传可以明确控制发布顺序与发布身份。 + +## 结果 + +- 没有系统 Node.js 或 pnpm 的干净离线机器把种子安装进 `.dsh/profiles/desktop`,并启动可工作的 dsh 会话。 +- 已签名应用记录固定少量的 seed store 分片,而不是记录每个 pnpm 缓存文件;macOS 分片内每个 Mach-O 对象都带有发布 Developer ID、安全时间戳与 hardened runtime,每个 Windows 产物都带有配置的硬件支持 EV 签名,安装后的私有 store 仍保持普通 pnpm 布局。 +- `.dsh/profiles/desktop/node_modules` 包含并解析桌面 dsh 包和每个 GUI 安装的桌面插件。 +- 每个桌面 pnpm 操作都使用内置可执行文件和 `.dsh/desktop/pnpm/store`;不读取用户 `PATH`、配置、store 或 profile `node_modules`。 +- Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。 +- 后端与浏览器应用不能修改桌面包。 +- npm/CLI dsh 与 Electron 绝不从对方的 `node_modules` 解析或安装插件。 +- 在产品窗口打开前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。 +- 安装、健康检查或更新失败后,当前 profile 仍然可用,或在重启后恢复 `rollback/profile`。 +- 一个 Desktop 版本绑定 Electron 与 dsh;每次 dsh 更新都通过一个 Electron 更新弹窗交付,并产生一次用户可见的重启。 +- 共享 `.dsh` 数据在迁移或修改前拒绝不兼容的读取方。 +- 不打开回环监听端口,沙箱渲染进程不能访问任意文件系统或 Electron API。 +- Workspace 开发无需下载发布资源即可运行当前已构建代码,未封装安装器的应用验证仍保留生产安装路径。 +- Windows 发布打包要求已验证的 SignTool、EV Token、匹配的公开叶证书、Token Password 和明确的密钥容器,绝不会回退到未签名产物或可导出的密钥文件。 +- 目标更新只有在已完成签名的构建及其引用的每个产物通过发布校验后才能暴露新频道元数据;保留的历史产物继续供差分更新使用。 +- 每个发布阻断平台上的签名已安装产物均能从上一个受支持版本成功更新。 + +## 评审决策 + +| 决策 | 建议 | +|---|---| +| 首次启动 | 打包离线种子 store 子集,并通过 pnpm 安装 | +| Desktop profile | 一个包含精确 dsh 与插件依赖、由 Electron 持有的保留 profile | +| 插件管理 | Electron-only GUI 与包服务;没有 CLI、后端或浏览器安装路径 | +| 激活 | 暂存项目、完整健康检查、记录式目录替换和一个回滚副本 | +| 初始平台 | macOS arm64/x64 与 Windows x64;Linux 尚无受支持的发布目标 | +| 更新行为 | 后台检查,差分下载与重启前显式确认,启动时校准 dsh | + +## 风险 + +插件生命周期脚本会执行第三方代码。在 GUI 安装功能交付前,获准 registry、包策略、精确版本、完整性、`allowBuilds` 和诊断都需要安全评审。 + +更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。pnpm 解析与完整项目健康检查必须在替换活跃项目前拒绝暂存项目。 + +通过 npm 安装的 dsh 与桌面 dsh 可能在共享持久化数据时使用不同版本。每个共享 owner 都必须在读取、迁移或写入前执行格式版本与进程锁。 + +不同操作系统的目录替换行为不同,而且可能中断。激活记录与已安装产物故障测试必须证明每次文件系统移动都可以恢复。 + +代码签名、公证和更新托管需要生产发布基础设施。只运行仓库测试不能完成这些认证。 diff --git a/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.i18n.yaml new file mode 100644 index 0000000000..72cf5954dc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-client-resource-model.md +2026-09-05-client-resource-model.md: 75502ffc91af049bf89b7c36ec6ae3dc1339a5f8 +2026-09-05-client-resource-model.zh.md: d1430e88fc16b46a6ad32bbeacb1d59e0a7f6131 diff --git a/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.md b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.md new file mode 100644 index 0000000000..75502ffc91 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.md @@ -0,0 +1,88 @@ +# Agent Note: Client resource model + +Status: implemented + +English | [中文](2026-09-05-client-resource-model.zh.md) + +## Problem + +A right-Sidebar tab body, a chat card, or any other slot component often needs live data it knows only by address: the file an agent just wrote, later a chat node or a terminal. Before the resource model each consumer fetched for itself — the text preview owned its own Remote call and refresh loop — so every mount re-read, two components showing one file held two copies, switching tabs unmounted the body and lost its content, and each new kind of content meant a new bespoke hook. + +The tab record set the constraint. A tab must survive undo, redo, reload, and hot module replacement without the code that opened it, so the record can hold only serializable data: an address and navigation parameters. The opener therefore cannot hand a body its data, and injection is the wrong tool — injection is a registration-time relation between a domain and a seat, while opening is a runtime event. A component has to find its data from the address alone, through something registered once by whoever owns that kind of data. + +## Decision + +[`packages/client/resources`](../../../../packages/client/resources/README.md) (`@deepseek-ai/dsh-client-resources`) provides `ctx.resources` and the `useResource` global standard hook. Anything a consumer reads live is a **resource**, a resource is identified by its **address** and nothing else, and the address's protocol names the one **provider** that turns it into a frame stream. + +### Addresses + +A resource address is a `dsh-resource:///…` URL. The host is the protocol key — the key of `ResourceProtocolMap` — and the path belongs to the protocol's owner. `RESOURCE_SCHEME = 'dsh-resource'` is the one scheme constant; `protocolOf(address)` parses the string with `new URL`, requires `protocol === 'dsh-resource:'`, and returns the lower-cased host, or `undefined` for a string the parser rejects, another scheme, or an empty host. `dsh-resource` is not one of the URL specification's special schemes, so the parser keeps the host's case and treats the path as opaque; the lower-casing is explicit, and each path segment is percent-encoded by the protocol that defines it. A protocol that needs a scope encodes it in the path: `dsh-resource://file/session//`, with `session/` naming the session whose root resolves the file, or `dsh-resource://file/absolute/`, which carries no session and is read through the current one ([grammar](../../../../packages/util/workspace-path/README.md)). Any other scheme — `sidebar://guide` — is a navigation address: it names a tab, not data, and the model answers `none` for it ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)). + +### The service + +```ts ignore-check +interface Resources { + register

(provider: ResourceProvider

): () => void + pin(address: string, signal: AbortSignal): void + source(address: string): ObservableSnapshot> +} + +interface ResourceProvider

{ + readonly protocol: P + open(address: string, ctx: { readonly signal: AbortSignal }): AsyncIterable> + reload?(address: string): void +} + +interface ResourceSnapshot { + readonly status: 'none' | 'loading' | 'live' | 'failed' + readonly value: Value | undefined + readonly failure: RemoteFailure | undefined + readonly reload: () => void +} + +type UseResource =

(address: string) => ResourceSnapshot +``` + +`register` owns exactly one provider per protocol: a second registration for the same protocol throws, and the registration is an effect on the registering plugin's fiber, so a protocol leaves with its plugin and may be registered again afterwards. `pin` holds a resource open without subscribing until the signal aborts; an already-aborted signal pins nothing. `source` is the bare observable behind the hook, reference-stable per address, for callers outside React. The value type is looked up in `ResourceProtocolMap`, declared as an empty interface in `ui-slots` beside `SlotMap` — a module augmentation cannot introduce an export the target module lacks, and every consumer already depends on `ui-slots` — and each protocol's owner declaration-merges its member (`file: WorkspaceFileResource`); the resources package re-exports the type. + +### The hook + +`useResource` is declared on `GlobalStandardProps` in `ui-slots`, so every slot component has it whatever its scope, and the plugin provides it through `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })`, the same root keyed-hook path `useSessions` uses. It is not a session standard prop: a resource carries its own scope in its address, and components outside any session scope read resources too. `useResource

(address)` returns the snapshot: `none` when the address's protocol has no provider or the address is not a resource address, `loading` between the stream opening and its first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value. `reload()` asks the provider for a fresh frame and is a no-op when the protocol has no provider or no `reload`. + +### Frames + +A provider yields `RemoteResult` frames: the current state first, one frame per later change. An `ok` frame makes the resource `live`, replaces the value, and clears the failure; an `ok: false` frame makes it `failed`, records the failure, and keeps the last value. Failure is data, not an exception: the Remote face already folds failures into `ok: false` and never rejects, providers pass those frames on, and the model neither catches nor wraps — a throw inside a provider's stream is a programming error left to surface. A stream that ends on its own keeps its last state; frames a provider yields after the release that aborted it are dropped and the iterator is returned. Streams carry metadata, not payload: the `file` value is `{ absolutePath, version, bytes?, changed }`, and a consumer reads content itself, by page, through the [Workspace Files service](2026-09-05-workspace-files-service.md). + +### Lifecycle + +One record exists per address. Its holders are the hook's subscribers plus pins; the first holder opens the provider's stream under an `AbortController`, later holders share it and read the latest value at once, and the last release aborts the stream and resets the snapshot to idle — `loading` while a provider is registered, `none` otherwise. A provider that arrives while an address is already held opens that address's stream; one that leaves aborts it and the address reads `none`. Records are kept for the page lifetime so `source(address)` stays reference-stable across React's render-then-subscribe window and a StrictMode remount, where a recreated record would resubscribe and restart the stream on every render. + +The right Sidebar's Tab domain pins every open tab record's address for the record's life, so switching tabs unmounts a body without closing its stream and switching back reads the latest value; a record restored by undo is a new pin, and a resource the model already let go is read again ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)). `openResource(address)` accepts resource addresses only; pages such as the guide and the file tree are opened by kind and never enter the resource model. + +## Alternatives considered + +**Session-bound resources: `useResource` on the session kit and a `(session, address)` identity.** The first form. Rejected because a file is not a session concern — the session is only who authorizes the path — and because the model must serve protocols and components outside any session scope. Identity became the address alone, the scope moved into the address grammar, and the hook moved to the global kit. + +**Content in the resource stream.** Rejected: content can be arbitrarily large, and a stream is for pushing change, not payload. The stream carries metadata and the consumer reads content by page, which is also what lets one open tab hold a multi-megabyte file at the cost of one page. + +**Failure as a thrown error, wrapping a non-`RemoteFailure` throw as `gateway/internal`.** Rejected: the Remote face never rejects, so anything a provider throws is a bug, and wrapping it would be a fallback that hides the bug from the developer who caused it. A failure is an `ok: false` frame; a throw surfaces. + +**`file:///`, then `file:////` with the scope in the authority.** Two earlier grammars. The single-slash form was not a URL the platform parser accepted, so every consumer hand-parsed it. Moving the scope into the authority made it a URL but gave each resource protocol its own scheme — `file://`, later `chat://`, `terminal://` — so the set of schemes grew with the set of protocols, a `file://` address no longer meant what it means everywhere else, and telling a resource address from a navigation address needed a list. The single `dsh-resource:///…` scheme makes that test one comparison, leaves the host free to name the protocol, and keeps every other scheme available to navigation. + +**A hand-parsed scheme prefix instead of the URL parser.** The first `protocolOf` matched a regular expression for the scheme. Rejected once addresses were URLs: the parser already decides validity and case, and a string it rejects should read as "no protocol" rather than be half-parsed. + +**A per-tab stream hook, or a framework-managed `useTabResource(fetch)`.** Rejected in turn: a stream hook on the tab domain asks the wrong owner — `file` data must come from the workspace file service, chat data from the chat domain — and a framework-owned fetch has no good cache key. What remains is owner props on the tab plus one client-wide `useResource` keyed by address. + +## Consequences + +Any slot component reads live data by address and nothing else, so an opener passes data only and a body reconstructs itself from its record after undo, reload, or hot replacement. Two components showing one address share one stream, and a pinned address survives its body's unmount. A protocol's transport lives in exactly one provider, and adding a protocol is one declaration-merged type plus one registration. + +The costs are recorded here so they are not rediscovered. Records are never reclaimed: memory grows with the number of distinct addresses ever read, not with reads. Abort compliance rests with the provider; the model drops what a released stream still yields but cannot stop a provider that ignores the signal before its next frame. The failure type is the Remote face's `RemoteFailure`, so a provider whose source is not a Remote call has to mint one. A navigation address or a malformed string reads as `none` rather than an error, which keeps mixed address lists cheap to render but gives a misspelled protocol no diagnostic beyond the missing value. + +## Testing + +`packages/client/resources/tests/resources.client.spec.ts` drives the registry with scripted feeds: protocol ownership and disposal, `none` for a protocol without a provider and for a navigation address, a provider arriving after a held address and leaving while it is held, registrations dropped with their fiber, open-on-first-holder and close-on-last, one source per address, pins including an already-aborted signal, a remount reading the latest value without reopening, reopening as a fresh stream, frames after abort dropped with the iterator returned, a stream ending on its own, failure frames beside the last value, and `reload` forwarding. `tests/apply.client.spec.ts` mounts the plugin in `SlotTestRuntime` and checks, through a root-scope probe component, that `useResource` reaches props, that rendering it opens the provider's stream, and that disposing the plugin withdraws both the service and the hook. + +## Deferred + +Reclaiming idle records, a resource-owned failure type decoupled from the Remote face, and the `chat` and `terminal` protocols are open; each waits for a consumer. The developer-facing reference is [docs/subsystems/client-resources.md](../../../../docs/subsystems/client-resources.md); the Sidebar that consumes the model is described in [docs/subsystems/sidebar-right.md](../../../../docs/subsystems/sidebar-right.md). diff --git a/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md new file mode 100644 index 0000000000..d1430e88fc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md @@ -0,0 +1,88 @@ +# Agent Note: 客户端资源模型 + +Status: implemented + +[English](2026-09-05-client-resource-model.md) | 中文 + +## Problem + +右侧 Sidebar 的 tab 正文、聊天卡片或任何别的 slot 组件,常常需要只以地址可知的活数据:agent 刚写的文件,将来的聊天节点或终端。资源模型出现前每个消费方各自取数——文本预览自己持有 Remote 调用与刷新循环——于是每次挂载都重读、两个组件显示同一文件就持有两份、切 tab 卸载正文就丢内容,每种新内容都意味着一个新的专用 hook。 + +约束来自 tab 记录。tab 必须在打开它的代码不在场时挺过撤销、重做、刷新与热替换,所以记录只能存可序列化的数据:一个地址与导航参数。因此开启方不能把数据交给正文,注入也不是合适的工具——注入是领域与席位之间注册期的关系,而打开是运行期事件。组件必须只凭地址找到数据,途径是由数据拥有者注册一次的东西。 + +## Decision + +[`packages/client/resources`](../../../../packages/client/resources/README.zh.md)(`@deepseek-ai/dsh-client-resources`)提供 `ctx.resources` 与 `useResource` 全局标准 hook。消费方活读的任何东西都是**资源**,资源只由其**地址**标识,地址的协议命名唯一一个把它变成帧流的**提供方**。 + +### 地址 + +资源地址是 `dsh-resource:///…` 形式的 URL。host 是协议键——`ResourceProtocolMap` 的键——路径归协议拥有者。`RESOURCE_SCHEME = 'dsh-resource'` 是唯一的 scheme 常量;`protocolOf(address)` 用 `new URL` 解析字串,要求 `protocol === 'dsh-resource:'`,返回小写 host;解析器拒绝的字串、其它 scheme 或空 host 返回 `undefined`。`dsh-resource` 不是 URL 规范里的特殊 scheme,解析器会保留 host 的大小写并把路径当作不透明串,所以小写化是显式做的,每段路径由定义它的协议做百分号编码。需要作用域的协议把作用域编进路径:`dsh-resource://file/session//<相对该会话工作区根的路径>`,`session/` 命名以其根解析该文件的会话;或 `dsh-resource://file/absolute/<绝对路径>`,不带会话、经当前会话读取([语法](../../../../packages/util/workspace-path/README.zh.md))。其它任何 scheme——`sidebar://guide`——是导航地址:它命名一个 tab 而非数据,模型对它回答 `none`([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。 + +### 服务 + +```ts ignore-check +interface Resources { + register

(provider: ResourceProvider

): () => void + pin(address: string, signal: AbortSignal): void + source(address: string): ObservableSnapshot> +} + +interface ResourceProvider

{ + readonly protocol: P + open(address: string, ctx: { readonly signal: AbortSignal }): AsyncIterable> + reload?(address: string): void +} + +interface ResourceSnapshot { + readonly status: 'none' | 'loading' | 'live' | 'failed' + readonly value: Value | undefined + readonly failure: RemoteFailure | undefined + readonly reload: () => void +} + +type UseResource =

(address: string) => ResourceSnapshot +``` + +`register` 让每个协议恰有一个提供方:同一协议的第二次注册抛错,注册是挂在注册方插件 fiber 上的 effect,所以协议随插件离开、之后可再注册。`pin` 在不订阅的情况下让资源保持打开直到信号中止;已中止的信号什么也不钉。`source` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用。值类型在 `ResourceProtocolMap` 里查得,它作为空接口声明在 `ui-slots` 里、与 `SlotMap` 并列——模块增强无法给目标模块添加它没有的导出,而每个消费方本来就依赖 `ui-slots`——各协议拥有者声明合并自己的成员(`file: WorkspaceFileResource`);resources 包再导出这个类型。 + +### hook + +`useResource` 声明在 `ui-slots` 的 `GlobalStandardProps` 上,因此每个 slot 组件不论作用域都有它,插件经 `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })` 提供,与 `useSessions` 走同一条根 keyed hook 路径。它不是会话标准 prop:资源的作用域随地址携带,会话作用域之外的组件也要读资源。`useResource

(address)` 返回快照:地址协议没有提供方或地址不是资源地址时为 `none`,流已打开、首帧未到时为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。`reload()` 请提供方给一个新帧,协议没有提供方或提供方没有 `reload` 时是空操作。 + +### 帧 + +提供方产出 `RemoteResult` 帧:首帧是当前状态,之后每次变化一帧。`ok` 帧使资源 `live`、替换值、清除失败;`ok: false` 帧使其 `failed`、记下失败、保留最后一个值。失败是数据不是异常:Remote 面本来就把失败折进 `ok: false` 且从不 reject,提供方原样转发这些帧,模型既不捕获也不包装——提供方流里抛出是编程错误,任其冒出。自行结束的流保持最后状态;提供方在中止它的那次释放之后产出的帧被丢弃,迭代器被归还。流只推元数据不推载荷:`file` 的值是 `{ absolutePath, version, bytes?, changed }`,消费方自己经 [Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)按页读内容。 + +### 生命周期 + +每个地址一条记录。持有者是 hook 的订阅者加 pin;第一个持有者在 `AbortController` 下打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并把快照重置为空闲——有提供方注册时为 `loading`,否则为 `none`。地址已被持有时到达的提供方会打开该地址的流;离开的提供方中止它,地址读作 `none`。记录在页面存续期内保留,使 `source(address)` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定,否则重建记录会让每次渲染重订阅、重开流。 + +右侧 Sidebar 的 Tab 域在每条打开的 tab 记录存续期内钉住其地址,所以切 tab 卸载正文不关流、切回读到最新值;撤销恢复的记录是一次新的钉住,模型已放掉的资源会重新读取([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。`openResource(address)` 只收资源地址;引导页与文件树这类页面按 kind 打开,从不进入资源模型。 + +## Alternatives considered + +**会话绑定的资源:`useResource` 挂会话标准件、身份为 `(session, address)`。** 第一版形态。被否,因为文件不是会话的事——会话只是路径的授权者——而且模型必须服务会话作用域之外的协议与组件。身份改为只有地址,作用域进入地址语法,hook 移到全局标准件。 + +**内容进资源流。** 被否:内容可能任意大,流是用来推变化的,不是推载荷。流只带元数据,消费方按页读内容,这也是一个打开的 tab 能以一页的代价承载数兆字节文件的原因。 + +**以抛错表达失败,并把非 `RemoteFailure` 的抛出包装成 `gateway/internal`。** 被否:Remote 面从不 reject,所以提供方抛出的任何东西都是 bug,包装它就是把 bug 藏起来不让肇事者看见的 fallback。失败是 `ok: false` 帧;抛出就冒出来。 + +**`file:///`,再到把作用域放在 authority 位的 `file:////`。** 两版更早的语法。单斜杠形态不是平台解析器接受的 URL,每个消费方都得手工解析。把作用域移到 authority 位使它成为 URL,却让每个资源协议各占一个 scheme——`file://`、将来的 `chat://`、`terminal://`——scheme 的集合随协议集合增长,`file://` 地址不再是它在别处的含义,区分资源地址与导航地址需要一张清单。单一 scheme `dsh-resource:///…` 让这个判断只需一次比较,host 留给协议命名,其它所有 scheme 留给导航。 + +**手写 scheme 前缀解析代替 URL 解析器。** 第一版 `protocolOf` 用正则匹配 scheme。地址成为 URL 后被否:解析器已经决定合法性与大小写,它拒绝的字串应读作「无协议」而不是被解析一半。 + +**每 tab 一个流 hook,或框架代管的 `useTabResource(fetch)`。** 依次被否:挂在 tab 域上的流 hook 问错了拥有者——`file` 数据必须来自工作区文件服务,聊天数据来自聊天域——而框架代管的 fetch 没有好的缓存键。留下的是 tab 上的 owner props 加一个按地址的客户端级 `useResource`。 + +## Consequences + +任何 slot 组件只凭地址读活数据,于是开启方只传数据,正文在撤销、刷新或热替换后能从记录重建自己。显示同一地址的两个组件共享一条流,被钉住的地址在正文卸载后仍存活。一个协议的传输只住在一个提供方里,新增协议只是一个声明合并的类型加一次注册。 + +代价记录在此以免被重新发现。记录不回收:内存随读过的不同地址数增长,而非随读取次数增长。中止合规归提供方;模型会丢弃已释放的流仍产出的帧,却阻止不了忽略信号的提供方跑到下一帧。失败类型是 Remote 面的 `RemoteFailure`,来源不是 Remote 调用的提供方得自己铸一个。导航地址或畸形字串读作 `none` 而非报错,这让混合地址列表渲染起来便宜,却让拼错的协议除了缺值之外没有任何诊断。 + +## Testing + +`packages/client/resources/tests/resources.client.spec.ts` 用脚本化的 feed 驱动注册表:协议归属与注销、无提供方的协议与导航地址都为 `none`、提供方在地址已被持有后到达与在持有中离开、注册随 fiber 消失、首个持有者开流末个关流、一址一源、包括已中止信号在内的 pin、重挂读到最新值且不重开、重开为新流、中止后帧丢弃且迭代器归还、流自行结束、失败帧与最后值并存、`reload` 转发。`tests/apply.client.spec.ts` 在 `SlotTestRuntime` 里挂载插件,经一个根作用域探针组件验证 `useResource` 到达 props、渲染它即打开提供方的流、dispose 插件同时撤走服务与 hook。 + +## Deferred + +回收空闲记录、与 Remote 面解耦的资源自有失败类型、`chat` 与 `terminal` 协议都还开放;各自等待一个消费方。面向开发者的参考是 [docs/subsystems/client-resources.md](../../../../docs/subsystems/client-resources.zh.md);消费这个模型的 Sidebar 见 [docs/subsystems/sidebar-right.md](../../../../docs/subsystems/sidebar-right.zh.md)。 diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml new file mode 100644 index 0000000000..f7a6978cca --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md +2026-09-05-sidebar-tab-types-and-navigation.md: a79292eba25828287ce43ad16b7eb917dcdddcb4 +2026-09-05-sidebar-tab-types-and-navigation.zh.md: bf661948036257366714617b192525ac35b0905f diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md new file mode 100644 index 0000000000..a79292eba2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md @@ -0,0 +1,121 @@ +# Agent Note: Right Sidebar tab types and navigation + +Status: implemented + +English | [中文](2026-09-05-sidebar-tab-types-and-navigation.zh.md) + +## Problem + +The [docking surface](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) gives the right Sidebar panes, tabs, and floating panels, but a pane full of tabs is only useful if other plugins can put content into them. That needs three contracts the surface itself does not define: how a plugin declares a kind of tab and the addresses it can show, how any caller — a produced-file chip in the conversation, a row in a file tree, a plugin's own button — asks the Sidebar to show something, and what a tab's body may rely on at runtime. Each contract is a public face that plugins shipped from outside this repository will write against, so each has to be settled before those plugins exist: a renamed field, a changed enum value, or a different address grammar afterwards breaks every one of them. + +Two constraints shaped the answers. Dynamic client plugins may not import runtime values from one another — a function, a constant, a class — only types, so nothing in these contracts may require a helper function or an exported constant from the Sidebar package. And the Web client already has one component model, the Slot system; a second one for tabs would be a parallel framework to learn and maintain. + +## Decision + +A tab type is a static registration into `ctx.sidebarRightTabs`; a tab's body and title are ordinary keyed Slot registrations; `ctx.sidebarRight` opens content in exactly two ways — a resource by address, or a page by kind — and otherwise only operates the layout; and bodies read occurrence information through the framework-injected `useTabInfo()`. The four faces are described below in the order a plugin author meets them. + +### The type registry: `ctx.sidebarRightTabs` + +`register(definition): () => void` records one tab type and returns the disposer the caller holds in its own `ctx.effect`, so a type lives exactly as long as the plugin that contributed it. The definition is static: + +```ts ignore-check +interface SidebarRightTabDefinition { + readonly id: string // this implementation's identity in the tab system + readonly kind: string // what the tabs of this type are; what openTab names + readonly patterns?: readonly string[] // resource-address globs; omitted by a page type + readonly priority?: 'extension' | 'builtin' | 'fallback' // defaults to extension + readonly canOpen?: (address: string) => boolean // veto after a glob matched + readonly title: (address: string) => string // chip text, captured at open time + readonly guide?: readonly SidebarRightGuideEntry[] // entry boxes on the guide page +} +``` + +`id` and `kind` are different things. `kind` is the type discriminator — what a tab *is*, what `openTab` names, what tab identity is built from. `id` is the identity of one *implementation* of a kind, unique across every registration; a package name is the natural value. The two are separate because a kind is not unique: an `extension` may register the kind a `builtin` already holds, and the two implementations then coexist in the registry with the extension in force. The registry rejects a second registration of an `id`, a second registration in the same band of a kind, and any registration meeting a `fallback` of the same kind; it accepts exactly the extension-over-builtin pair, and the builtin resumes when the extension unregisters. + +`patterns` are globs over resource addresses, matched with `picomatch` under VS Code's editor-resolver rule with one local change: a pattern containing `:` is matched against the whole address (`dsh-resource://file/**`), one without is matched against the URI's path at any depth (`*.md`), matching ignores case and does not hide dotfiles, and an address that is not a URI matches no path pattern. A page type — the guide, the file tree — recognizes no address and omits `patterns`; it is opened by kind. + +`priority` is one of three literal bands, spelled as strings so that a type from another package needs no runtime import: `extension` is the band of a type from outside the product and the highest, so a type that declares nothing outranks every viewer shipped here; `builtin` is the ordinary band for shipped types; `fallback` is the plain-content position that anything more specific should beat, which VS Code's text editor holds implicitly and our text preview holds explicitly. `candidates(address)` returns every type whose globs match and whose `canOpen` does not veto, ranked by band, then by the length of the longest pattern that matched, then by registration order. `claim(address, kind?)` takes the best candidate, or the named kind's type in force when the caller overrides (its globs are not consulted; naming the type is the decision), and throws for an address nothing will open — a wiring mistake, not a user error. `get(kind)` returns the type in force; `entries()` and `guide()` list the types and their guide boxes in force; `subscribe` observes changes. + +`title(address)` and `guide[].title()` are thunks read on every use, so a language change needs no re-registration. The registry itself is a plain object provided at `apply`'s top level **without** `Service.tracker`: a tracker would rebind `this.ctx` to the caller's context, and a cross-package `register()` would then add its effect to the caller's fiber while that fiber is the active scope, stalling the browser boot with no error. + +### Bodies and titles: keyed Slot seats under the definition's `id` + +The type registry says what a type is; the Slot system says what it looks like. A type registers its body into the keyed, session-scoped seat `sidebar.right.pane.tab` under its own `id`, and may register a title component into `sidebar.right.pane.tab.title` under the same key. The seat that draws a tab resolves the tab's `kind` to the type in force through the registry and dispatches to that type's `id`, so an extension taking over a builtin's kind is rendered without either package knowing about the other, and without any priority number crossing a package boundary. A kind with no type in force renders the owner's "nothing can view this" notice; a type with no title registration gets the `title(address)` text the registry captured when the tab opened. + +Two further seats extend the guide and the menu: `sidebar.right.tab.guide` is a chain whose first non-declining entry replaces the shipped guide body without replacing the tab, and `sidebar.right.tab.menu.item` is a list appended after the kit's own layout actions, for actions that mean something about a tab's content. A type's controls — a reload, a wrap toggle — live inside its own body; the strip belongs to the panel and carries only the panel's controls. A type's own state is an ordinary Slot store and inject face on the body registration; the framework adds nothing to the component model. + +### Tab occurrence information + +[Responsive Sidebar and tab information](2026-09-07-sidebar-responsive-tab-info.md) supersedes this note's choice of flat owner props for occurrence information. Bodies, titles and guide replacements receive the framework-injected `useTabInfo()` to read `{ sidebar, panel, tab }`. The record, navigation, visibility, signal and bound actions live inside `tab`; exact fields belong to the [Sidebar reference](../../../../docs/subsystems/sidebar-right.md). + +The Tab domain still owns one occurrence per committed record, with an `AbortController`, navigation snapshot and actions bound to its Session. It pins the address in the [resource model](2026-09-05-client-resource-model.md) for the record's lifetime; hiding and switching Sessions do not end it, while closing the record aborts and releases it. Existing framework store and navigation hooks provide live reads, without subscriptions in tab implementations. + +### Navigation: `ctx.sidebarRight` + +The face opens content in two ways and does nothing else with content: + +```ts ignore-check +openResource(address: string, options?: { kind?: string; params?: SidebarRightResourceParams; paneId?; replaceTab?: TabId; revealIfOpened?: boolean }): void +openTab(kind: K, options?: { params?: SidebarRightTabParamsFor; paneId?; replaceTab?: TabId; revealIfOpened?: boolean }): void +``` + +`openResource` takes a resource address — a `dsh-resource:///…` URI, the only scheme the resource model has — and asks the registry who shows it: without `kind`, every type is consulted and the ranking decides; with `kind`, that type's implementation in force opens it. An address with any other scheme fails on the same path as an address nothing claims. `openTab` opens a page type by kind and never sees an address: the Sidebar records the tab under `sidebar://`, composed in one place inside the package, so that a page tab has a `contentId` for identity and history like any other tab. The scheme is bookkeeping: no caller composes it, no business package contains the literal, and the file tree and the guide are opened as `openTab('files')` and `openTab('guide')`. + +Both opens run the same four steps: resolve the type (by ranking or by kind), locate an existing tab by `(kind, contentId)` unless `revealIfOpened` is `false`, place the tab — in `replaceTab`'s pane and strip slot, in `paneId`, or in the active pane — and record the expansion, the open-or-focus, and the `replaceTab` close as one history entry before handing `{ address, params }` to the tab domain. Placement is the caller's business, never a type-level trait: the file tree opens into its own pane because it says so, as VS Code's Explorer passes `SIDE_GROUP` or `ACTIVE_GROUP` itself. `replaceTab` means one thing — open in that tab's place and close it in the same step — and exists for the guide's entry boxes, which hand their tab over to the page they name. + +Parameters are typed by what is being opened, through two merge-extensible maps declared in the Sidebar package and augmented by the owners of the keys: + +```ts +interface SidebarRightResourceParamsMap {} // key: resource type — the text preview declares { line?: number } +interface SidebarRightTabParamsMap {} // key: kind — a page type declares its own shape, or nothing +``` + +`openResource` accepts the union of every declared resource shape and `openTab` the shape declared for `K`; a body narrows `navigation.params` by the protocol or kind it knows it serves. Parameters belong to the resource type rather than to the viewer because a line number is a fact about a file location, not about the text preview, and any type that claims `file` addresses receives the same shape. Values must be JSON-serializable, and a record must be rebuildable from address and parameters alone, because undo, redo, reload, and HMR rebuild tabs after the opener is gone. + +Beside the two opens, the face carries `close(tabId)`, `active()`, `isExpanded()`, `toggleExpanded()`, and four operational methods — `focus(tabId)`, `split(paneId?)` (returning the new pane, or `undefined` when the pane budget or the width rule forbids the split, recording nothing), `float(tabId, rect?)`, and `dock(paneId)` — each recording one history entry and a no-op on a missing target or one already in the requested state. There is no layout snapshot, no subscription, and no lookup by address: the face grants control over the layout, not a view of it. The seat publishes its binding — its session, its store's actions, and its surface — while mounted; a command on the public face acts on the mounted session and throws with no mounted session surface. A tab's own actions reach their session's own store instead: the slot runtime mints one store per session, the plugin adopts each as it is minted, and the controller routes by session id, so an action fired after the user switched sessions still lands, and does nothing for a session whose store was never minted. + +### Addresses + +Addresses come in two families that never mix. Resource addresses are the resource model's `dsh-resource:///…` URIs (a workspace file is `dsh-resource://file/session//`, an arbitrary file `dsh-resource://file/absolute/`, both built and parsed by `dsh-util-workspace-path`); they are what `openResource` takes, what `patterns` match, and what `useResource` reads. Navigation addresses name pages rather than data; today the only one is the internal `sidebar://` a page tab is recorded under. Only the resource family is a contract: the navigation family is composed and consumed inside the Sidebar, and a fuller navigation protocol is a later decision that this one leaves room for by keeping every navigation literal in one place. + +### Entry points + +The conversation's `openFile(path, { line? })` — tool-row path links, produced-file chips, closing-message mentions — encodes the path as a file resource address for the Session, and calls `openResource` with `params.line` when the caller knows one; the `read` tool row passes the line its `offset` argument started from. The strip's `+` calls `openTab('guide', { paneId, revealIfOpened: false })` for the pane it sits in; a guide entry box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`; a file-tree row calls `tab.actions.openResource(address)`, which lands in the tree's own pane. + +## Alternatives considered + +**A chain slot for tab dispatch, or a keyed slot alone.** A chain's `select` is not enumerable, and the guide page and the navigation face must enumerate types; a keyed slot carries a body and nothing else, so a type's title and address recognition had nowhere to live. Two stages — a definition registry plus keyed component seats — is the repository's existing pattern (`ConversationViewRegistry`). + +**Runtime hooks or an instance object per tab.** Several forms were tried on paper — a Cordis fiber per tab, an abstract base class, an `initial`/`create` pair returning an instance with `dispose`, a set of `useTab*` hooks, a framework-managed `useTabResource(fetch)`, a `useTabStream`. Rejected in turn: a fiber per tab is far too heavy; dynamic packages cannot share a base class or an exported constant; an instance layer duplicates what a Slot store and inject face already are; per-tab hooks restate owner props; a framework-owned fetch has no good cache key; and a stream hook on the tab domain asks the wrong owner — chat data must come from the chat domain, file data from the workspace file service. What remains is owner props plus one client-wide `useResource`. `visible` was later added as a prop rather than a hook for the same reason: it is one more fact about the occurrence, and the props already carry the occurrence. The rejection of occurrence-reading hooks is superseded by the [tab information decision](2026-09-07-sidebar-responsive-tab-info.md); the independent instance, fiber and data-stream ownership rationale still applies. + +**A per-pane tools seat for the active tab's controls (`sidebar.right.pane.tab.tools`).** Shipped for one review round, then removed: it put type-private buttons on the panel's strip beside the split and collapse controls, where they read as panel chrome. A type's controls belong in its own body. + +**Type-level placement (`opensInto`) and a hidden sibling heuristic.** Rejected: where a tab lands is the opener's business, exactly as VS Code's Explorer decides `sideBySide` itself. + +**Extension lists and numeric priorities.** `claims.extensions` cannot express `.d.ts`, `Dockerfile`, a directory constraint, or a whole scheme — it is a degenerate glob; numeric priorities need an exported constant that dynamic packages cannot import. Literal bands over globs. VS Code's own bands were reduced from five to three: an `option` band (listed, never chosen automatically) has no consumer until an "open with…" affordance exists, and a `default` band was renamed `extension` because the name read as the lowest tier while it is the highest. + +**One `open(address)` for everything, with a helper that builds page addresses.** The first design opened pages by address too, so a business package needed a `sidebar://` literal or a `sidebarAddress(kind)` helper from the Sidebar package. Both are forbidden by the value-import rule and both leak a navigation scheme that is not yet designed. Splitting the face into `openResource` and `openTab` puts the only literal inside the package and lets each mode type its parameters. + +**Naming a specific implementation when opening (`?impl=`), `find(address)`, `mode()`/`setMode()`, a layout snapshot, a `features` list.** All considered and left out. Naming an implementation belongs to a navigation protocol that does not exist yet; `find` and a snapshot would make the face a view of the layout when it is meant to be control over it; presentation mode is a UI toggle, not a plugin concern; a capability list is premature while the face is settling. + +**Slot priorities to express an extension taking over a builtin, then registry-minted slot keys.** The first attempt had the overriding type register its body at a lower slot priority through an exported constant — a value import across dynamic plugins, and a second rule system (slot priority) standing in for the registry's. The second attempt had the registry mint a key per registration and return it from `register()`, which made registration a two-step dance whose ordering mattered. Letting the implementation declare its own `id` — required, unique, the same string it registers its seats under — needs no constant, no minting, and no ordering, and gives the registry the identity it needs to reject duplicates. + +## Consequences + +- A type is one static object plus one or two keyed seat registrations; its occurrence information is read through injected `useTabInfo()`. The framework grows no per-type API surface, and a type shipped from outside this repository imports only types from the Sidebar package. +- Two opens with two parameter maps mean a caller cannot open a page by address or a resource by kind alone, and the compiler tells it so; the cost is that every new resource type or page kind that wants typed parameters augments a map. +- `id` and `kind` being distinct lets an extension replace a shipped type in place, per kind, with the builtin resuming when the extension unregisters; the cost is one more required field on every definition. +- The navigation face is control-only. A plugin that needs to know the layout cannot ask for it, which keeps the layout's shape out of every plugin's contract until a navigation protocol decides what to expose. +- The `sidebar://` literal lives in one file. Changing the navigation grammar later touches the Sidebar package and nothing else. +- These faces are the part of the Sidebar that is fixed: addresses, registration fields and bands, the two opens and their parameter maps, seat names and injected tab information. Everything a user sees as behaviour — where a float snaps, when a split control greys out, the copy, the tree's ordering — is a product rule outside every contract here and changes without notice to any plugin. + +## Testing + +`ui-sidebar-right` specs cover the registry (bands, extension-over-builtin with resumption, `id` and same-band collisions, glob and path matching, `canOpen`, ranking and tiebreaks), both opens (normal, edge, and failure paths including the wrong scheme and an unregistered kind), `replaceTab` as one history entry, the seat resolving a kind to the implementation in force and back, `useTabInfo()` including `tab.visible` under collapse and floating, and the operational methods with their no-op and throw cases. The Web e2e suite drives the guide, the file tree, and a file open through the real plugin graph in Chromium. Both suites are keyless. + +## Deferred + +- A navigation protocol beyond `sidebar://`: sub-routes within a page, naming an implementation, and the ecosystem-facing rules for other navigation schemes. +- Parameters for the shipped page types, which today declare none. +- Opening into a session other than the one on screen from the public face, which acts on the mounted session only; a tab's own actions already act on their tab's session. +- A localized message when an open fails from the conversation; the failure is currently the thrown error's text. diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md new file mode 100644 index 0000000000..bf66194803 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md @@ -0,0 +1,121 @@ +# Agent Note: 右侧 Sidebar 的 tab 类型与导航 + +Status: implemented + +[English](2026-09-05-sidebar-tab-types-and-navigation.md) | 中文 + +## Problem + +[停靠面](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)给了右侧 Sidebar 分栏、tab 与浮动面板,但一格 tab 只有在别的插件能往里放内容时才有用。这需要三份停靠面自身不定义的契约:插件如何声明一种 tab 及其能展示的地址;任何调用方——会话区里的产出文件 chip、文件树里的一行、插件自己的按钮——如何请 Sidebar 展示某样东西;以及 tab 的正文在运行时能依赖什么。每一份都是仓外插件将来要对着写的公开面,所以必须在那些插件出现之前定下来:之后改一个字段名、一个枚举值或地址语法,就会同时弄坏它们全部。 + +两条约束决定了答案。动态客户端插件之间不允许引用运行时值——函数、常量、类——只能引类型,因此这些契约里不得要求从 Sidebar 包引入帮助函数或导出常量。而 Web 客户端已经有一套组件模型,即 Slot 系统;再为 tab 造一套,就是第二个要学要维护的并行框架。 + +## Decision + +tab 类型是向 `ctx.sidebarRightTabs` 的一次静态注册;tab 的正文与标题是普通的 keyed Slot 注册;`ctx.sidebarRight` 只以两种方式打开内容——按地址开资源、按 kind 开页——其余只操作布局;正文通过框架注入的 `useTabInfo()` 读取实例信息。下面按插件作者遇到的顺序描述这四个面。 + +### 类型注册表:`ctx.sidebarRightTabs` + +`register(definition): () => void` 记录一种 tab 类型并返回注销器,调用方把它放进自己的 `ctx.effect`,于是类型的寿命恰好等于贡献它的插件。定义是静态的: + +```ts ignore-check +interface SidebarRightTabDefinition { + readonly id: string // this implementation's identity in the tab system + readonly kind: string // what the tabs of this type are; what openTab names + readonly patterns?: readonly string[] // resource-address globs; omitted by a page type + readonly priority?: 'extension' | 'builtin' | 'fallback' // defaults to extension + readonly canOpen?: (address: string) => boolean // veto after a glob matched + readonly title: (address: string) => string // chip text, captured at open time + readonly guide?: readonly SidebarRightGuideEntry[] // entry boxes on the guide page +} +``` + +`id` 与 `kind` 是两回事。`kind` 是类型判别符——tab *是什么*、`openTab` 点名什么、tab 身份由什么构成。`id` 是某个 kind 的一个*实现*的身份,在全部注册里唯一,包名是自然的取值。两者分开是因为 kind 并不唯一:`extension` 可以注册一个 `builtin` 已持有的 kind,两个实现随即在注册表里共存,生效的是 extension。注册表拒绝重复的 `id`、同一 kind 在同一档的第二次注册、以及任何与同 kind 的 `fallback` 相遇的注册;它只接受 extension 压 builtin 这一对,extension 注销后 builtin 恢复。 + +`patterns` 是资源地址上的 glob,用 `picomatch` 按 VS Code 编辑器解析器的规则匹配,只有一处本地改动:含 `:` 的 pattern 匹配整个地址(`dsh-resource://file/**`),不含的匹配 URI 的路径且任意深度(`*.md`),匹配不区分大小写、不隐藏 dotfile,不是 URI 的地址不匹配任何路径 pattern。页类型——引导页、文件树——不识别任何地址,省略 `patterns`,按 kind 打开。 + +`priority` 是三个字面量档位之一,写成字符串,好让别的包的类型不需要任何运行时引入:`extension` 是来自产品之外的类型的档位也是最高档,所以什么都不声明的类型压过这里随包交付的每个查看器;`builtin` 是随包类型的常规档;`fallback` 是任何更具体的东西都应压过的纯内容位置,VS Code 的文本编辑器隐含地占据它,我们的文本预览明确地占据它。`candidates(address)` 返回 glob 命中且 `canOpen` 未否决的每个类型,按档位、再按命中的最长 pattern 长度、再按注册顺序排序。`claim(address, kind?)` 取最佳候选,或在调用方指定时取该 kind 生效的类型(不查它的 glob;点名即决定),对无人愿开的地址抛错——这是接线错误,不是用户错误。`get(kind)` 返回生效类型;`entries()` 与 `guide()` 列出生效类型及其引导入口;`subscribe` 观察变化。 + +`title(address)` 与 `guide[].title()` 是每次使用时重读的 thunk,语言切换无需重新注册。注册表本身是 `apply` 顶层提供的普通对象,**不带** `Service.tracker`:tracker 会把 `this.ctx` 重绑到调用方上下文,跨包 `register()` 就会在调用方 fiber 仍是活动作用域时往它上加 effect,浏览器启动会无声卡死。 + +### 正文与标题:按定义 `id` keyed 的 Slot 坑位 + +类型注册表说类型是什么;Slot 系统说它长什么样。类型把正文注册进 keyed、session 作用域的坑位 `sidebar.right.pane.tab`,键是自己的 `id`,并可把标题组件注册进 `sidebar.right.pane.tab.title`,键相同。画 tab 的座位经注册表把 tab 的 `kind` 解析成生效类型,再派发到该类型的 `id`,于是 extension 接管 builtin 的 kind 时两个包互不知晓也能正确渲染,且没有任何优先级数字跨过包边界。没有生效类型的 kind 渲染属主的「没有东西能查看它」提示;没注册标题的类型得到注册表在打开时捕获的 `title(address)` 文本。 + +另有两个坑位扩展引导与菜单:`sidebar.right.tab.guide` 是 chain,第一个不拒绝的条目在不替换 tab 的前提下替换随包引导正文;`sidebar.right.tab.menu.item` 是 list,追加在库自身布局动作之后,放与 tab 内容有关的动作。类型自己的控件——重载、换行开关——住在自己正文里;tab 条属于面板,只放面板的控件。类型自己的状态是正文注册上普通的 Slot store 与 inject 面;框架不给组件模型添任何东西。 + +### 标签实例信息 + +[响应式 Sidebar 与标签信息](2026-09-07-sidebar-responsive-tab-info.zh.md)取代本记录中以平铺 owner props 传递实例信息的选择。正文、标题与引导页替换项接收框架注入的 `useTabInfo()`,以 `{ sidebar, panel, tab }` 读取所属 Sidebar、窗格与标签。实例的记录、导航、可见性、signal 与绑定动作均在 `tab` 内;精确字段见 [Sidebar 参考](../../../../docs/subsystems/sidebar-right.zh.md)。 + +标签域仍为每个已提交记录拥有一个实例,包括 `AbortController`、导航快照和绑定到所属 Session 的动作。记录存活期间,其地址被钉在[资源模型](2026-09-05-client-resource-model.zh.md)中;隐藏与切换 Session 不结束实例,关闭记录则中止并释放它。框架已有的存储与导航钩子提供实时读取,类型不自行订阅。 + +### 导航:`ctx.sidebarRight` + +该面以两种方式打开内容,对内容不做别的事: + +```ts ignore-check +openResource(address: string, options?: { kind?: string; params?: SidebarRightResourceParams; paneId?; replaceTab?: TabId; revealIfOpened?: boolean }): void +openTab(kind: K, options?: { params?: SidebarRightTabParamsFor; paneId?; replaceTab?: TabId; revealIfOpened?: boolean }): void +``` + +`openResource` 接一个资源地址——`dsh-resource:///…` URI,资源模型仅有的 scheme——并问注册表谁来展示:不带 `kind` 时问遍所有类型由排序决定;带 `kind` 时由该类型生效的实现打开。其它 scheme 的地址与无人认领的地址走同一条失败路径。`openTab` 按 kind 打开页类型,永远见不到地址:Sidebar 把该 tab 记账在 `sidebar://` 下,这个字面量只在包内一处拼装,为的是页 tab 与其它 tab 一样有 `contentId` 供身份与历史使用。这个 scheme 只是记账:没有调用方拼它,业务包里没有这个字面量,文件树与引导页分别以 `openTab('files')`、`openTab('guide')` 打开。 + +两种打开走同样四步:解析类型(按排序或按 kind);除非 `revealIfOpened` 为 `false`,否则按 `(kind, contentId)` 定位已有 tab;落位——落在 `replaceTab` 的格与条位、`paneId`、或活跃格;把展开、打开或聚焦、以及 `replaceTab` 的关闭记为一条历史,再把 `{ address, params }` 交给 tab 域。落位是调用方的事,从不是类型级特性:文件树把文件开进自己的格是因为它自己说了,正如 VS Code 的 Explorer 自己传 `SIDE_GROUP` 或 `ACTIVE_GROUP`。`replaceTab` 只有一个含义——在那个 tab 的位置打开并在同一步关掉它——为的是引导页入口框把自己的 tab 交给所点的页。 + +参数按被打开的东西定型,经 Sidebar 包声明、由键的拥有者增补的两张可声明合并表: + +```ts +interface SidebarRightResourceParamsMap {} // key: resource type — the text preview declares { line?: number } +interface SidebarRightTabParamsMap {} // key: kind — a page type declares its own shape, or nothing +``` + +`openResource` 接受所有已声明资源形状的联合,`openTab` 接受为 `K` 声明的形状;正文按自己所服务的协议或 kind 收窄 `navigation.params`。参数属于资源类型而非查看器,因为行号是关于文件位置的事实,不是关于文本预览的,任何认领 `file` 地址的类型收到同一形状。值必须可 JSON 序列化,一条记录必须只凭地址与参数就能重建,因为撤销、重做、刷新与 HMR 都在开启方已不在时重建 tab。 + +除两种打开外,该面还有 `close(tabId)`、`active()`、`isExpanded()`、`toggleExpanded()`,以及四个操作型方法——`focus(tabId)`、`split(paneId?)`(返回新格,预算或宽度规则不允许分栏时返回 `undefined` 且不记账)、`float(tabId, rect?)` 与 `dock(paneId)`——每个记一条历史,目标不存在或已在目标态时为 no-op。没有布局快照、没有订阅、没有按地址查找:该面给的是对布局的控制权,不是布局的视图。座位挂载期间发布其绑定——自己的会话、其 store 的 action 与其面;公开面上的命令作用于已挂载会话,没有已挂载会话面时抛错。tab 自己的动作则到达其会话自己的 store:slot 运行时每个会话铸一个 store,插件在铸出时逐个收养,控制器按会话 id 路由,因此用户切换会话之后触发的动作照样落地,而 store 从未铸出的会话什么也不做。 + +### 地址 + +地址分两族,永不混用。资源地址是资源模型的 `dsh-resource:///…` URI(工作区文件是 `dsh-resource://file/session//<相对该会话工作区根的路径>`,任意文件是 `dsh-resource://file/absolute/<绝对路径>`,都由 `dsh-util-workspace-path` 构造与解析);它们是 `openResource` 的入参、`patterns` 的匹配对象、`useResource` 的读取对象。导航地址命名的是页而非数据;今天唯一的一种是页 tab 记账用的内部 `sidebar://`。只有资源族是契约:导航族在 Sidebar 内部拼装与消费,更完整的导航协议是之后的决定,本决定通过把所有导航字面量留在一处为它预留空间。 + +### 入口 + +会话区的 `openFile(path, { line? })`——工具行路径链接、产出文件 chip、收尾消息提及——把路径编码为该 Session 的文件资源地址并调用 `openResource`,调用方知道行号时带 `params.line`;`read` 工具行传入其 `offset` 参数起始的行。tab 条的「+」为所在格调用 `openTab('guide', { paneId, revealIfOpened: false })`;引导入口框调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`;文件树的一行调用 `tab.actions.openResource(address)`,落在树自己的格里。 + +## Alternatives considered + +**用 chain 坑位派发 tab,或只用 keyed 坑位。** chain 的 `select` 不可枚举,而引导页与导航面必须枚举类型;keyed 坑位只带正文,类型的标题与地址识别无处可住。两段——定义注册表加 keyed 组件坑位——是仓库既有模式(`ConversationViewRegistry`)。 + +**运行时 hook 或每 tab 一个实例对象。** 纸面上试过多种形态——每 tab 一个 Cordis fiber、抽象基类、返回带 `dispose` 实例的 `initial`/`create` 对、一组 `useTab*` hook、框架托管的 `useTabResource(fetch)`、`useTabStream`。依次否决:每 tab 一个 fiber 太重;动态包无法共享基类或导出常量;实例层重复了 Slot store 与 inject 面已经是的东西;每 tab hook 复述 owner props;框架托管的 fetch 没有好的缓存键;tab 域上的流 hook 问错了主人——聊天数据必须来自聊天域,文件数据来自工作区文件服务。剩下的是 owner props 加一个全客户端的 `useResource`。`visible` 后来以 prop 而非 hook 加入也是同一理由:它是关于该次出现的又一个事实,而 props 已经承载了该次出现。 对实例读取钩子的否决由[标签信息决策](2026-09-07-sidebar-responsive-tab-info.zh.md)取代;对独立实例对象、fiber 与数据流所有权的理由仍适用。 + +**每格一个工具区坑位放活跃 tab 的控件(`sidebar.right.pane.tab.tools`)。** 上线一轮评审后删除:它把类型私有按钮放到面板 tab 条上、与分栏和收起控件并列,读起来像面板 chrome。类型的控件属于自己的正文。 + +**类型级落位(`opensInto`)与隐藏的相邻格启发式。** 否决:tab 落在哪是开启方的事,正如 VS Code 的 Explorer 自己决定 `sideBySide`。 + +**扩展名列表与数字优先级。** `claims.extensions` 表达不了 `.d.ts`、`Dockerfile`、目录约束或整个 scheme——它是退化的 glob;数字优先级需要动态包无法引入的导出常量。字面量档位加 glob。VS Code 自己的档位从五个收成三个:`option` 档(只列出、永不自动选中)在「用其他方式打开」存在之前没有消费者,`default` 档改名 `extension`,因为那个名字读起来像最低档而它是最高档。 + +**一个 `open(address)` 包打天下,外加拼页地址的帮助函数。** 第一版页也按地址打开,于是业务包需要 `sidebar://` 字面量或来自 Sidebar 包的 `sidebarAddress(kind)` 帮助函数。两者都被值引用规则禁止,也都泄露了尚未设计的导航 scheme。把面拆成 `openResource` 与 `openTab`,唯一的字面量留在包内,且每种模式各自定型参数。 + +**打开时点名某个实现(`?impl=`)、`find(address)`、`mode()`/`setMode()`、布局快照、`features` 清单。** 都考虑过并留在外面。点名实现属于尚不存在的导航协议;`find` 与快照会把该面变成布局的视图,而它本该是对布局的控制;呈现模式是 UI 开关不是插件关心的事;能力清单在该面尚在收敛时为时过早。 + +**用 Slot 优先级表达 extension 接管 builtin,随后是注册表铸造的坑位键。** 第一次尝试让覆盖方经一个导出常量以更低的 Slot 优先级注册正文——这是动态插件间的值引用,也是拿第二套规则(Slot 优先级)替注册表的规则站台。第二次尝试让注册表为每次注册铸一个键并从 `register()` 返回,这把注册变成了两步且顺序敏感的舞步。让实现自己声明 `id`——必填、唯一、与它注册坑位所用的同一个串——既不需要常量,也不需要铸键与顺序,还给了注册表拒绝重复所需的身份。 + +## Consequences + +- 一个类型 = 一个静态对象 + 一到两个 keyed 坑位注册;其实例信息通过注入的 `useTabInfo()` 读取。框架不长任何按类型的 API 面,仓外类型从 Sidebar 包只引类型。 +- 两种打开配两张参数表,意味着调用方无法只按地址开页或只按 kind 开资源,编译器会说明;代价是每个想要类型化参数的新资源类型或页 kind 都要增补一张表。 +- `id` 与 `kind` 分离让 extension 能按 kind 原位替换随包类型,extension 注销后 builtin 恢复;代价是每个定义多一个必填字段。 +- 导航面只有控制权。需要知道布局的插件无法索取,这让布局的形状在导航协议决定暴露什么之前不进任何插件的契约。 +- `sidebar://` 字面量住在一个文件里。之后改导航语法只碰 Sidebar 包。 +- 这些面是 Sidebar 里被定死的部分:地址、注册字段与档位、两种打开及其参数表、slot 名与注入的标签信息。用户看到的一切行为——浮窗贴到哪、分栏控件何时置灰、文案、树的排序——都是这里任何契约之外的产品规则,改动无需通知任何插件。 + +## Testing + +`ui-sidebar-right` 的 spec 覆盖注册表(档位、extension 压 builtin 及恢复、`id` 与同档冲突、glob 与路径匹配、`canOpen`、排序与平局)、两种打开(正常、边界与失败路径,含错误 scheme 与未注册 kind)、`replaceTab` 记一条历史、座位把 kind 解析到生效实现并回退、`useTabInfo()` 含折叠与浮窗下的 `tab.visible`、以及操作型方法的 no-op 与抛错情形。Web e2e 套件在 Chromium 里经真实插件图驱动引导页、文件树与一次文件打开。两套均无需密钥。 + +## Deferred + +- `sidebar://` 之外的导航协议:页内子路由、点名实现、以及面向生态的其它导航 scheme 规则。 +- 随包页类型的参数,今天未声明任何。 +- 从公开面往屏上会话之外的会话里打开;公开面只作用于已挂载的会话,而 tab 自己的动作已作用于其所在会话。 +- 从会话区打开失败时的本地化提示;目前是抛错文本本身。 diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml new file mode 100644 index 0000000000..e8267647e2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md +2026-09-05-workspace-files-service.md: a95e083f8cea57b957a2c060fe1b7f2b76050153 +2026-09-05-workspace-files-service.zh.md: 59c638157f3e65efe3b89b220401434d1f2370b0 diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md new file mode 100644 index 0000000000..a95e083f8c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md @@ -0,0 +1,151 @@ +# Agent Note: Workspace file service + +Status: implemented + +English | [中文](2026-09-05-workspace-files-service.zh.md) + +## Problem + +The Web client needs to look at files inside a session's workspace from a browser that may not be on the Host machine: a file the agent produced, the path a `read` tool row names, later a file tree and previews of files that are neither small nor text. The one endpoint that read a workspace file over the wire lived on the Session Controller as `workspace-file.ts`, beside session lifecycle it had nothing to do with. It returned a whole file under one total byte cap, so a large log could not be looked at even in part and a binary could not be looked at at all; it had no `stat`, no listing, and no change signal, so a preview could not learn that the agent had rewritten the file without re-reading it; and its result named the file by a Host `url`, a spelling nothing on the Client used as an address. + +Two constraints frame any answer. Reads through `ctx.fs` are deliberately unconfined — the sandboxing backend fences writes and edits only and says so — so a web-facing read endpoint must own every fence itself, and the fences must survive a symlink that leaves the workspace, which a string-prefix test cannot see. And `dsh-fs` exposed one raw-byte read, `readBytes(target, signal, maxBytes)`, which refuses any file longer than its cap: correct for an image the model ingests whole, useless for one window of a large file. + +## Decision + +`packages/api/workspace-files` (`@deepseek-ai/dsh-api-workspace-files`) owns the Host `ctx.workspaceFiles` service, the `workspaceFiles` Remote namespace, and the Client `file` provider that turns `stat` and `changes` into live metadata for the [resource model](2026-09-05-client-resource-model.md); [dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) governs their package organization. Every method confines itself to the workspace root the sandbox policy resolves for the addressed session, names files by their absolute path in the filesystem's execution world, and pages or windows content so that no method ever buffers a whole file. The byte window rides on a new `dsh-fs` seam, `FileSystem.readByteRange`, implemented by every provider. The Session Controller carries no workspace-file code. + +### Package topology + +[dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) supersedes this note's choice of separate Host and Client packages; the file service, authorization, paging, and change-feed decisions here remain in force. Host and Client compile in separate leaf configurations, share wire types, and the Client does not import the Host runtime entry. + +| Face | Package | Files | Depends on | +|---|---|---|---| +| Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts` (`WorkspaceFiles`, `Config`, gates, pager), `src/changes.ts` (`WorkspaceChangeFeed`), `src/types.ts` (wire types, error codes) | `dsh-fs`, `dsh-sandbox-policy`, `dsh-typert-protocol`, `dsh-agent`, `dsh-session` | +| Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts` (plugin body), `provider.ts`, `change-feed.ts`, `remote.ts`, `types.ts`, and shared `src/types.ts` | `dsh-api-gateway/client`, `dsh-api-session-controller/client`, `dsh-client-resources`, `dsh-util-workspace-path`, `dsh-typert-protocol`, and the package's generated `./remote` | + +`api/remotes` and both root aggregates reference the matching Host/Client leaf. The package exports `.`, `./client`, `./types`, `./typert`, and `./remote`, with one `workspace-files` web-app row supplying both faces. The Client plugin injects `['resources', 'remote', 'remote.workspaceFiles', 'sessions']`; the resource model takes result types directly from the protocol package, and the text preview owns the Sidebar parameter declaration, so the Client compilation graph has no reverse dependency on Remote assembly or Sidebar UI. + +### The `workspaceFiles` Remote namespace + +Every Host method takes the target `Agent` first, resolved by the Gateway from the Session identity on the wire, so a Client calls `remote.workspaceFiles.stat(sessionId, path, signal)` and never names a root. The five signatures, as `src/index.ts` declares them: + +```ts ignore-check +@Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise +@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise +@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise +@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise +@Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable +``` + +- **`stat`** returns `WorkspaceFileStat { absolutePath, version, bytes? }`: the file's identity, its opaque freshness token, and its size when the backend reports one. It accepts a regular file only. +- **`read`** returns one window of lines, `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`; `lines` counts the page's lines, so a page holding one empty line (`text: ''`, `lines: 1`) and a page past the end (`lines: 0`) read differently. `range.offset` is the 1-based first line and defaults to 1; `range.limit` is the largest number of lines and defaults to `maxLines`, which it may not exceed. Lines end at `\n` and a final `\n` terminates the last line rather than opening an empty one; `text` joins the page's lines with `\n` and carries no terminator; `eof` is true when the page includes the last line, and an offset past the end returns an empty page with `eof` true. The pager walks `streamText`, counts the lines before the window without keeping them, admits each in-window segment against `maxBytes` before buffering it, and returns at the first character past the window, so a file of any size costs one page of memory. The `version` and `bytes` on a page are the stat's, taken before the stream. +- **`readBytes`** returns one window of raw bytes, `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest byte count and defaults to `maxBytes`, which it may not exceed. `data` is base64, shorter than `length` where the file ends and empty at or past it; `eof` is true when the window includes the last byte. Nothing is decoded and nothing is refused as binary. `read` pages by lines and never by bytes; a byte window is `readBytes`. +- **`list`** returns `WorkspaceDirectoryListing { path, entries, truncated }`: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as `{ name, type, size? }`, and whether `maxEntries` cut the list. `type` is `file`, `directory`, or `other`; a symlink child reports the type of what it points to and a dangling one is `other`, while opening such a child still fails the link gate below. Dotfiles are listed; nothing is filtered. +- **`changes`** yields `WorkspaceFileWatchFrame`: `{ kind: 'ready' }` after the observation queue is registered and the workspace root resolves, followed by `{ kind: 'change', change }`. The `WorkspaceFileChange` payload is `{ absolutePath, version }` for a present file or `{ absolutePath, absent: true }` for one observed gone. Its source is `fs/observed` inside the workspace root, never an OS watcher. Observations after the first pull are queued, including during root resolution; cancellation or plugin disposal ends the generation. + +### Paths on the wire + +Two path vocabularies leave the service, and each method uses exactly one. `read`, `readBytes`, `stat`, and `changes` name a file by `absolutePath`: its absolute path in the filesystem's execution world, symlinks resolved (`ctx.fs.processPath(target)`), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful `stat.absolutePath`, without reading a Session summary's cwd. `list` speaks workspace paths — the same syntax its `path` argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called `absolutePath` and not `url` because it is not a resource address; the address grammar belongs to `dsh-util-workspace-path` and is described with the resource model. Input paths to `read`, `readBytes`, `stat`, and `list` are absolute or relative to the session's workspace root, never to the backend's own cwd. + +`version` is an opaque string a consumer compares for equality and never parses: the local backend derives it from device, inode, size, and nanosecond mtime and ctime, so a rewrite that leaves the content identical still changes it. `offset` means a line on `read` and a byte on `readBytes`; the two units never mix, and `eof` on either means the window reached the file's end. + +### The four gates + +Every `read`, `readBytes`, `stat`, and `list` passes four gates in order, and the constraints are the service's own because the filesystem does not confine reads. The path is inspected before containment is decided, so a caller learns whether an outside path exists and what kind it is before `outside-workspace` refuses it; that is accepted because the caller is the Session's own owner, who can already read the Host through the Agent. + +1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods and `not-directory` for `list`. An empty path is a `gateway/bad-request`. +2. **Containment.** The path resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is `sandboxPolicy.resolve({ session }).workspaceRoot` resolved the same way (the session's cwd, falling back to the policy's configured root). A `..` traversal or an absolute path outside the root is `outside-workspace`. A string-prefix comparison is never used: `resolve` realpaths, so a prefix test cannot see a link that leaves the root. +3. **The caps.** A page or window above `maxBytes`, or a `read` asking for more than `maxLines`, is refused, never shortened, because a silently cut page reads as the whole page; a listing above `maxEntries` is cut and says so. +4. **Text.** For `read` only: content that is not UTF-8 up to the end of the page, a NUL byte in the backend's 8 KiB opening sample, or a NUL byte anywhere in the page is `not-text`; bytes past the page are not inspected. + +After the gates the file methods `stat` the target once more, because the file may have gone or changed kind between the inspection and the read: a vanished file is `not-found` and a replaced one `not-regular-file` with the new kind. The gate order has one visible consequence: an entry outside the root whose type already disqualifies it reports its kind, not its position. + +### Failures + +Each failure is one `RemoteError` code with typed details, declared beside the throwing code and discriminated by code, never by message. + +| Code | When | Details | +|---|---|---| +| `workspace-file/not-found` | no entry at the path, or the file vanished after the gates | `{ path }` | +| `workspace-file/outside-workspace` | the resolved target is not inside the workspace root | `{ path }` | +| `workspace-file/too-large` | a page's text or a requested byte window exceeds `maxBytes` | `{ path, limit }` | +| `workspace-file/not-text` | invalid UTF-8 up to the page's end, or a NUL byte in the sample or the page (`read` only) | `{ path }` | +| `workspace-file/not-regular-file` | `read`, `readBytes`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | +| `workspace-file/not-directory` | `list` on something that is not a directory | `{ path, kind: 'file' \| 'symlink' \| 'other' }` | +| `workspace-file/unsupported-address` | Client-minted: a resource address this provider cannot serve | `{ address }` | +| `workspace-file/unknown-workspace` | Client-minted: an `absolute` address with no current Session | `{ address }` | +| `gateway/bad-request` | an empty path, or an `offset`, `limit`, or `length` that is not an integer in range | `{}` | + +The set is append-only: a code may be added, and none is renamed or removed, because consumers branch on these strings across the wire. + +### Configuration + +Three fields, all validated positive integers changeable from `cordis.yml`, and no other tunables: `maxBytes` (default 2,097,152, 2 MiB) is the inclusive cap on one page's text and on one byte window; `maxLines` (default 5,000) is the default and largest page in lines; `maxEntries` (default 2,000) is the cap on returned directory entries. The file itself has no size cap: a caller pages or windows through it. + +### The `readByteRange` seam in `dsh-fs` + +A byte window of a large file needs a filesystem read bounded by the window, and `FileSystem` had only `readBytes(target, signal, maxBytes)`, which bounds by the whole file. `dsh-fs` therefore gains a second raw-byte primitive: + +```ts ignore-check +abstract readByteRange(target: FsTarget, range: { offset: number; length: number }, signal?: AbortSignal): Promise +``` + +It returns the bytes at `[offset, offset + length)`, shorter when the file ends inside the window and empty when `offset` lies at or past the end. The window is the bound: a backend transfers at most `length` bytes beyond the prefix it skips to reach `offset` and never buffers the whole file, so the caller's cap on `length` is the guard against unbounded buffering, sitting beside `readBytes`'s bound rather than replacing it. The parameter order follows `readText`, `streamText`, and `listDir` — target, then the operation's own arguments, then an optional signal — rather than `readBytes`'s signal-in-the-middle form, which is the one exception in the class. Both `offset` and `length` are non-negative integers by precondition; the seam is a typed same-process boundary and validates nothing, and the Remote method validates at the wire. + +`fs-local` opens `createReadStream(targetKey, { start: offset, end: offset + length - 1 })` after the same regular-file stat as its other reads, returning an empty array for `length` 0 without opening a stream; `fs-sandbox` extends `LocalFileSystem` and inherits it. `fs-e2b` has an SDK that streams only from a file's start, so it skips `offset` bytes, copies `length` into the window, and cancels the stream the moment the window is full, transferring no more than the window beyond the skipped prefix; a stream that ends first is left to close. The four test doubles that extend `FileSystem` implement the method too. + +### The Client `file` provider + +The Client export registers one `ResourceProvider<'file'>` into `ctx.resources` for the plugin's lifetime and declares `ResourceProtocolMap.file`. The text-preview package registers this package's exported `WorkspaceFileParams` as `SidebarRightResourceParamsMap.file`. + +- **The value is metadata**, `WorkspaceFileResource { absolutePath, version, bytes?, changed }`; content never rides the stream because content can be arbitrarily large and a stream is for pushing change, not payload. A consumer reads pages with `read` (or windows with `readBytes`) and uses `version` and `changed` to know when they are stale. +- **The address names the file; its scope selects the Session.** A `session` address's relative path reaches the Host unchanged for resolution and containment against that Session's workspace root; Client cwd is not a prerequisite. An `absolute` address reads through the current Session, failing with `workspace-file/unknown-workspace` when none is current. Unsupported grammar yields `workspace-file/unsupported-address`. These two Client errors end the stream and make reload a no-op. +- **The frames.** The first frame is a `stat` (`changed: false`) or its failure as an `ok: false` frame; the provider throws and catches nothing, because the Remote face never rejects and a throw inside a provider stream is a programming error left to surface. A Host write carrying a version the value does not hold yields `changed: true` with the byte count kept and no stat; a frame carrying the held version is dropped. A reported disappearance stats again — still there is fresh metadata flagged `changed`, gone is a `not-found` frame with the previous value left for display. `reload(address)` stats again and yields `changed: false`. The follow is on the address, not the file: after a failed stat the stream continues, so the agent creating the file, or a reload, brings the resource live. Aborting the signal ends the stream silently. +- **One `changes` subscription per Session.** The first follower opens `remote.$stream`, the last release disposes it, and successor streams and plugin teardown await pending closes. The Client starts its first `stat` only after accepting Host `ready`; sending a local WebSocket request is not Host acknowledgement. A follower registers by address, queues changes before its path is known, then filters queued and live frames by the successful stat's `absolutePath`, normalizing backslashes to slashes. Any Session write can trigger a re-stat before the first successful binding. Gateway supervision reconnects carrier loss; Host end or terminal failure ends followers and retains their last metadata until reopened. +- **Navigation parameters.** `SidebarRightResourceParamsMap.file` is `WorkspaceFileParams { line?: number }`, a 1-based line to reveal. A line travels as a navigation parameter and not as part of the address, because the file is one piece of content whether it opens at the top or at line 400. + +### Related notes + +The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, `useResource`, the `dsh-resource:///…` address grammar, and the reasoning for one resource per address; the [text preview and file tree](../feature/2026-09-05-sidebar-text-preview-and-file-tree.md) are the shipped consumers of `read`, `list`, and the `file` provider; the [right Sidebar docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) is the surface they open into; [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) is where serving files over HTTP was rejected. Anyone extending this system reaches the same five methods through `remote.workspaceFiles` and the same `file` resource through `useResource<'file'>`; the wire types are published as `@deepseek-ai/dsh-api-workspace-files/types`. + +## Alternatives considered + +**Keeping the workspace file endpoint on the Session Controller.** The first form: one `read` under a total byte cap, registered as a sub-plugin of the Session Controller because that is where the wire entry already was. Rejected because a Workspace File service is its own capability — reading, statting, listing, and observing files inside a workspace root — and everything that queries workspace files belongs to it, while the Session Controller's concern is session lifecycle. The move also let the service grow to five methods without the Controller's file gaining a second purpose. + +**A dual-face package with reverse UI dependencies.** The split-package choice followed two project-reference cycles after `api/remotes` referenced the Client leaf: the resource model imported Remote assembly for result types, and the file provider imported Sidebar UI for its parameter map. TypeScript rejected these cycles with `TS6202`. [dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) supersedes that split: result types come directly from the protocol package, and Sidebar parameter registration belongs to the text preview; both root aggregates retain explicit compiler entries. + +**Serving workspace files over HTTP.** Already rejected by [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) on origin grounds and not revisited: `read` and `readBytes` carry plain text and base64 over the authenticated Remote carrier, so no document is served, no URL is minted, and no origin question arises. + +**Log-reachable authorization for the read.** The one precedent that sends file content over the wire, command attachments, authorizes only files that appear in the session log. Enough for produced files, but a typed path or a directory tree could never open. Path containment inside the workspace root was chosen, with the endpoint owning the constraints the filesystem's unconfined reads do not, and containment decided by `fs.contains` on resolved targets so a symlink cannot escape it. + +**Whole-file read and slice for the byte window.** The interim form of `readBytes` read the file from its start to the window's end through `readBytes(target, signal, offset + length)` and sliced. It cannot read a window of a file longer than that end — the seam refuses such a file as too large — so no window could ever report `eof: false`, which contradicts the reason the method exists. Rejected in favour of the `readByteRange` seam, whose bound is the window. + +**Naming the file field `url` (or `hostUrl`).** The Session Controller's `WorkspaceFileText.url` was the Host's `file:` URL of the file. Rejected once resource addresses existed: a URL on the wire reads as an address, and this one was not one — it was a differently encoded spelling of the same path the address carries, which the Client had to decode to match change frames. A wire field is named by what it is, so the field is `absolutePath` and the `changes` frames carry the same field. + +**A default `readByteRange` in the `FileSystem` base class.** A non-abstract default over `readBytes` would have spared the test doubles a method but could only be implemented by reading the whole file up to the window's end, the very behaviour rejected above, or by passing an unbounded cap. Abstract, with every provider and double implementing it. + +**String-prefix containment.** Comparing resolved path strings against the root is simpler than `fs.contains`, but `resolve` realpaths, so a symlink that leaves the root resolves to a path outside it while a prefix test on the unresolved spelling passes; and a prefix test on the resolved spelling still needs the backend's notion of "same file". The filesystem decides containment. + +## Consequences + +- Workspace file access belongs to the Host/Client faces of `api/workspace-files`; the Session Controller carries neither implementation, and compiler and runtime entries stay separate. +- A file of any size opens: text by line page, anything by byte window, each costing one page or window of memory on the Host and never a whole file; the cost is that a consumer assembles pages itself and that a single line above `maxBytes` has no page at all, because pages are cut by lines. +- Every filesystem provider now offers a windowed raw read. `fs-e2b` pays for it by transferring the skipped prefix, since its SDK cannot seek; `fs-local` seeks. +- Paths on the wire are canonical: `absolutePath` and change frames spell a file with symlinks resolved. An address built from another spelling of the same file — a workspace root reached through a symlink — opens and stats it, but its change frames never match, so `changed` stays false until a reload. +- Change frames report the agent's own operations only. A file edited by the user's editor, a shell, or a subprocess raises no frame; an agent merely reading a file that something else changed does raise one, because the read observes a new version. +- The gate order reports kind before position, a page's `version` may be one write behind its content, and a stalled `changes` consumer grows Host memory, because a generation's queue is unbounded; each is a known trade-off recorded in the package README. +- The `file` resource pushes change, not content, so a preview learns a file moved on without a payload and reads the pages it wants; a failed open keeps following the address, so the agent creating the file brings the tab live without user action. +- `readBytes` has no shipped consumer yet: it is the wire form the image and binary previews build on. + +## Testing + +Host specs in `packages/api/workspace-files/tests` exercise the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept), the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size), `stat`, `list` with truncation, symlink children, and `not-directory`, the `changes` stream driven by `fs/observed` and filtered by root, and every gate and code against a real local backend, because a fake filesystem would let a prefix test pass the symlink case the gate exists to catch. Client specs in `packages/api/workspace-files/tests` cover the provider's frames (opening stat, failure frames, writes without content, disappearance, reload, recovery, abort), the change feed (one stream per session, fan-out by normalized path, queued frames, ending on signal or Host close), the unsupported-address cases, and registration and disposal with the fiber. `fs/fs`, `fs-local`, and `fs-e2b` specs pin `readByteRange`'s range semantics — a middle window, a tail shorter than asked, past-end and zero-length windows, errors, aborts, and the e2b cancel — and `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite. + +## Deferred + +- A web e2e chain through the Sidebar: open a file, have the agent write it, see `changed`, reload. +- Aliasing a follower under the Host's canonical spelling once the first `stat` reveals it, so a symlinked workspace root still receives change frames. +- A bound on a `changes` generation's queue. +- The shipped consumer of `readBytes` (image and binary previews) and any write, search, or media route; the service is read-only. +- Scopes other than `session` in the file address; the grammar leaves room, the provider serves one. +- Reload delivery per record: today `reload` re-stats every follower of the file's absolute path in the session, so two records naming one file — a `session` and an `absolute` address, or two readers with different addresses — clear each other's `changed` flag. diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md new file mode 100644 index 0000000000..59c638157f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md @@ -0,0 +1,151 @@ +# Agent Note: 工作区文件服务 + +Status: implemented + +[English](2026-09-05-workspace-files-service.md) | 中文 + +## Problem + +Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工作区里的文件:agent 产出的文件、`read` 工具行点名的路径,之后还有文件树,以及既不小也不是文本的文件预览。唯一一个经线路读取工作区文件的端点以 `workspace-file.ts` 住在 Session Controller 上,与它毫无关系的会话生命周期为邻。它在一个总字节上限之下返回整个文件,因此大日志连一部分都看不了、二进制根本看不了;它没有 `stat`、没有列举、没有变更信号,预览不重读就无法得知 agent 已改写文件;其结果还以 Host 的 `url` 命名文件,而 Client 上没有任何东西把这种拼法当地址用。 + +两个约束框定了任何答案。经 `ctx.fs` 的读取是有意不受限的——沙箱后端只围栏写与编辑,并明说了这一点——所以面向 web 的读端点必须自己拥有每一道围栏,而且围栏必须经得住一条离开工作区的符号链接,这是字符串前缀测试看不见的。另外 `dsh-fs` 只暴露一种原始字节读取 `readBytes(target, signal, maxBytes)`,它拒绝任何比上限更长的文件:对模型整体摄入的图片是正确的,对大文件的一个窗口则毫无用处。 + +## Decision + +`packages/api/workspace-files`(`@deepseek-ai/dsh-api-workspace-files`)同时拥有 Host 服务 `ctx.workspaceFiles`、`workspaceFiles` Remote 命名空间,以及将 `stat` 与 `changes` 转成[资源模型](2026-09-05-client-resource-model.zh.md)实时元数据的 Client `file` 提供者;包组织方式由[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)规定。每个方法都把自己限制在沙箱策略为被寻址会话解析出的工作区根内,以文件在文件系统执行环境中的绝对路径命名文件,并对内容分页或开窗,因此没有任何方法会缓冲整个文件。字节窗口依托 `dsh-fs` 新增的 seam `FileSystem.readByteRange`,由每个提供者实现。Session Controller 不再携带任何工作区文件代码。 + +### 包拓扑 + +[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)取代本记录中把 Host 与 Client 分成两个包的组织选择;这里的文件服务、授权、分页和变更流约定保持不变。Host 与 Client 分别编译在两个叶配置中,共享线路类型,Client 不导入 Host 运行时入口。 + +| 面 | 包 | 文件 | 依赖 | +|---|---|---|---| +| Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts`(`WorkspaceFiles`、`Config`、围栏、切页器)、`src/changes.ts`(`WorkspaceChangeFeed`)、`src/types.ts`(线路类型、错误码) | `dsh-fs`、`dsh-sandbox-policy`、`dsh-typert-protocol`、`dsh-agent`、`dsh-session` | +| Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts`(插件体)、`provider.ts`、`change-feed.ts`、`remote.ts`、`types.ts`,以及共享的 `src/types.ts` | `dsh-api-gateway/client`、`dsh-api-session-controller/client`、`dsh-client-resources`、`dsh-util-workspace-path`、`dsh-typert-protocol`,以及本包生成的 `./remote` | + +`api/remotes` 和两个根聚合分别引用匹配的 Host/Client 叶子。包导出 `.`、`./client`、`./types`、`./typert` 和 `./remote`,web-app 中单个 `workspace-files` 条目供应两面。Client 插件注入 `['resources', 'remote', 'remote.workspaceFiles', 'sessions']`;资源模型直接从协议包取结果类型,Sidebar 参数声明归文本预览,因此 Client 编译图不再反向依赖 Remote 装配或右栏 UI。 + +### `workspaceFiles` Remote 命名空间 + +每个 Host 方法首参都是目标 `Agent`,由 Gateway 从线路上的 Session 身份解析而来,因此 Client 调用 `remote.workspaceFiles.stat(sessionId, path, signal)`,从不自行命名根。五个签名照 `src/index.ts` 的声明: + +```ts ignore-check +@Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise +@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise +@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise +@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise +@Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable +``` + +- **`stat`** 返回 `WorkspaceFileStat { absolutePath, version, bytes? }`:文件身份、不透明的新鲜度令牌,以及后端报得出时的大小。它只接受普通文件。 +- **`read`** 返回一个行窗口 `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`;`lines` 计页内行数,使只含一个空行的页(`text: ''`、`lines: 1`)与越过文件末尾的页(`lines: 0`)可区分。`range.offset` 是 1 起算的首行,缺省 1;`range.limit` 是最多行数,缺省 `maxLines` 且不得超过。行以 `\n` 结束,末尾的 `\n` 终止最后一行而不是开启一空行;`text` 以 `\n` 连接本页各行且不带终止符;页含最后一行时 `eof` 为 true,越过末尾的 offset 返回 `eof` 为 true 的空页。切页器沿 `streamText` 前进,数过窗口前的行而不保留,把每个窗内片段先按 `maxBytes` 核准再缓冲,并在越过窗口的第一个字符处返回,因此任意大小的文件只花一页内存。页上的 `version` 与 `bytes` 来自流之前的那次 stat。 +- **`readBytes`** 返回一个原始字节窗口 `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`。`range.offset` 是 0 起算的首字节,缺省 0;`range.length` 是最多字节数,缺省 `maxBytes` 且不得超过。`data` 为 base64,文件在窗内结束则短于 `length`,位于或越过末尾则为空;窗口含最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝。`read` 按行分页、绝不按字节;字节窗口走 `readBytes`。 +- **`list`** 返回 `WorkspaceDirectoryListing { path, entries, truncated }`:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 `{ name, type, size? }` 给出,以及 `maxEntries` 是否截断了列表。`type` 为 `file`、`directory` 或 `other`;符号链接子项报告其指向目标的类型,悬空者为 `other`,而打开这样的子项仍会在下文的链接关被拒。dotfile 照常列出,不做任何过滤。 +- **`changes`** 产出 `WorkspaceFileWatchFrame`:在观察队列注册且工作区根解析完成后先发 `{ kind: 'ready' }`,随后为 `{ kind: 'change', change }`。载荷 `WorkspaceFileChange` 对存在的文件为 `{ absolutePath, version }`,对消失的文件为 `{ absolutePath, absent: true }`。来源是工作区根内的 `fs/observed`,不监视操作系统。首次拉取后的观察都会排队,包括根解析期间的观察;取消或插件释放会结束该代流。 + +### 线路上的路径 + +离开服务的路径词汇有两套,每个方法只用其中一套。`read`、`readBytes`、`stat` 与 `changes` 以 `absolutePath` 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(`ctx.fs.processPath(target)`),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 `stat.absolutePath` 绑定跟随者,不读取会话摘要的 cwd。`list` 说工作区路径——与其 `path` 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 `absolutePath` 而不叫 `url`,因为它不是资源地址;地址语法归 `dsh-util-workspace-path` 所有,与资源模型一并描述。`read`、`readBytes`、`stat` 与 `list` 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd。 + +`version` 是消费者只比较是否相等、从不解析的不透明字符串:本地后端由设备、inode、大小及纳秒级 mtime 与 ctime 导出,因此内容不变的重写也会改变它。`offset` 在 `read` 上指行、在 `readBytes` 上指字节;两套单位从不混用,二者的 `eof` 都表示窗口到达了文件末尾。 + +### 四道关 + +每次 `read`、`readBytes`、`stat` 与 `list` 依次过四道关,而这些约束是服务自己的,因为文件系统并不限制读取。路径先被检视再判定是否在工作区内,因此调用方在 `outside-workspace` 拒绝之前就能得知工作区外的路径是否存在、是何种类;这一点被接受,因为调用方就是 Session 的所有者,本来就能经 Agent 读 Host。 + +1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`),对 `list` 为 `not-directory`。空路径是 `gateway/bad-request`。 +2. **包含关系。** 路径解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是以同样方式解析的 `sandboxPolicy.resolve({ session }).workspaceRoot`(会话 cwd,退而取策略配置的根)。`..` 爬出或根外绝对路径为 `outside-workspace`。从不使用字符串前缀比较:`resolve` 会取 realpath,前缀测试看不见离开根的链接。 +3. **上限。** 超过 `maxBytes` 的页或窗口,或 `read` 索要超过 `maxLines` 的行数,一律拒绝、绝不截短,因为悄悄截短的页读起来就像整页;超过 `maxEntries` 的列表被截断并如实报告。 +4. **文本。** 仅限 `read`:到页末为止不是 UTF-8 的内容、后端 8 KiB 开头样本里的 NUL 字节,或页内任何位置的 NUL 字节,都是 `not-text`;页之后的字节不检查。 + +过关之后文件方法再对目标 `stat` 一次,因为在检查与读取之间文件可能已消失或换了种类:消失者为 `not-found`,被替换者为带新种类的 `not-regular-file`。关的顺序有一个可见后果:根外条目若类型本身已不合格,报告的是其种类而不是其位置。 + +### 失败 + +每种失败都是一个带类型化 details 的 `RemoteError` 代码,声明在抛出它的代码旁,按代码而非消息区分。 + +| 代码 | 何时 | Details | +|---|---|---| +| `workspace-file/not-found` | 路径处无条目,或文件在过关后消失 | `{ path }` | +| `workspace-file/outside-workspace` | 解析出的目标不在工作区根内 | `{ path }` | +| `workspace-file/too-large` | 一页文本或所请求的字节窗口超过 `maxBytes` | `{ path, limit }` | +| `workspace-file/not-text` | 到页末为止的非法 UTF-8,或样本或页内的 NUL 字节(仅 `read`) | `{ path }` | +| `workspace-file/not-regular-file` | 对非普通文件执行 `read`、`readBytes` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | +| `workspace-file/not-directory` | 对非目录执行 `list` | `{ path, kind: 'file' \| 'symlink' \| 'other' }` | +| `workspace-file/unsupported-address` | Client 铸出:本提供者无法服务的资源地址 | `{ address }` | +| `workspace-file/unknown-workspace` | Client 铸出:没有当前会话时的 `absolute` 地址 | `{ address }` | +| `gateway/bad-request` | 空路径,或不是范围内整数的 `offset`、`limit`、`length` | `{}` | + +这个集合只增不改不删:可以新增代码,但不重命名、不移除任何一个,因为消费方跨线路按这些字符串分支。 + +### 配置 + +三个字段,都是可在 `cordis.yml` 中修改、经校验的正整数,此外没有其他可调项:`maxBytes`(默认 2,097,152,即 2 MiB)是单页文本与单个字节窗口的含上限;`maxLines`(默认 5,000)是页的缺省与最大行数;`maxEntries`(默认 2,000)是返回目录条目数的上限。文件本身没有大小上限:调用方分页或开窗读完它。 + +### `dsh-fs` 中的 `readByteRange` seam + +大文件的字节窗口需要一种以窗口为界的文件系统读取,而 `FileSystem` 只有以整文件为界的 `readBytes(target, signal, maxBytes)`。因此 `dsh-fs` 新增第二个原始字节原语: + +```ts ignore-check +abstract readByteRange(target: FsTarget, range: { offset: number; length: number }, signal?: AbortSignal): Promise +``` + +它返回 `[offset, offset + length)` 处的字节,文件在窗内结束则变短,`offset` 位于或越过末尾则为空。窗口即界:后端最多传输为到达 `offset` 而跳过的前缀之外的 `length` 字节,从不缓冲整个文件,因此调用方对 `length` 的上限就是防无界缓冲的守卫,与 `readBytes` 的界并列而非取代它。参数顺序遵循 `readText`、`streamText` 与 `listDir`——先目标,再操作自己的参数,最后可选 signal——而不是 `readBytes` 把 signal 放中间的形式,那是该类中唯一的例外。`offset` 与 `length` 按前置条件都是非负整数;seam 是类型化的同进程边界,不做任何校验,由 Remote 方法在线路处校验。 + +`fs-local` 在与其他读取相同的普通文件 stat 之后打开 `createReadStream(targetKey, { start: offset, end: offset + length - 1 })`,对 `length` 为 0 直接返回空数组而不开流;`fs-sandbox` 继承 `LocalFileSystem`,随之继承该方法。`fs-e2b` 的 SDK 只能从文件开头开始流式读取,于是它跳过 `offset` 字节、把 `length` 字节拷入窗口,并在窗口填满的那一刻取消流,除跳过的前缀外传输量不超过窗口;先行结束的流则任其关闭。继承 `FileSystem` 的四个测试替身也实现了该方法。 + +### Client `file` 提供者 + +Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存活期与插件相同,并声明 `ResourceProtocolMap.file`。文本预览包把本包导出的 `WorkspaceFileParams` 注册为 `SidebarRightResourceParamsMap.file`。 + +- **值是元数据**,`WorkspaceFileResource { absolutePath, version, bytes?, changed }`;内容从不进入流,因为内容可以任意大,而流是用来推送变更而不是载荷的。消费者用 `read` 读页(或用 `readBytes` 开窗),并以 `version` 与 `changed` 得知它们何时过时。 +- **地址命名文件,作用域决定读取会话。** `session` 地址携带的相对路径原样交给 Host,由 Host 按该会话的工作区根解析并检查包含关系,不要求 Client 持有 cwd。`absolute` 地址经当前会话读取,缺少当前会话时产生 `workspace-file/unknown-workspace`。不支持的语法产生 `workspace-file/unsupported-address`。这两种 Client 错误会结束流,刷新无动作。 +- **帧。** 第一帧是 `stat`(`changed: false`)或其失败的 `ok: false` 帧;提供者不抛也不接,因为 Remote 面从不 reject,而提供者流里的抛错只可能是编程错误,任其浮出。携带值尚未持有的版本的 Host 写入产生 `changed: true`、保留字节数、不做 stat;携带已持有版本的帧被丢弃。报告的消失会再 stat 一次——仍在则是标为 `changed` 的新元数据,不在则是保留上一个值供展示的 `not-found` 帧。`reload(address)` 再 stat 一次并产生 `changed: false`。跟随的是地址而不是文件:stat 失败后流继续,因此 agent 创建该文件或一次刷新会让资源恢复正常。中止 signal 则流静默结束。 +- **每会话一条 `changes` 订阅。** 首位跟随者打开 `remote.$stream`,最后一位离开时释放,后继流和插件拆除等待关闭完成。Client 接受 Host 的 `ready` 后才开始首次 `stat`;本地发出 WebSocket 请求不是 Host 确认。跟随者先按地址注册,缓冲路径未知期间的变更,成功 stat 后按返回的 `absolutePath` 过滤排队与实时帧,反斜杠归一为斜杠。尚未成功绑定时,Session 内任何写入均可触发重新 stat。载体掉线由 Gateway 监督器重连;Host 结束或终态失败会结束跟随者,并保留最近元数据,直到重新打开。 +- **导航参数。** `SidebarRightResourceParamsMap.file` 是 `WorkspaceFileParams { line?: number }`,即要显露的 1 起算行号。行号作为导航参数而不是地址的一部分传递,因为不论从顶部还是第 400 行打开,文件都是同一份内容。 + +### 相关记录 + +[资源模型](2026-09-05-client-resource-model.zh.md)拥有 `ctx.resources`、`useResource`、`dsh-resource:///…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md)是 `read`、`list` 与 `file` 提供者随包交付的消费方;[右侧 Sidebar 停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)是它们打开进去的界面;[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 `remote.workspaceFiles` 触达同样的五个方法、经 `useResource<'file'>` 触达同样的 `file` 资源;线路类型以 `@deepseek-ai/dsh-api-workspace-files/types` 发布。 + +## Alternatives considered + +**把工作区文件端点留在 Session Controller 上。** 最初形态:总字节上限之下的一个 `read`,作为 Session Controller 的子插件注册,因为线路入口本来就在那里。被否,因为 Workspace File 服务是自己的能力——在工作区根内读取、stat、列举与观察文件——凡查询工作区文件的都归它,而 Session Controller 关心的是会话生命周期。搬出也让服务长到五个方法而不给 Controller 的文件添第二重目的。 + +**带有反向 UI 依赖的双面包。** 拆包选择源于 `api/remotes` 引用 Client 叶子后形成的两条工程引用环:资源模型为了结果类型引用 Remote 装配,文件提供者为了 Sidebar 参数表引用右栏 UI。TypeScript 以 `TS6202` 拒绝这些环。[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)取代拆包选择:结果类型直接取自协议包,Sidebar 参数注册移至文本预览;保留两个根聚合中的显式编译入口。 + +**经 HTTP 供工作区文件。** 已被[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)以 origin 理由否决且未重议:`read` 与 `readBytes` 经认证的 Remote 载体传送纯文本与 base64,因此不供文档、不铸 URL,也不产生 origin 问题。 + +**读取的"日志可达"授权。** 唯一把文件内容送过线路的先例——命令附件——只授权出现在会话日志里的文件。对产出文件够用,但手输的路径或目录树永远打不开。选择了工作区根内的路径包含,端点自行承担文件系统不受限读取所不具备的约束,并由 `fs.contains` 对已解析目标判定包含关系,使符号链接无法逃逸。 + +**为字节窗口整文件读取再切片。** `readBytes` 的临时形态经 `readBytes(target, signal, offset + length)` 从文件开头读到窗口末端再切片。它读不了比该末端更长的文件的窗口——seam 会以过大拒绝这样的文件——因此没有任何窗口能报告 `eof: false`,与该方法存在的理由相悖。被否,改为以窗口为界的 `readByteRange` seam。 + +**把文件字段命名为 `url`(或 `hostUrl`)。** Session Controller 的 `WorkspaceFileText.url` 是 Host 侧文件的 `file:` URL。资源地址出现后即被否:线路上的 URL 读起来像地址,而这个不是——它只是地址所携同一路径的另一种编码拼法,Client 必须解码才能匹配变更帧。线路字段按其所是命名,因此字段为 `absolutePath`,`changes` 帧携带同一字段。 + +**在 `FileSystem` 基类里给 `readByteRange` 一个默认实现。** 基于 `readBytes` 的非抽象默认能免去测试替身一个方法,但只能靠把文件从头读到窗口末端来实现——正是上文否决的行为——或者传一个无界上限。改为抽象方法,由每个提供者与替身实现。 + +**字符串前缀包含判定。** 把解析后的路径字符串与根比较比 `fs.contains` 简单,但 `resolve` 会取 realpath,离开根的符号链接解析到根外路径,而对未解析拼法的前缀测试会放行;对已解析拼法的前缀测试也仍需后端对"同一文件"的定义。由文件系统判定包含关系。 + +## Consequences + +- 工作区文件访问由 `api/workspace-files` 的 Host/Client 两面共同承担;Session Controller 不携带其中任何实现,两面的编译与运行时入口保持独立。 +- 任意大小的文件都能打开:文本按行页、任何文件按字节窗口,在 Host 上各自只花一页或一窗内存、从不整文件;代价是消费者自己拼装页面,且单行超过 `maxBytes` 的行没有任何页,因为页按行切。 +- 每个文件系统提供者现在都提供开窗的原始读取。`fs-e2b` 为此付出传输被跳过前缀的代价,因为其 SDK 不能 seek;`fs-local` 能 seek。 +- 线路上的路径是规范的:`absolutePath` 与变更帧以符号链接已解析的拼法命名文件。由同一文件另一种拼法铸出的地址——经符号链接到达的工作区根——能打开并 stat 它,但其变更帧永不匹配,因此 `changed` 在刷新前保持 false。 +- 变更帧只报告 agent 自己的操作。用户编辑器、shell 或子进程改动的文件不产生帧;agent 仅仅读取一个被别处改动的文件却会产生帧,因为读取观察到了新版本。 +- 关的顺序先报种类后报位置,页的 `version` 可能落后内容一次写入,停滞的 `changes` 消费者会让 Host 内存增长,因为一代流的队列无界;每一条都是包 README 记录在册的已知取舍。 +- `file` 资源推送变更而非内容,因此预览不靠载荷就得知文件已更新并读取它想要的页;失败的打开继续跟随地址,因此 agent 创建该文件时 tab 无需用户动作即恢复正常。 +- `readBytes` 尚无随包交付的消费方:它是图片与二进制预览赖以构建的线路形态。 + +## Testing + +`packages/api/workspace-files/tests` 中的 Host spec 覆盖分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与被拒的 limit、保留回车)、字节窗口(缺省值、后面还有内容的中段窗口、恰好与变短的尾窗、越界与空文件、NUL 与非法 UTF-8 经 base64 往返、与 `stat` 一致的版本、作为 `too-large` 的上限、坏范围、远超上限的文件的一个窗口、无大小时推断的 `eof`)、`stat`、带截断、符号链接子项与 `not-directory` 的 `list`、由 `fs/observed` 驱动并按根过滤的 `changes` 流,以及针对真实本地后端的每道关与每个代码——因为假文件系统会让前缀测试放过这道关本为捕获的符号链接场景。`packages/api/workspace-files/tests` 中的 Client spec 覆盖提供者的帧(开头 stat、失败帧、不带内容的写入、消失、刷新、恢复、中止)、变更流(每会话一条流、按归一路径扇出、排队的帧、因 signal 或 Host 关闭而结束)、不支持地址的各种情形,以及随 fiber 的注册与释放。`fs/fs`、`fs-local` 与 `fs-e2b` 的 spec 钉住 `readByteRange` 的范围语义——中段窗口、短于所求的尾窗、越界与零长窗口、错误、中止以及 e2b 的取消——`dsh-util-workspace-path` 的 spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。 + +## Deferred + +- 一条经 Sidebar 的 web e2e 链:打开文件、让 agent 写它、看到 `changed`、刷新。 +- 在首次 `stat` 揭示 Host 的规范拼法后为跟随者加别名,使经符号链接的工作区根也能收到变更帧。 +- 给 `changes` 一代流的队列加上限。 +- `readBytes` 随包交付的消费方(图片与二进制预览)以及任何写入、搜索或媒体路由;本服务只读。 +- 文件地址中 `session` 之外的作用域;语法留有余地,提供者只服务一个。 +- 按记录投递重载:今天 `reload` 重新 stat 该会话中此文件绝对路径的所有跟随者,因此命名同一文件的两条记录——`session` 与 `absolute` 地址,或地址不同的两个读者——会互相清掉 `changed` 标记。 diff --git a/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.i18n.yaml new file mode 100644 index 0000000000..0a04d3712e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md +2026-09-07-sidebar-responsive-tab-info.md: ade20148cccd2233224417f762e65492828943bb +2026-09-07-sidebar-responsive-tab-info.zh.md: 0903e4ff06ca187fcc1fd0e9d8de63f70d5cc427 diff --git a/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md new file mode 100644 index 0000000000..ade20148cc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md @@ -0,0 +1,31 @@ +# Agent Note: Responsive Sidebar and injected tab information + +Status: implemented + +English | [中文](2026-09-07-sidebar-responsive-tab-info.zh.md) + +## Problem + +Tab extensions need consistent live information about their containing pane and Sidebar without a growing list of owner props. The workbench must also preserve content while adapting to limited viewport space, without reopening a Sidebar the user has closed. + +## Decision + +The slot framework injects one `useTabInfo()` returning nested `sidebar`, `panel`, and `tab` fields. It composes the framework-bound layout and navigation hooks; extensions neither subscribe themselves nor receive a service object. Body visibility requires an active tab in an expanded Sidebar; title visibility does not require an active tab. Hiding or switching Sessions leaves the tab lifetime intact. Closing the record aborts its signal. Tab actions stay bound to their owning Session. Store adoption is a private capability of the plugin assembly, not a public controller operation. + +The frame protects 400px for the conversation by shrinking the right column, then closing it before shrinking the conversation. Its first-open preference is 45% of the viewport, retained thereafter in pixels, with a 300px floor and 70% viewport ceiling. The left column keeps its preference at widths of at least 1024px. Closing is recorded state: widening never opens it, while a user action or explicit Session API may. Refresh restores defaults rather than persisting layout. + +Fullscreen uses the same mounted content tree and covers the viewport while retaining the underlying column reservation. Opening below 768px selects automatic fullscreen; exiting it there closes the Sidebar. Widening can end automatic fullscreen but leaves manually selected fullscreen intact. The product permits two horizontal panes, a 50/50 initial split, and a 20–80% divider; narrow panes refuse new splits. The generic docking engine retains its independent capabilities. A fullscreen entry completes its slide before reporting the underlying track; that covered width change is instantaneous, so neither entry nor returning to normal reveals a background reflow. At the two-pane budget the split control is hidden; a one-pane width refusal remains disabled. On exit, the frame prepares the destination before the overlay retreats: no right track for close, a normal track for restore. Transition suppression survives clearing the fullscreen report and ends on the next geometry action. + +This decision supersedes the flat owner-props choice in [tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md) and the no-concession, overlay presentation and product pane limit in [docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md). Their registration, record-lifetime, state ownership and engine-selection rationale remain active. + +## Alternatives considered + +**Flat information props or three separate hooks.** A single nested read groups the three ownership levels and allows additional fields without proliferating props or readers. + +**Automatic reopening after a viewport change.** It makes opening depend on layout history rather than an explicit action. A closed Sidebar stays closed, with its content preserved. + +**A separate fullscreen content tree.** Remounting would interrupt tab-local state. The same element changes presentation instead. + +## Consequences + +Tab extensions use a framework-injected reader and keep their own store actions separate from `tab.actions`. Layout, seat and docking tests cover width concessions, explicit reopening, body/title visibility, tab lifetimes, horizontal drop zones and divider limits; browser tests exercise the assembled application. Compact mobile controls and layout persistence remain outside this decision. diff --git a/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md new file mode 100644 index 0000000000..0903e4ff06 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 响应式 Sidebar 与注入的标签信息 + +Status: implemented + +[English](2026-09-07-sidebar-responsive-tab-info.md) | 中文 + +## 问题 + +标签扩展需要一致的所属窗格与 Sidebar 实时信息,而不依赖不断增长的 owner props。工作区也需要适应有限的视口空间,同时保留内容,并且不重新打开用户已经关闭的 Sidebar。 + +## 决策 + +Slot 框架注入一个 `useTabInfo()`,返回嵌套的 `sidebar`、`panel` 与 `tab` 字段。它组合框架绑定的布局与导航 hook;扩展既不自行订阅,也不接收服务对象。正文可见要求 Sidebar 展开且标签活跃;标题可见不要求标签活跃。隐藏或切换 Session 保留标签生命周期。关闭记录会中止其 signal。标签动作始终绑定到所属 Session。Store 收编是插件组装的私有能力,不是公共控制器操作。 + +框架先缩小右列,再关闭右列,最后才缩小会话区,以保护会话区的 400px 宽度。右列首次打开偏好为视口的 45%,此后按像素保留,下限为 300px,上限为视口的 70%。在宽度至少为 1024px 时,左列保持自身偏好。关闭是被记录的状态:变宽不会打开右栏,用户动作或显式 Session API 可以打开。刷新恢复默认值,不持久化布局。 + +全屏使用同一棵已挂载内容树,覆盖视口并保留底层列的占位。在 768px 以下打开会选择自动全屏;在此宽度下退出全屏会关闭 Sidebar。变宽可以结束自动全屏,但保留手动选择的全屏。产品允许两个水平窗格,初始按 50/50 分割,分割线范围为 20–80%;窄窗格拒绝新分栏。通用停靠引擎保留其独立能力。 全屏入场先完成滑入,再报告底层轨道;被覆盖的宽度变化瞬间完成,因此入场及返回普通模式都不暴露底层重排。达到两格预算时隐藏分栏控件;单格宽度不足时仍显示禁用控件。 退场时,框架先准备目标布局,再让覆盖层退出:关闭不留右轨道,恢复保留普通轨道。清除全屏报告时仍保留过渡抑制,直到下一次几何操作才结束。 + +本决策取代[标签类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md)的平铺 owner props 选择,以及[停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)中的无让步、覆盖模式和产品窗格上限。它们的注册、记录生命周期、状态所有权与引擎选型理由继续有效。 + +## 考虑过的替代方案 + +**平铺信息 props 或三个独立 hook。** 一个嵌套读取接口按三个所有权层级分组,允许增加字段而不增加 props 或读取接口。 + +**视口变化后自动重开。** 这会让打开依赖布局历史,而不是显式动作。关闭的 Sidebar 保持关闭,同时保留内容。 + +**独立的全屏内容树。** 重新挂载会打断标签局部状态。因此由同一元素改变呈现方式。 + +## 后果 + +标签扩展使用框架注入的读取接口,自身 store actions 与 `tab.actions` 保持分离。布局、seat 与停靠测试覆盖列宽让步、显式重开、正文与标题可见性、标签生命周期、水平放置区与分割比例;浏览器测试覆盖组装后的应用。紧凑移动端控件与布局持久化不属于本决策。 diff --git a/.agents/notes/implemented/architecture/2026-09-07-workspace-files-dual-face-package.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-07-workspace-files-dual-face-package.i18n.yaml new file mode 100644 index 0000000000..2b1ef7d81a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-07-workspace-files-dual-face-package.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-07-workspace-files-dual-face-package.md +2026-09-07-workspace-files-dual-face-package.md: adc900865cb8d5f19518828a9cb230a835d025f5 +2026-09-07-workspace-files-dual-face-package.zh.md: ccfa60417284129a280efd7ddad2ea275c242f1e diff --git a/.agents/notes/implemented/architecture/2026-09-07-workspace-files-dual-face-package.md b/.agents/notes/implemented/architecture/2026-09-07-workspace-files-dual-face-package.md new file mode 100644 index 0000000000..adc900865c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-07-workspace-files-dual-face-package.md @@ -0,0 +1,36 @@ +# Agent Note: Workspace files as one dual-face API package + +Status: implemented + +English | [中文](2026-09-07-workspace-files-dual-face-package.zh.md) + +## Problem + +The workspace file service and its browser resource provider evolve together, but their compiler graph contained reverse dependencies on Remote assembly and Sidebar UI. Splitting the packages avoided the cycles while separating ownership of the wire protocol from its Client model. A Host-only package with a types-only Client compiler entry also lacks the `dsh.client` and `./client` declarations that distinguish runtime exports in Client catalog analysis. + +## Decision + +`packages/api/workspace-files` owns both implementations. Its Host and Client leaf configurations remain direct references of their respective root aggregates; the solution root references both leaves. The Host exports the file service, `./client` exports the actual resource-provider plugin, and `dsh.client` declares the browser plugin. One web-app row loads both faces. This supersedes only the package-splitting decision in the [workspace file service note](2026-09-05-workspace-files-service.md), whose authorization, paging, and stream semantics remain unchanged. + +Two dependency directions keep the compiler graph acyclic: + +- `client/resources` imports `RemoteResult` and `RemoteFailure` from their defining `typert/protocol` package, not from `api/remotes`, which assembles providers that consume the resource model. +- The text preview declares `SidebarRightResourceParamsMap.file` using the file package's exported parameter type. The file provider declares its resource value but imports no Sidebar UI. The caller imports the viewer's type entry when it needs that navigation declaration. + +These remove `remotes → workspace-files → resources → remotes` and `remotes → workspace-files → sidebar-right → ui-conversation → remotes`. Runtime Cordis service injection remains independent from TypeScript project references. + +## Alternatives considered + +**Keep separate packages.** This isolates the compiler cycle but splits one file capability's Host and Client ownership. Removing the reverse type dependencies permits the same dual-face organization as other API controllers. + +**Remove the root Client reference.** Transitive references still compile the leaf, but both root aggregates must explicitly name this package's matching face. + +**Change catalog analysis or add an empty Client plugin.** Neither supplies the requested browser implementation. A real `./client` export with `dsh.client` uses the analyzer's existing supported dual-face path. + +## Consequences + +Host wire methods and browser resource behavior are unchanged. The browser implementation, tests, and documentation have one package owner; Client type dependencies stop at the protocol and resource-model layers instead of reaching UI or Remote assembly. + +## Verification + +The Cordis inspect catalog check analyzes the declared Client export, both compiler aggregates retain their leaf references, and the Host and Client file-service tests exercise the same implementations. The existing dependency and project-reference checks enforce their compilation relationships. diff --git a/.agents/notes/implemented/architecture/2026-09-07-workspace-files-dual-face-package.zh.md b/.agents/notes/implemented/architecture/2026-09-07-workspace-files-dual-face-package.zh.md new file mode 100644 index 0000000000..ccfa604172 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-07-workspace-files-dual-face-package.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 工作区文件统一为 API 双面包 + +Status: implemented + +[English](2026-09-07-workspace-files-dual-face-package.md) | 中文 + +## Problem + +工作区文件服务与浏览器资源提供者共同演进,但其编译图包含指向 Remote 装配和 Sidebar UI 的反向依赖。拆包避开了这些环,却分离了线路协议与其 Client 模型的归属。只有 Host 实现、Client 编译入口仅含类型的包,也缺少 Client 目录分析用于区分运行时导出的 `dsh.client` 与 `./client` 声明。 + +## Decision + +`packages/api/workspace-files` 拥有两面的实现。Host 与 Client 叶配置仍由各自的根聚合直接引用,solution 根配置引用两片叶子。Host 导出文件服务,`./client` 导出实际的资源提供者插件,`dsh.client` 声明浏览器插件。web-app 的一个条目加载两面。这只取代[工作区文件服务记录](2026-09-05-workspace-files-service.zh.md)中的拆包决定,其授权、分页与流语义保持不变。 + +两条依赖方向使编译图保持无环: + +- `client/resources` 从定义 `RemoteResult` 与 `RemoteFailure` 的 `typert/protocol` 包导入它们,不依赖 `api/remotes`;后者负责装配消费资源模型的提供者。 +- 文本预览使用文件包导出的参数类型声明 `SidebarRightResourceParamsMap.file`。文件提供者声明其资源值,但不导入 Sidebar UI。调用方需要该导航声明时,导入查看器的类型入口。 + +这消除了 `remotes → workspace-files → resources → remotes` 和 `remotes → workspace-files → sidebar-right → ui-conversation → remotes`。Cordis 运行时服务注入仍独立于 TypeScript 工程引用。 + +## Alternatives considered + +**保留两个包。** 这隔离了编译环,却拆开同一文件能力的 Host 与 Client 归属。删除反向类型依赖后,可以采用与其它 API Controller 相同的双面组织。 + +**删除根 Client 引用。** 传递引用仍会编译该叶子,但两个根聚合必须显式命名本包对应的编译面。 + +**修改目录分析或增加空 Client 插件。** 两者都不能提供要求的浏览器实现。实际的 `./client` 导出和 `dsh.client` 使用分析器已有的双面支持路径。 + +## Consequences + +Host 线路方法和浏览器资源行为不变。浏览器实现、测试与文档归同一个包所有;Client 类型依赖止于协议和资源模型层,不反向触及 UI 或 Remote 装配。 + +## Verification + +Cordis inspect 目录检查分析声明的 Client 导出,两个编译聚合保留其叶引用,Host 与 Client 文件服务测试覆盖相同的实现。现有依赖与工程引用检查约束这些编译关系。 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml index 46a62de31a..a8899fea13 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md -2026-07-31-web-workspace-file-links.md: 87e8829b52f36ab9eb12149ff28775931a5ded1b -2026-07-31-web-workspace-file-links.zh.md: 36b31c1224d3e51d5ed18b071495b2389f1bface +2026-07-31-web-workspace-file-links.md: 1fd58deafa99f6c84371d2a2c3975eae86522ac2 +2026-07-31-web-workspace-file-links.zh.md: 8cb1d06562398a832fed0743a7c06894d0a4212b diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md index 87e8829b52..1fd58deafa 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md @@ -18,6 +18,8 @@ Two distinct defects sat behind that. The transcript never said what a turn had **The path link reads as a link.** Underlined at rest, not only on hover. This is the smaller half of the diff and the larger half of the fix. +**Superseded for the web client by the [right Sidebar](2026-09-04-right-sidebar-docking-infrastructure.md):** `openFile` now opens a text-preview tab in the right Sidebar, which reads the file's text over the authenticated Remote carrier — no document is served, so the origin questions below do not arise — and the **Show in folder** action is gone; `session/openWorkspacePath` remains on the Host with no web caller. The decision as it shipped in July follows. + **Opening stays the Host's job, and prefers the default browser.** `session/openWorkspacePath` hands the path to the operating system, which yields a `file://` document in a real browser: full page capabilities, and no reachability into `/api`, because a `file://` document is not same-origin with it. Measured on the reported artifact: `localStorage` works, the theme toggle flips, the tabs switch, and `fetch` to the API fails. For documents a browser renders — `.html`, `.htm`, `.xhtml`, `.svg` — the opener resolves the default *browser* rather than the type's default application when the platform can name one, because a developer who binds `.html` to an editor would otherwise click a produced page and get source code. macOS reads the LaunchServices `https` handler and desktop Linux reads `$BROWSER`; either falls back to the default application when no browser can be named. Windows uses its registered association, and WSL first translates the path before using that same Windows handoff. When files are hidden, **Show in folder** passes `.` through the same owner `openFile`; it appears only for a loopback page whose current `host.describe.canOpenPath` permits native opening. Other deployments omit it, with `nativeOpen: false` available when desktop detection would be a false positive. **Serving workspace files over HTTP is out of scope, and so are non-local clients.** Serving files from the harness itself — same-origin with `/api`, behind `CSP: sandbox`, or from a second listener whose own port gives served documents their own origin — was rejected with the product scope: previews for a browser that is not on the Host machine are not supported, so the Host opener answers the supported case completely and the HTTP machinery would answer only the unsupported one. @@ -33,4 +35,4 @@ Two distinct defects sat behind that. The transcript never said what a turn had ## Consequences -Every existing file affordance changed at once: write, edit, read, and the generic single-file card all reach `openFile`, so the link fix and browser preference apply without per-row changes. The assembled Web test covers single-line CSS overflow and a one-click Host handoff without launching a native application. A produced `file://` document cannot `fetch` its own siblings (while ` + + diff --git a/apps/desktop/renderer/plugin-manager.js b/apps/desktop/renderer/plugin-manager.js new file mode 100644 index 0000000000..a50de3a825 --- /dev/null +++ b/apps/desktop/renderer/plugin-manager.js @@ -0,0 +1,101 @@ +const api = window.dshDesktop + +async function main() { + const locale = await api.locale() + const messages = locale.messages + const message = (key, values = {}) => messages[key].replaceAll(/\{([^{}]+)\}/gu, (placeholder, name) => values[name] ?? placeholder) + document.documentElement.lang = locale.id + document.querySelector('#page-title').textContent = messages.pluginManagerTitle + document.querySelector('#title').textContent = messages.pluginManagerTitle + document.querySelector('#description').textContent = messages.pluginManagerDescription + document.querySelector('#refresh').textContent = messages.refresh + document.querySelector('#package-label').textContent = messages.npmPackage + document.querySelector('#install').textContent = messages.install + document.querySelector('#installed-heading').textContent = messages.installed + document.querySelector('#empty').textContent = messages.noPlugins + + const list = document.querySelector('#plugins') + const empty = document.querySelector('#empty') + const status = document.querySelector('#status') + const form = document.querySelector('#install-form') + const input = document.querySelector('#package-spec') + const refresh = document.querySelector('#refresh') + + function setBusy(busy, statusMessage = '') { + for (const control of document.querySelectorAll('button, input')) control.disabled = busy + status.textContent = statusMessage + } + + async function render() { + const plugins = await api.plugins.list() + list.replaceChildren(...plugins.map(plugin => { + const item = document.createElement('li') + const identity = document.createElement('span') + const version = document.createElement('span') + version.className = 'package-version' + version.textContent = plugin.version + identity.append(document.createTextNode(plugin.name), version) + const remove = document.createElement('button') + remove.type = 'button' + remove.textContent = messages.remove + remove.addEventListener('click', () => void run( + () => api.plugins.remove(plugin.name), + message('removing', { name: plugin.name }), + )) + const update = document.createElement('button') + update.type = 'button' + update.textContent = messages.update + update.addEventListener('click', () => { + const next = window.prompt(message('targetVersion', { name: plugin.name }), plugin.version)?.trim() + if (next === undefined || next === '' || next === plugin.version) return + void run(() => api.plugins.update(plugin.name, next), message('updating', { name: plugin.name })) + }) + const actions = document.createElement('span') + actions.className = 'package-actions' + actions.append(update, remove) + item.append(identity, actions) + return item + })) + empty.hidden = plugins.length !== 0 + } + + async function run(operation, statusMessage) { + setBusy(true, statusMessage) + try { + await operation() + await render() + status.textContent = messages.operationComplete + } catch (error) { + status.textContent = error instanceof Error ? error.message : String(error) + } finally { + setBusy(false, status.textContent) + } + } + + async function load(statusMessage, success) { + setBusy(true, statusMessage) + try { + await render() + status.textContent = success + } catch (error) { + status.textContent = error instanceof Error ? error.message : String(error) + } finally { + setBusy(false, status.textContent) + } + } + + form.addEventListener('submit', (event) => { + event.preventDefault() + const spec = input.value.trim() + if (spec === '') return + void run(async () => { + await api.plugins.add(spec) + input.value = '' + }, message('installing', { spec })) + }) + refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed)) + + await load(messages.loadingPlugins, '') +} + +void main() diff --git a/apps/desktop/scripts/desktop-auto-update-environment.d.mts b/apps/desktop/scripts/desktop-auto-update-environment.d.mts new file mode 100644 index 0000000000..b1661646e5 --- /dev/null +++ b/apps/desktop/scripts/desktop-auto-update-environment.d.mts @@ -0,0 +1,90 @@ +/** Environment variable that selects the Desktop update deployment. */ +export const DESKTOP_AUTO_UPDATE_ENV: 'DSH_DESKTOP_AUTO_UPDATE_ENV' + +/** Supported Desktop update deployment. */ +export type DesktopAutoUpdateEnvironment = 'test' | 'production' + +/** Directory name of one supported Desktop release target. */ +export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64' + +/** Public updater URL for one release target. */ +export interface DesktopAutoUpdateConfig { + readonly environment: DesktopAutoUpdateEnvironment + readonly target: DesktopAutoUpdateTarget + readonly origin: string + readonly publicUrl: string + readonly keyPrefix: string +} + +/** Public updater URL and private COS destination for one upload target. */ +export interface DesktopUploadConfig extends DesktopAutoUpdateConfig { + readonly bucket: string + readonly secretIdEnvName: string + readonly secretKeyEnvName: string +} + +/** + * Resolve the update deployment, defaulting local release work to test. + * @param env - Packaging or upload environment. + * @returns Validated deployment name. + */ +export function resolveDesktopAutoUpdateEnvironment( + env: NodeJS.ProcessEnv, +): DesktopAutoUpdateEnvironment + +/** + * Resolve one supported platform and architecture to its update directory. + * @param platform - Target Node.js platform. + * @param arch - Target Node.js architecture. + * @returns Update target directory. + */ +export function resolveDesktopAutoUpdateTarget( + platform: NodeJS.Platform, + arch: string, +): DesktopAutoUpdateTarget + +/** + * Return the local completion record filename for one packaged target. + * @param target - Supported release target. + * @returns Filename stored beside electron-builder artifacts. + */ +export function desktopBuildRecordFilename(target: DesktopAutoUpdateTarget): string + +/** + * Return the electron-builder channel metadata filename for an application version. + * @param version - Desktop semantic version. + * @param platform - Target platform. + * @returns Channel metadata filename emitted for the target. + */ +export function desktopUpdateMetadataFilename( + version: string, + platform: NodeJS.Platform, +): string + +/** + * Resolve the public updater URL for one release target. + * @param env - Packaging or upload environment. + * @param platform - Target Node.js platform. + * @param arch - Target Node.js architecture. + * @returns Resolved updater configuration. + * @throws When the test deployment lacks a valid HTTPS origin. + */ +export function resolveDesktopAutoUpdateConfig( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + arch: string, +): DesktopAutoUpdateConfig + +/** + * Resolve the public updater URL and private COS destination for one upload target. + * @param env - Upload environment. + * @param platform - Target Node.js platform. + * @param arch - Target Node.js architecture. + * @returns Resolved upload configuration. + * @throws When the selected deployment lacks a required origin or bucket, or the test origin is not HTTPS. + */ +export function resolveDesktopUploadConfig( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + arch: string, +): DesktopUploadConfig diff --git a/apps/desktop/scripts/desktop-auto-update-environment.mjs b/apps/desktop/scripts/desktop-auto-update-environment.mjs new file mode 100644 index 0000000000..da3c36ccb5 --- /dev/null +++ b/apps/desktop/scripts/desktop-auto-update-environment.mjs @@ -0,0 +1,169 @@ +/** Resolve the Desktop auto-update channel and its Tencent COS destination. */ + +import { prerelease, valid } from 'semver' + +/** Environment variable that selects the Desktop update deployment. */ +export const DESKTOP_AUTO_UPDATE_ENV = 'DSH_DESKTOP_AUTO_UPDATE_ENV' + +const UPDATE_ENVIRONMENTS = { + test: { + originEnvName: 'DOWNLOAD_TEST_ORIGIN', + fixedOrigin: undefined, + bucketEnvName: 'DOWNLOAD_TEST_COS_BUCKET', + secretIdEnvName: 'DOWNLOAD_TEST_COS_SECRET_ID', + secretKeyEnvName: 'DOWNLOAD_TEST_COS_SECRET_KEY', + }, + production: { + originEnvName: undefined, + fixedOrigin: 'https://download.deepseek.com', + bucketEnvName: 'DOWNLOAD_PROD_COS_BUCKET', + secretIdEnvName: 'DOWNLOAD_PROD_COS_SECRET_ID', + secretKeyEnvName: 'DOWNLOAD_PROD_COS_SECRET_KEY', + }, +} + +const UPDATE_TARGETS = new Set(['mac-arm64', 'mac-x64', 'win-x64']) + +/** + * Resolve the update deployment, defaulting local release work to test. + * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. + * @returns {'test' | 'production'} Validated deployment name. + */ +export function resolveDesktopAutoUpdateEnvironment(env) { + const value = env[DESKTOP_AUTO_UPDATE_ENV]?.trim() || 'test' + if (value !== 'test' && value !== 'production') { + throw new Error(`desktop auto-update: ${DESKTOP_AUTO_UPDATE_ENV} must be "test" or "production"`) + } + return value +} + +/** + * Resolve one supported platform and architecture to its update directory. + * @param {NodeJS.Platform} platform - Target Node.js platform. + * @param {string} arch - Target Node.js architecture. + * @returns {'mac-arm64' | 'mac-x64' | 'win-x64'} Update target directory. + */ +export function resolveDesktopAutoUpdateTarget(platform, arch) { + const os = platform === 'darwin' ? 'mac' : platform === 'win32' ? 'win' : platform + const target = `${os}-${arch}` + if (!UPDATE_TARGETS.has(target)) { + throw new Error(`desktop auto-update: unsupported target ${target}`) + } + return target +} + +/** + * Return the local completion record filename for one packaged target. + * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported release target. + * @returns {string} Filename stored beside electron-builder artifacts. + */ +export function desktopBuildRecordFilename(target) { + if (!UPDATE_TARGETS.has(target)) { + throw new Error(`desktop auto-update: unsupported target ${target}`) + } + return `${target}-release.json` +} + +/** + * Return the electron-builder channel metadata filename for an application version. + * @param {string} version - Desktop semantic version. + * @param {NodeJS.Platform} platform - Target platform. + * @returns {string} Channel metadata filename emitted for the target. + */ +export function desktopUpdateMetadataFilename(version, platform) { + if (valid(version) === null) { + throw new Error(`desktop auto-update: invalid Desktop version ${JSON.stringify(version)}`) + } + if (platform !== 'darwin' && platform !== 'win32') { + throw new Error(`desktop auto-update: unsupported metadata platform ${platform}`) + } + const release = prerelease(version) + const channel = release === null ? 'latest' : String(release[0]) + return `${channel}${platform === 'darwin' ? '-mac' : ''}.yml` +} + +/** + * Read one required release setting without accepting whitespace-only values. + * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. + * @param {string} name - Environment variable to read. + * @returns {string} Trimmed setting. + */ +function requiredEnvironmentValue(env, name) { + const value = env[name]?.trim() + if (value === undefined || value === '') { + throw new Error(`desktop auto-update: ${name} must be set to a non-empty value`) + } + return value +} + +/** + * Normalize an HTTPS origin and reject paths or credentials. + * @param {string} value - Candidate origin. + * @param {string} name - Environment variable used in diagnostics. + * @returns {string} Normalized HTTPS origin without a trailing slash. + */ +function httpsOrigin(value, name) { + let parsed + try { + parsed = new URL(value) + } + catch { + throw new Error(`desktop auto-update: ${name} must be an absolute HTTPS origin`) + } + if (parsed.protocol !== 'https:' + || parsed.username !== '' + || parsed.password !== '' + || parsed.pathname !== '/' + || parsed.search !== '' + || parsed.hash !== '') { + throw new Error(`desktop auto-update: ${name} must be an absolute HTTPS origin without a path, credentials, query, or fragment`) + } + return parsed.origin +} + +/** + * Resolve the public updater URL for one release target. + * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. + * @param {NodeJS.Platform} platform - Target Node.js platform. + * @param {string} arch - Target Node.js architecture. + * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string }} Resolved updater configuration. + * @throws {Error} When the test deployment lacks a valid HTTPS origin. + */ +export function resolveDesktopAutoUpdateConfig(env, platform, arch) { + const environment = resolveDesktopAutoUpdateEnvironment(env) + const target = resolveDesktopAutoUpdateTarget(platform, arch) + const deployment = UPDATE_ENVIRONMENTS[environment] + let origin = deployment.fixedOrigin + if (origin === undefined) { + const { originEnvName } = deployment + if (originEnvName === undefined) throw new Error('desktop auto-update: selected deployment has no origin') + origin = httpsOrigin(requiredEnvironmentValue(env, originEnvName), originEnvName) + } + const keyPrefix = `_/harness/desktop/stable/${target}` + return { + environment, + target, + origin, + keyPrefix, + publicUrl: `${origin}/${keyPrefix}/`, + } +} + +/** + * Resolve the public updater URL and private COS destination for one upload target. + * @param {NodeJS.ProcessEnv} env - Upload environment. + * @param {NodeJS.Platform} platform - Target Node.js platform. + * @param {string} arch - Target Node.js architecture. + * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. + * @throws {Error} When the selected deployment lacks a required origin or bucket, or the test origin is not HTTPS. + */ +export function resolveDesktopUploadConfig(env, platform, arch) { + const update = resolveDesktopAutoUpdateConfig(env, platform, arch) + const deployment = UPDATE_ENVIRONMENTS[update.environment] + return { + ...update, + bucket: requiredEnvironmentValue(env, deployment.bucketEnvName), + secretIdEnvName: deployment.secretIdEnvName, + secretKeyEnvName: deployment.secretKeyEnvName, + } +} diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts new file mode 100644 index 0000000000..423e017b15 --- /dev/null +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -0,0 +1,49 @@ +import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment.mjs' + +/** Mutable target directories plus the shared immutable download cache. */ +export interface DesktopTargetBuildPaths { + readonly root: string + readonly artifacts: string + readonly runtime: string + readonly packageSet: string + readonly seed: string + readonly seedPnpm: string + readonly nodeExtract: string + readonly packedDsh: string + readonly packedVendor: string + readonly packedLandlock: string + readonly downloads: string +} + +/** + * Resolve the fixed build target selected by a packaging environment. + * @param env - Packaging environment. + * @param hostPlatform - Build-host platform used when no target override exists. + * @param hostArch - Build-host architecture used when no target override exists. + * @returns Supported Desktop target name. + */ +export function resolveDesktopBuildTarget( + env?: NodeJS.ProcessEnv, + hostPlatform?: NodeJS.Platform, + hostArch?: string, +): DesktopAutoUpdateTarget + +/** + * Return the mutable preparation and artifact directories owned by one release target. + * @param target - Supported Desktop target name. + * @returns Target paths plus the shared immutable download cache. + */ +export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget): DesktopTargetBuildPaths + +/** + * Resolve the paths owned by the target selected in a packaging environment. + * @param env - Packaging environment. + * @param hostPlatform - Build-host platform used when no target override exists. + * @param hostArch - Build-host architecture used when no target override exists. + * @returns Selected target paths. + */ +export function resolveDesktopTargetBuildPaths( + env?: NodeJS.ProcessEnv, + hostPlatform?: NodeJS.Platform, + hostArch?: string, +): DesktopTargetBuildPaths diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs new file mode 100644 index 0000000000..504136e1db --- /dev/null +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -0,0 +1,70 @@ +/** Resolve build-owned Desktop paths without sharing mutable state across release targets. */ + +import { join, resolve } from 'node:path' + +const APP_ROOT = resolve(import.meta.dirname, '..') +const BUILD_ROOT = join(APP_ROOT, '.desktop-build') +const SUPPORTED_TARGETS = new Set(['mac-arm64', 'mac-x64', 'win-x64']) + +/** + * Resolve the fixed build target selected by a packaging environment. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @param {NodeJS.Platform} hostPlatform - Build-host platform used when no target override exists. + * @param {string} hostArch - Build-host architecture used when no target override exists. + * @returns {'mac-arm64' | 'mac-x64' | 'win-x64'} Supported Desktop target name. + */ +export function resolveDesktopBuildTarget( + env = process.env, + hostPlatform = process.platform, + hostArch = process.arch, +) { + const platform = env.DSH_DESKTOP_TARGET_PLATFORM ?? env.npm_config_platform ?? hostPlatform + const arch = env.DSH_DESKTOP_TARGET_ARCH ?? env.npm_config_arch ?? hostArch + const os = platform === 'darwin' ? 'mac' : platform === 'win32' || platform === 'win' ? 'win' : platform + const target = `${os}-${arch}` + if (!SUPPORTED_TARGETS.has(target)) { + throw new Error(`desktop build paths: unsupported target ${target}`) + } + return /** @type {'mac-arm64' | 'mac-x64' | 'win-x64'} */ (target) +} + +/** + * Return the mutable preparation and artifact directories owned by one release target. + * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name. + * @returns {{ root: string, artifacts: string, runtime: string, packageSet: string, seed: string, seedPnpm: string, nodeExtract: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. + */ +export function desktopTargetBuildPaths(target) { + if (!SUPPORTED_TARGETS.has(target)) { + throw new Error(`desktop build paths: unsupported target ${String(target)}`) + } + const root = join(BUILD_ROOT, 'targets', target) + const packed = join(root, 'packed') + return { + root, + artifacts: join(root, 'artifacts'), + runtime: join(root, 'runtime'), + packageSet: join(root, 'package-set'), + seed: join(root, 'seed'), + seedPnpm: join(root, 'seed-pnpm'), + nodeExtract: join(root, 'node-extract'), + packedDsh: join(packed, 'dsh'), + packedVendor: join(packed, 'vendor'), + packedLandlock: join(packed, 'landlock'), + downloads: join(BUILD_ROOT, 'downloads'), + } +} + +/** + * Resolve the paths owned by the target selected in a packaging environment. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @param {NodeJS.Platform} hostPlatform - Build-host platform used when no target override exists. + * @param {string} hostArch - Build-host architecture used when no target override exists. + * @returns {ReturnType} Selected target paths. + */ +export function resolveDesktopTargetBuildPaths( + env = process.env, + hostPlatform = process.platform, + hostArch = process.arch, +) { + return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) +} diff --git a/apps/desktop/scripts/desktop-release-environment.d.mts b/apps/desktop/scripts/desktop-release-environment.d.mts new file mode 100644 index 0000000000..ad48933f65 --- /dev/null +++ b/apps/desktop/scripts/desktop-release-environment.d.mts @@ -0,0 +1,61 @@ +/** Environment variable that supplies the Electron application identifier. */ +export const DESKTOP_APP_ID_ENV: 'DSH_DESKTOP_APP_ID' + +/** Environment variable that supplies electron-builder's macOS certificate qualifier. */ +export const MACOS_SIGNING_IDENTITY_ENV: 'DSH_DESKTOP_MACOS_SIGNING_IDENTITY' + +/** Environment variable that supplies the expected Apple Developer Team ID. */ +export const MACOS_TEAM_ID_ENV: 'DSH_DESKTOP_MACOS_TEAM_ID' + +/** Public identity expected on a macOS release. */ +export interface MacOSSigningEnvironment { + readonly signingIdentity: string + readonly teamId: string +} + +/** Apple ID credentials accepted by notarytool. */ +export interface MacOSAppleIdNotarizationEnvironment { + readonly appleId: string + readonly appleIdPassword: string + readonly teamId: string +} + +/** App Store Connect API credentials accepted by notarytool. */ +export interface MacOSApiKeyNotarizationEnvironment { + readonly appleApiKey: string + readonly appleApiKeyId: string + readonly appleApiIssuer: string +} + +/** Keychain profile accepted by notarytool. */ +export interface MacOSKeychainNotarizationEnvironment { + readonly keychainProfile: string + readonly keychain?: string +} + +/** One complete credential strategy accepted by notarytool. */ +export type MacOSNotarizationEnvironment = + | MacOSAppleIdNotarizationEnvironment + | MacOSApiKeyNotarizationEnvironment + | MacOSKeychainNotarizationEnvironment + +/** + * Resolve and validate the application identifier shared by every platform target. + * @param env - Packaging environment. + * @returns Reverse-DNS application identifier. + */ +export function resolveDesktopAppId(env: NodeJS.ProcessEnv): string + +/** + * Resolve and validate the public identity expected on a macOS release. + * @param env - Packaging environment. + * @returns Expected certificate qualifier and Team ID. + */ +export function resolveMacOSSigningEnvironment(env: NodeJS.ProcessEnv): MacOSSigningEnvironment + +/** + * Resolve one complete credential set accepted by Apple's notary service. + * @param env - Packaging environment. + * @returns Notary credentials without the submitted artifact path. + */ +export function resolveMacOSNotarizationEnvironment(env: NodeJS.ProcessEnv): MacOSNotarizationEnvironment diff --git a/apps/desktop/scripts/desktop-release-environment.mjs b/apps/desktop/scripts/desktop-release-environment.mjs new file mode 100644 index 0000000000..46746428d9 --- /dev/null +++ b/apps/desktop/scripts/desktop-release-environment.mjs @@ -0,0 +1,98 @@ +/** Resolve public release identifiers supplied by the packaging environment. */ + +/** Environment variable that supplies the Electron application identifier. */ +export const DESKTOP_APP_ID_ENV = 'DSH_DESKTOP_APP_ID' + +/** Environment variable that supplies electron-builder's macOS certificate qualifier. */ +export const MACOS_SIGNING_IDENTITY_ENV = 'DSH_DESKTOP_MACOS_SIGNING_IDENTITY' + +/** Environment variable that supplies the expected Apple Developer Team ID. */ +export const MACOS_TEAM_ID_ENV = 'DSH_DESKTOP_MACOS_TEAM_ID' + +const APPLE_API_KEY_ENV = 'APPLE_API_KEY' +const APPLE_API_KEY_ID_ENV = 'APPLE_API_KEY_ID' +const APPLE_API_ISSUER_ENV = 'APPLE_API_ISSUER' +const APPLE_ID_ENV = 'APPLE_ID' +const APPLE_APP_SPECIFIC_PASSWORD_ENV = 'APPLE_APP_SPECIFIC_PASSWORD' +const APPLE_TEAM_ID_ENV = 'APPLE_TEAM_ID' +const APPLE_KEYCHAIN_ENV = 'APPLE_KEYCHAIN' +const APPLE_KEYCHAIN_PROFILE_ENV = 'APPLE_KEYCHAIN_PROFILE' + +/** + * Read one required non-empty environment variable. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @param {string} name - Required variable name. + * @returns {string} Trimmed variable value. + */ +function requireEnvironmentValue(env, name) { + const value = env[name]?.trim() + if (value === undefined || value === '') { + throw new Error(`desktop release environment: ${name} must be set to a non-empty value`) + } + return value +} + +/** + * Resolve and validate the application identifier shared by every platform target. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @returns {string} Reverse-DNS application identifier. + */ +export function resolveDesktopAppId(env) { + const appId = requireEnvironmentValue(env, DESKTOP_APP_ID_ENV) + if (!/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/u.test(appId)) { + throw new Error(`desktop release environment: ${DESKTOP_APP_ID_ENV} must be a reverse-DNS identifier`) + } + return appId +} + +/** + * Resolve and validate the public identity expected on a macOS release. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @returns {{ signingIdentity: string, teamId: string }} Expected certificate qualifier and Team ID. + */ +export function resolveMacOSSigningEnvironment(env) { + const signingIdentity = requireEnvironmentValue(env, MACOS_SIGNING_IDENTITY_ENV) + if (signingIdentity.startsWith('Developer ID Application:')) { + throw new Error(`desktop release environment: ${MACOS_SIGNING_IDENTITY_ENV} must omit the "Developer ID Application:" prefix`) + } + const teamId = requireEnvironmentValue(env, MACOS_TEAM_ID_ENV) + if (!/^[A-Z0-9]{10}$/u.test(teamId)) { + throw new Error(`desktop release environment: ${MACOS_TEAM_ID_ENV} must contain 10 uppercase letters or digits`) + } + return { signingIdentity, teamId } +} + +/** + * Resolve one complete credential set accepted by Apple's notary service. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @returns {{ appleId: string, appleIdPassword: string, teamId: string } | { appleApiKey: string, appleApiKeyId: string, appleApiIssuer: string } | { keychainProfile: string, keychain?: string }} Notary credentials without the submitted artifact path. + */ +export function resolveMacOSNotarizationEnvironment(env) { + const appleIdValues = [env[APPLE_ID_ENV], env[APPLE_APP_SPECIFIC_PASSWORD_ENV], env[APPLE_TEAM_ID_ENV]] + if (appleIdValues.some(value => value !== undefined)) { + return { + appleId: requireEnvironmentValue(env, APPLE_ID_ENV), + appleIdPassword: requireEnvironmentValue(env, APPLE_APP_SPECIFIC_PASSWORD_ENV), + teamId: requireEnvironmentValue(env, APPLE_TEAM_ID_ENV), + } + } + + const apiKeyValues = [env[APPLE_API_KEY_ENV], env[APPLE_API_KEY_ID_ENV], env[APPLE_API_ISSUER_ENV]] + if (apiKeyValues.some(value => value !== undefined)) { + return { + appleApiKey: requireEnvironmentValue(env, APPLE_API_KEY_ENV), + appleApiKeyId: requireEnvironmentValue(env, APPLE_API_KEY_ID_ENV), + appleApiIssuer: requireEnvironmentValue(env, APPLE_API_ISSUER_ENV), + } + } + + const keychainProfile = env[APPLE_KEYCHAIN_PROFILE_ENV]?.trim() + if (keychainProfile !== undefined && keychainProfile !== '') { + const keychain = env[APPLE_KEYCHAIN_ENV]?.trim() + return keychain === undefined || keychain === '' + ? { keychainProfile } + : { keychainProfile, keychain } + } + + throw new Error('desktop release environment: macOS packaging requires APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER; APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID; or APPLE_KEYCHAIN_PROFILE') +} diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts new file mode 100644 index 0000000000..ff8a2f4868 --- /dev/null +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -0,0 +1,257 @@ +/** Validate packaged Desktop update artifacts before any network upload begins. */ + +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { readFile, stat } from 'node:fs/promises' +import { basename, join, resolve } from 'node:path' +import { load } from 'js-yaml' +import type { DesktopPackageTargetName } from './package-target.ts' +import { + desktopBuildRecordFilename, + desktopUpdateMetadataFilename, + resolveDesktopUploadConfig, +} from './desktop-auto-update-environment.mjs' +import { desktopTargetBuildPaths } from './desktop-build-paths.mjs' + +const APP_ROOT = resolve(import.meta.dirname, '..') +const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..') +const TARGETS = { + 'mac-arm64': { platform: 'darwin', arch: 'arm64', os: 'mac' }, + 'mac-x64': { platform: 'darwin', arch: 'x64', os: 'mac' }, + 'win-x64': { platform: 'win32', arch: 'x64', os: 'win' }, +} as const satisfies Record + +/** One local file and its final object metadata. */ +export interface DesktopUploadArtifact { + readonly path: string + readonly filename: string + readonly key: string + readonly contentType: string + readonly cacheControl: string + readonly channelMetadata: boolean +} + +/** A fully validated upload operation with channel metadata ordered last. */ +export interface DesktopUploadPlan { + readonly environment: 'test' | 'production' + readonly target: DesktopPackageTargetName + readonly version: string + readonly publicUrl: string + readonly bucket: string + readonly secretIdEnvName: string + readonly secretKeyEnvName: string + readonly artifacts: readonly DesktopUploadArtifact[] +} + +/** Filesystem and environment inputs used to validate one upload. */ +export interface DesktopUploadPlanOptions { + readonly environment?: NodeJS.ProcessEnv + readonly repositoryRoot?: string + readonly appRoot?: string + readonly artifactsRoot?: string +} + +interface UpdateFileInfo { + readonly filename: string + readonly size: number + readonly sha512: string +} + +function object(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`desktop upload: ${label} must be an object`) + } + return value as Record +} + +function stringField(value: unknown, label: string): string { + if (typeof value !== 'string' || value === '') { + throw new Error(`desktop upload: ${label} must be a non-empty string`) + } + return value +} + +function numberField(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + throw new Error(`desktop upload: ${label} must be a positive integer`) + } + return value +} + +async function jsonFile(path: string, label: string): Promise> { + let parsed: unknown + try { + parsed = JSON.parse(await readFile(path, 'utf8')) + } + catch (error) { + throw new Error(`desktop upload: cannot read ${label} at ${path}: ${error instanceof Error ? error.message : String(error)}`) + } + return object(parsed, label) +} + +async function manifestVersion(path: string, label: string): Promise { + return stringField((await jsonFile(path, label)).version, `${label}.version`) +} + +function updateFileInfo(value: unknown, label: string, expectedFilename: string): UpdateFileInfo { + const info = object(value, label) + const filename = stringField(info.url ?? info.path, `${label}.url`) + if (filename !== basename(filename) || filename !== expectedFilename) { + throw new Error(`desktop upload: ${label} must reference ${expectedFilename}, received ${filename}`) + } + return { + filename, + size: numberField(info.size, `${label}.size`), + sha512: stringField(info.sha512, `${label}.sha512`), + } +} + +async function sha512(path: string): Promise { + const hash = createHash('sha512') + for await (const chunk of createReadStream(path)) hash.update(chunk) + return hash.digest('base64') +} + +async function verifyChecksummedArtifact( + artifactsRoot: string, + info: UpdateFileInfo, +): Promise { + const path = join(artifactsRoot, info.filename) + const details = await stat(path).catch(() => undefined) + if (details === undefined || !details.isFile()) { + throw new Error(`desktop upload: missing artifact ${path}`) + } + if (details.size !== info.size) { + throw new Error(`desktop upload: ${info.filename} size ${details.size} does not match update metadata ${info.size}`) + } + const actual = await sha512(path) + if (actual !== info.sha512) { + throw new Error(`desktop upload: ${info.filename} SHA-512 does not match update metadata`) + } + return path +} + +async function requireArtifact(artifactsRoot: string, filename: string): Promise { + const path = join(artifactsRoot, filename) + const details = await stat(path).catch(() => undefined) + if (details === undefined || !details.isFile() || details.size === 0) { + throw new Error(`desktop upload: missing or empty artifact ${path}`) + } + return path +} + +function uploadArtifact( + path: string, + keyPrefix: string, + contentType: string, + channelMetadata = false, +): DesktopUploadArtifact { + const filename = basename(path) + return { + path, + filename, + key: `${keyPrefix}/${filename}`, + contentType, + cacheControl: channelMetadata + ? 'no-cache' + : 'public, max-age=31536000, immutable', + channelMetadata, + } +} + +/** + * Validate the completed package record, dsh version, update metadata, hashes, and target files. + * @param targetName - Fixed platform and architecture selected by the upload command. + * @param options - Optional filesystem roots and environment for tests or release automation. + * @returns An upload plan whose mutable channel metadata is the final entry. + */ +export async function createDesktopUploadPlan( + targetName: DesktopPackageTargetName, + options: DesktopUploadPlanOptions = {}, +): Promise { + const target = TARGETS[targetName] + if (target === undefined) { + throw new Error(`desktop upload: unsupported target ${String(targetName)}`) + } + const environment = options.environment ?? process.env + const repositoryRoot = options.repositoryRoot ?? REPOSITORY_ROOT + const appRoot = options.appRoot ?? APP_ROOT + const artifactsRoot = options.artifactsRoot ?? desktopTargetBuildPaths(targetName).artifacts + const dshVersion = await manifestVersion(join(repositoryRoot, 'package.json'), 'dsh package') + const desktopVersion = await manifestVersion(join(appRoot, 'package.json'), 'desktop package') + if (dshVersion !== desktopVersion) { + throw new Error(`desktop upload: desktop version ${desktopVersion} does not match current dsh version ${dshVersion}`) + } + + const update = resolveDesktopUploadConfig(environment, target.platform, target.arch) + const buildRecord = await jsonFile( + join(artifactsRoot, desktopBuildRecordFilename(targetName)), + `${targetName} package completion record`, + ) + if (buildRecord.schemaVersion !== 1 + || buildRecord.target !== targetName + || buildRecord.version !== dshVersion + || buildRecord.environment !== update.environment + || buildRecord.publicUrl !== update.publicUrl) { + throw new Error(`desktop upload: ${targetName} package completion record does not match dsh ${dshVersion} and ${update.environment} update destination`) + } + + const metadataFilename = desktopUpdateMetadataFilename(dshVersion, target.platform) + const metadataPath = join(artifactsRoot, metadataFilename) + let metadataValue: unknown + try { + metadataValue = load(await readFile(metadataPath, 'utf8')) + } + catch (error) { + throw new Error(`desktop upload: cannot read update metadata at ${metadataPath}: ${error instanceof Error ? error.message : String(error)}`) + } + const metadata = object(metadataValue, metadataFilename) + const metadataVersion = stringField(metadata.version, `${metadataFilename}.version`) + if (metadataVersion !== dshVersion) { + throw new Error(`desktop upload: ${metadataFilename} version ${metadataVersion} does not match current dsh version ${dshVersion}`) + } + if (!Array.isArray(metadata.files) || metadata.files.length !== 1) { + throw new Error(`desktop upload: ${metadataFilename}.files must contain exactly one target update file`) + } + + const base = `deepseek-harness-${dshVersion}-${target.os}-${target.arch}` + const updaterExtension = target.platform === 'darwin' ? 'zip' : 'exe' + const updaterInfo = updateFileInfo(metadata.files[0], `${metadataFilename}.files[0]`, `${base}.${updaterExtension}`) + const updaterPath = await verifyChecksummedArtifact(artifactsRoot, updaterInfo) + const artifacts: DesktopUploadArtifact[] = [] + + if (target.platform === 'darwin') { + const dmgPath = await requireArtifact(artifactsRoot, `${base}.dmg`) + const blockmapPath = await requireArtifact(artifactsRoot, `${base}.zip.blockmap`) + artifacts.push( + uploadArtifact(dmgPath, update.keyPrefix, 'application/x-apple-diskimage'), + uploadArtifact(updaterPath, update.keyPrefix, 'application/zip'), + uploadArtifact(blockmapPath, update.keyPrefix, 'application/octet-stream'), + ) + } + else { + const blockMapSize = object(metadata.files[0], `${metadataFilename}.files[0]`).blockMapSize + numberField(blockMapSize, `${metadataFilename}.files[0].blockMapSize`) + artifacts.push(uploadArtifact( + updaterPath, + update.keyPrefix, + 'application/vnd.microsoft.portable-executable', + )) + } + + artifacts.push(uploadArtifact(metadataPath, update.keyPrefix, 'application/yaml', true)) + return { + environment: update.environment, + target: targetName, + version: dshVersion, + publicUrl: update.publicUrl, + bucket: update.bucket, + secretIdEnvName: update.secretIdEnvName, + secretKeyEnvName: update.secretKeyEnvName, + artifacts, + } +} diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts new file mode 100644 index 0000000000..3ae65d12aa --- /dev/null +++ b/apps/desktop/scripts/dev.ts @@ -0,0 +1,118 @@ +/** Build and launch the unpackaged Electron shell against the current workspace. */ + +import { spawn } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import type { DesktopRelease } from '../src/release.ts' +import { prepareDevelopmentProject } from './development-project.ts' + +const APP_ROOT = resolve(import.meta.dirname, '..') +const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..') +const BUILD_ROOT = join(APP_ROOT, '.desktop-build') +const DEVELOPMENT_ROOT = join(BUILD_ROOT, 'development') + +interface PackageManifest { + readonly version?: string +} + +function packageVersion(path: string, subject: string): string { + const manifest = JSON.parse(readFileSync(path, 'utf8')) as PackageManifest + if (typeof manifest.version !== 'string') throw new Error(`desktop development: ${subject} has no version`) + return manifest.version +} + +function debugPort(name: string, fallback: number): number { + const value = process.env[name] + if (value === undefined || value === '') return fallback + const port = Number(value) + if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { + throw new Error(`desktop development: ${name} must be an integer from 1 through 65535`) + } + return port +} + +async function run(command: string, args: readonly string[], cwd: string, environment = process.env): Promise { + await new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { cwd, env: environment, stdio: 'inherit' }) + child.once('error', reject) + child.once('exit', (code, signal) => { + if (code === 0) resolvePromise() + else reject(new Error(`desktop development: ${args.join(' ')} exited with ${String(code ?? signal)}`)) + }) + }) +} + +async function runPackageScript(script: string, cwd: string): Promise { + const packageManager = process.env.npm_execpath + if (packageManager === undefined || packageManager === '') { + throw new Error('desktop development: invoke this launcher through pnpm run dev:desktop or start:desktop') + } + await run(process.execPath, [packageManager, 'run', script], cwd) +} + +async function launchElectron(projectDir: string): Promise { + const require = createRequire(import.meta.url) + const electron: unknown = require('electron') + if (typeof electron !== 'string') throw new Error('desktop development: electron executable is unavailable') + const mainPort = debugPort('DSH_DESKTOP_MAIN_INSPECT_PORT', 9229) + const rendererPort = debugPort('DSH_DESKTOP_RENDERER_DEBUG_PORT', 9222) + const hostPort = debugPort('DSH_DESKTOP_HOST_INSPECT_PORT', 9230) + const home = resolve(process.env.DSH_HOME ?? join(DEVELOPMENT_ROOT, 'home')) + const userData = join(DEVELOPMENT_ROOT, 'electron-user-data') + const environment: NodeJS.ProcessEnv = { + ...process.env, + DSH_HOME: home, + DSH_DESKTOP_DEV_PROJECT_DIR: projectDir, + DSH_DESKTOP_HOST_INSPECT_PORT: String(hostPort), + DSH_DESKTOP_NODE_BINARY: process.execPath, + DSH_DESKTOP_OPEN_DEVTOOLS: process.env.DSH_DESKTOP_OPEN_DEVTOOLS ?? '1', + ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1', + } + console.log(`desktop development: DSH_HOME=${home}`) + console.log(`desktop development: inspectors main=${String(mainPort)}, renderer=${String(rendererPort)}, host=${String(hostPort)}`) + await run(electron, [ + `--inspect=127.0.0.1:${String(mainPort)}`, + `--remote-debugging-port=${String(rendererPort)}`, + `--user-data-dir=${userData}`, + APP_ROOT, + ], APP_ROOT, environment) +} + +async function main(): Promise { + const { values } = parseArgs({ options: { 'skip-build': { type: 'boolean', default: false } } }) + if (!values['skip-build']) { + await runPackageScript('build', REPOSITORY_ROOT) + await runPackageScript('build', APP_ROOT) + } + for (const path of [ + join(APP_ROOT, 'lib', 'main.js'), + join(REPOSITORY_ROOT, 'apps', 'desktop-host', 'lib', 'index.js'), + ]) { + if (!existsSync(path)) throw new Error(`desktop development: missing built artifact ${path}`) + } + const version = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package') + const pnpmVersion = packageVersion(join(APP_ROOT, 'node_modules', 'pnpm', 'package.json'), 'pnpm package') + const release: DesktopRelease = { + schemaVersion: 1, + version, + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: process.versions.node, + pnpmVersion, + } + const projectDir = prepareDevelopmentProject({ + projectDir: join(DEVELOPMENT_ROOT, 'project'), + cliDir: join(REPOSITORY_ROOT, 'apps', 'cli'), + hostDir: join(REPOSITORY_ROOT, 'apps', 'desktop-host'), + dependencyDir: join(REPOSITORY_ROOT, 'node_modules', '.pnpm', 'node_modules'), + release, + }) + await launchElectron(projectDir) +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +}) diff --git a/apps/desktop/scripts/development-project.ts b/apps/desktop/scripts/development-project.ts new file mode 100644 index 0000000000..275373fffe --- /dev/null +++ b/apps/desktop/scripts/development-project.ts @@ -0,0 +1,120 @@ +/** Prepare the disposable npm-project view used by an unpackaged Electron shell. */ + +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + unlinkSync, +} from 'node:fs' +import { dirname, join } from 'node:path' +import { createDevelopmentProjectMetadata } from '../src/project-manager.ts' +import type { DesktopRelease } from '../src/release.ts' + +interface PackageManifest { + readonly name?: string + readonly version?: string +} + +/** Inputs whose locations differ between the launcher and isolated tests. */ +export interface DevelopmentProjectOptions { + /** Directory replaced with the generated development project. */ + readonly projectDir: string + /** Current workspace's `apps/cli` package directory. */ + readonly cliDir: string + /** Current workspace's private Desktop Host application directory. */ + readonly hostDir: string + /** pnpm's workspace-wide virtual-hoist directory. */ + readonly dependencyDir: string + /** Release identity written into the disposable project metadata. */ + readonly release: DesktopRelease +} + +function readManifest(path: string): PackageManifest { + return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest +} + +function removeOwnedPath(path: string): void { + let stat: ReturnType + try { + stat = lstatSync(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + if (stat.isSymbolicLink()) { + unlinkSync(path) + return + } + if (stat.isDirectory()) { + rmSync(path, { recursive: true }) + return + } + unlinkSync(path) +} + +function linkDirectory(source: string, destination: string): void { + mkdirSync(dirname(destination), { recursive: true }) + symlinkSync(realpathSync(source), destination, process.platform === 'win32' ? 'junction' : 'dir') +} + +function mirrorDependencyLinks(sourceRoot: string, destinationRoot: string): void { + for (const entry of readdirSync(sourceRoot, { withFileTypes: true })) { + if (entry.name === '.bin') continue + const source = join(sourceRoot, entry.name) + if (entry.name.startsWith('@') && (entry.isDirectory() || entry.isSymbolicLink())) { + mkdirSync(join(destinationRoot, entry.name), { recursive: true }) + for (const scoped of readdirSync(source, { withFileTypes: true })) { + if (!scoped.isDirectory() && !scoped.isSymbolicLink()) continue + linkDirectory(join(source, scoped.name), join(destinationRoot, entry.name, scoped.name)) + } + continue + } + if (entry.isDirectory() || entry.isSymbolicLink()) linkDirectory(source, join(destinationRoot, entry.name)) + } +} + +/** + * Replace one disposable project with links to the current built workspace. + * @param options - Project destination, CLI package, and release identity. + * @returns the absolute project directory supplied by the caller. + */ +export function prepareDevelopmentProject(options: DevelopmentProjectOptions): string { + const cliManifest = readManifest(join(options.cliDir, 'package.json')) + if (cliManifest.name !== '@deepseek-ai/dsh' || cliManifest.version !== options.release.version) { + throw new Error( + `desktop development: apps/cli must be @deepseek-ai/dsh@${options.release.version}, found ` + + `${String(cliManifest.name)}@${String(cliManifest.version)}`, + ) + } + if (!existsSync(options.dependencyDir)) { + throw new Error('desktop development: workspace dependency links are missing; run pnpm install') + } + const hostManifest = readManifest(join(options.hostDir, 'package.json')) + if (hostManifest.name !== '@deepseek-ai/dsh-desktop-host' || hostManifest.version !== options.release.version) { + throw new Error( + `desktop development: apps/desktop-host must be @deepseek-ai/dsh-desktop-host@${options.release.version}, found ` + + `${String(hostManifest.name)}@${String(hostManifest.version)}`, + ) + } + if (!existsSync(join(options.hostDir, 'lib', 'index.js'))) { + throw new Error('desktop development: apps/desktop-host/lib/index.js is missing; run pnpm run build') + } + + removeOwnedPath(options.projectDir) + createDevelopmentProjectMetadata(options.projectDir, options.release) + const destinationModules = join(options.projectDir, 'node_modules') + mkdirSync(destinationModules, { recursive: true }) + mirrorDependencyLinks(options.dependencyDir, destinationModules) + const dshLink = join(destinationModules, '@deepseek-ai', 'dsh') + removeOwnedPath(dshLink) + linkDirectory(options.cliDir, dshLink) + const hostLink = join(destinationModules, '@deepseek-ai', 'dsh-desktop-host') + removeOwnedPath(hostLink) + linkDirectory(options.hostDir, hostLink) + return options.projectDir +} diff --git a/apps/desktop/scripts/macos-seed-store.ts b/apps/desktop/scripts/macos-seed-store.ts new file mode 100644 index 0000000000..1a02dfd5cc --- /dev/null +++ b/apps/desktop/scripts/macos-seed-store.ts @@ -0,0 +1,413 @@ +/** Sign Mach-O content in a pnpm CAS without invalidating the store index. */ + +import { createHash } from 'node:crypto' +import { + chmodSync, + closeSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + readSync, + readdirSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { availableParallelism, tmpdir } from 'node:os' +import { basename, dirname, join, relative, sep } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { Packr } from 'msgpackr' +import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs' +import { signMacOSSeedCode, verifyMacOSSeedCode } from './verify-macos-signature.mjs' + +const MACH_O_MAGICS = new Set([ + 'cafebabe', + 'cafebabf', + 'cefaedfe', + 'cffaedfe', + 'feedface', + 'feedfacf', + 'bebafeca', + 'bfbafeca', +]) +const CAS_PATH_PATTERN = /^([0-9a-f]{2})\/([0-9a-f]{126})(-exec)?$/u +const MAX_CONCURRENT_CODE_SIGNERS = 4 +const packr = new Packr({ moreTypes: true, useRecords: true }) + +interface PnpmStoreFileRecord { + checkedAt: number + digest: string + mode: number + size: number +} + +interface PnpmSideEffectsRecord { + readonly added?: Map +} + +interface PnpmPackageIndexRecord { + readonly algo?: string + readonly files?: Map + readonly sideEffects?: Map +} + +interface DecodedIndexRow { + readonly key: string + readonly value: PnpmPackageIndexRecord + changed: boolean +} + +interface CasFile { + readonly path: string + readonly digest: string + readonly executable: boolean +} + +interface FileReference { + readonly row: DecodedIndexRow + readonly record: PnpmStoreFileRecord +} + +interface SigningWork { + readonly file: CasFile + readonly references: readonly FileReference[] + readonly temporaryPath: string +} + +/** Summary of native code rewritten in one pnpm store. */ +export interface MacOSSeedStoreSigningResult { + readonly signedFiles: number + readonly prunedOrphans: number + readonly updatedIndexRows: number +} + +/** A signer used to make one writable Mach-O copy release-valid. */ +export type MacOSSeedCodeSigner = (path: string, identifier: string) => Promise + +/** A verifier used to check one Mach-O file after packaging transport. */ +export type MacOSSeedCodeVerifier = (path: string) => void + +/** Optional execution controls for seed-store code signing. */ +export interface MacOSSeedStoreSigningOptions { + readonly signer?: MacOSSeedCodeSigner + readonly concurrency?: number +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isStoreFileRecord(value: unknown): value is PnpmStoreFileRecord { + if (!isRecord(value)) return false + return typeof value.checkedAt === 'number' + && typeof value.digest === 'string' + && /^[0-9a-f]{128}$/u.test(value.digest) + && Number.isSafeInteger(value.mode) + && Number.isSafeInteger(value.size) +} + +function packageFileMaps(value: unknown, key: string): readonly Map[] { + if (!isRecord(value)) throw new Error(`desktop seed signing: invalid pnpm index record ${key}`) + const record = value as PnpmPackageIndexRecord + if (record.algo !== undefined && record.algo !== 'sha512') { + throw new Error(`desktop seed signing: unsupported pnpm index algorithm in ${key}`) + } + const maps: Map[] = [] + if (record.files !== undefined) { + if (!(record.files instanceof Map)) throw new Error(`desktop seed signing: invalid pnpm file map in ${key}`) + maps.push(record.files) + } + if (record.sideEffects !== undefined) { + if (!(record.sideEffects instanceof Map)) { + throw new Error(`desktop seed signing: invalid pnpm side-effects map in ${key}`) + } + for (const effect of record.sideEffects.values()) { + if (!isRecord(effect)) throw new Error(`desktop seed signing: invalid pnpm side effect in ${key}`) + if (effect.added === undefined) continue + if (!(effect.added instanceof Map)) { + throw new Error(`desktop seed signing: invalid pnpm side-effect file map in ${key}`) + } + maps.push(effect.added) + } + } + for (const files of maps) { + for (const file of files.values()) { + if (!isStoreFileRecord(file)) throw new Error(`desktop seed signing: invalid pnpm file record in ${key}`) + } + } + return maps +} + +function isExecutableMode(mode: number): boolean { + return (mode & 0o111) !== 0 +} + +function referenceKey(digest: string, executable: boolean): string { + return `${digest}:${executable ? 'exec' : 'nonexec'}` +} + +function isMachO(path: string): boolean { + const descriptor = openSync(path, 'r') + try { + const header = Buffer.alloc(4) + return readSync(descriptor, header, 0, header.length, 0) === header.length + && MACH_O_MAGICS.has(header.toString('hex')) + } finally { + closeSync(descriptor) + } +} + +function visitFiles(root: string): readonly string[] { + const files: string[] = [] + const visit = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isSymbolicLink()) { + throw new Error(`desktop seed signing: pnpm store contains a symbolic link: ${relative(root, path)}`) + } + if (entry.isDirectory()) visit(path) + else if (entry.isFile()) files.push(path) + else throw new Error(`desktop seed signing: unsupported pnpm store entry: ${relative(root, path)}`) + } + } + visit(root) + return files.sort((left, right) => left.localeCompare(right)) +} + +function versionRoots(storeRoot: string): readonly string[] { + return readdirSync(storeRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && /^v\d+$/u.test(entry.name)) + .map(entry => join(storeRoot, entry.name)) + .filter(root => existsSync(join(root, 'files'))) + .sort((left, right) => left.localeCompare(right)) +} + +function casFiles(versionRoot: string): readonly CasFile[] { + const filesRoot = join(versionRoot, 'files') + const result: CasFile[] = [] + for (const path of visitFiles(filesRoot)) { + if (!isMachO(path)) continue + const normalized = relative(filesRoot, path).split(sep).join('/') + const match = CAS_PATH_PATTERN.exec(normalized) + if (match === null) { + throw new Error(`desktop seed signing: Mach-O content has an unsupported pnpm CAS path: ${normalized}`) + } + result.push({ + path, + digest: `${match[1]}${match[2]}`, + executable: match[3] !== undefined, + }) + } + return result +} + +function readIndexRows(database: DatabaseSync): readonly DecodedIndexRow[] { + const rows: DecodedIndexRow[] = [] + for (const row of database.prepare('SELECT key, data FROM package_index').iterate() as Iterable<{ + key: string + data: Uint8Array + }>) { + rows.push({ key: row.key, value: packr.unpack(row.data) as PnpmPackageIndexRecord, changed: false }) + } + return rows +} + +function fileReferences(rows: readonly DecodedIndexRow[]): ReadonlyMap { + const references = new Map() + for (const row of rows) { + for (const files of packageFileMaps(row.value, row.key)) { + for (const record of files.values()) { + const key = referenceKey(record.digest, isExecutableMode(record.mode)) + const values = references.get(key) ?? [] + values.push({ row, record }) + references.set(key, values) + } + } + } + return references +} + +function writeCasFile(path: string, body: Buffer, mode: number): void { + mkdirSync(dirname(path), { recursive: true }) + try { + writeFileSync(path, body, { flag: 'wx', mode }) + } catch (error) { + if (!isRecord(error) || error.code !== 'EEXIST' || !readFileSync(path).equals(body)) throw error + } + chmodSync(path, mode) +} + +function signedCasPath(versionRoot: string, digest: string, executable: boolean): string { + return join( + versionRoot, + 'files', + digest.slice(0, 2), + `${digest.slice(2)}${executable ? '-exec' : ''}`, + ) +} + +async function runConcurrent( + values: readonly T[], + concurrency: number, + run: (value: T) => Promise, +): Promise { + let next = 0 + const failure: { error?: unknown; failed: boolean } = { failed: false } + const worker = async (): Promise => { + while (!failure.failed) { + const index = next + if (index >= values.length) return + next += 1 + try { + await run(values[index] as T) + } catch (error) { + if (!failure.failed) { + failure.failed = true + failure.error = error + } + } + } + } + const workers = Array.from( + { length: Math.min(concurrency, values.length) }, + async () => worker(), + ) + await Promise.all(workers) + if (failure.failed) throw failure.error +} + +async function rewriteVersionStore( + versionRoot: string, + appId: string, + signer: MacOSSeedCodeSigner, + concurrency: number, +): Promise { + const databasePath = join(versionRoot, 'index.db') + if (!existsSync(databasePath)) { + throw new Error(`desktop seed signing: pnpm store has no package index: ${databasePath}`) + } + const database = new DatabaseSync(databasePath) + const workRoot = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-signing-')) + const obsoleteFiles = new Set() + let prunedOrphans = 0 + let rows: readonly DecodedIndexRow[] = [] + try { + rows = readIndexRows(database) + const references = fileReferences(rows) + const signingWork: SigningWork[] = [] + for (const file of casFiles(versionRoot)) { + const body = readFileSync(file.path) + const actualDigest = createHash('sha512').update(body).digest('hex') + if (actualDigest !== file.digest) { + throw new Error(`desktop seed signing: pnpm CAS digest mismatch at ${file.path}`) + } + const fileReferences = references.get(referenceKey(file.digest, file.executable)) ?? [] + if (fileReferences.length === 0) { + obsoleteFiles.add(file.path) + prunedOrphans += 1 + continue + } + const temporary = join(workRoot, `${signingWork.length.toString().padStart(4, '0')}-${basename(file.path)}`) + copyFileSync(file.path, temporary) + chmodSync(temporary, 0o755) + signingWork.push({ file, references: fileReferences, temporaryPath: temporary }) + } + await runConcurrent(signingWork, concurrency, async (work) => { + await signer(work.temporaryPath, `${appId}.seed.${work.file.digest.slice(0, 32)}`) + }) + for (const work of signingWork) { + const signedBody = readFileSync(work.temporaryPath) + if (!isMachO(work.temporaryPath)) { + throw new Error(`desktop seed signing: signer produced non-Mach-O content for ${work.file.path}`) + } + const signedDigest = createHash('sha512').update(signedBody).digest('hex') + const mode = work.file.executable ? 0o755 : 0o644 + const destination = signedCasPath(versionRoot, signedDigest, work.file.executable) + writeCasFile(destination, signedBody, mode) + const checkedAt = Date.now() + for (const reference of work.references) { + reference.record.checkedAt = checkedAt + reference.record.digest = signedDigest + reference.record.mode = mode + reference.record.size = signedBody.length + reference.row.changed = true + } + if (destination !== work.file.path) obsoleteFiles.add(work.file.path) + } + const changedRows = rows.filter(row => row.changed) + database.exec('BEGIN IMMEDIATE') + let committed = false + try { + const statement = database.prepare('INSERT OR REPLACE INTO package_index (key, data) VALUES (?, ?)') + for (const row of changedRows) statement.run(row.key, packr.pack(row.value)) + database.exec('COMMIT') + committed = true + } finally { + if (!committed) database.exec('ROLLBACK') + } + for (const path of obsoleteFiles) unlinkSync(path) + database.exec('VACUUM') + return { signedFiles: signingWork.length, prunedOrphans, updatedIndexRows: changedRows.length } + } finally { + database.close() + rmSync(workRoot, { recursive: true, force: true }) + } +} + +/** + * Replace every Mach-O CAS object with a Developer ID signed object and update pnpm's SHA-512 index. + * A signer rejection leaves the original CAS objects and package index unchanged. + * @param storeRoot - Loose pnpm store prepared for the packaged seed. + * @param appId - Electron application ID used as the signing identifier prefix. + * @param expected - Company Developer ID identity and Team ID. + * @param options - Optional signer and worker bound used by focused tests. + * @returns Counts for release diagnostics after every signer completes and the index transaction commits. + */ +export async function signMacOSSeedStore( + storeRoot: string, + appId: string, + expected: MacOSSigningEnvironment, + options: MacOSSeedStoreSigningOptions = {}, +): Promise { + const roots = versionRoots(storeRoot) + if (roots.length === 0) throw new Error(`desktop seed signing: no pnpm store versions found in ${storeRoot}`) + const concurrency = options.concurrency ?? Math.min(MAX_CONCURRENT_CODE_SIGNERS, availableParallelism()) + if (!Number.isSafeInteger(concurrency) || concurrency < 1) { + throw new Error(`desktop seed signing: concurrency must be a positive integer; received ${String(concurrency)}`) + } + const signer = options.signer ?? (async (path, identifier) => { + await signMacOSSeedCode(path, identifier, expected) + }) + const results: MacOSSeedStoreSigningResult[] = [] + for (const root of roots) results.push(await rewriteVersionStore(root, appId, signer, concurrency)) + return results.reduce((total, current) => ({ + signedFiles: total.signedFiles + current.signedFiles, + prunedOrphans: total.prunedOrphans + current.prunedOrphans, + updatedIndexRows: total.updatedIndexRows + current.updatedIndexRows, + }), { signedFiles: 0, prunedOrphans: 0, updatedIndexRows: 0 }) +} + +/** + * Verify that every Mach-O CAS object has the expected Developer ID, timestamp, and hardened runtime. + * @param storeRoot - Loose or extracted pnpm store. + * @param expected - Company Developer ID identity and Team ID. + * @param verifier - Injectable signature verifier used by focused tests. + * @returns Number of verified Mach-O files. + */ +export function verifyMacOSSeedStore( + storeRoot: string, + expected: MacOSSigningEnvironment, + verifier: MacOSSeedCodeVerifier = (path) => { verifyMacOSSeedCode(path, expected) }, +): number { + let count = 0 + for (const root of versionRoots(storeRoot)) { + for (const file of casFiles(root)) { + verifier(file.path) + count += 1 + } + } + return count +} diff --git a/apps/desktop/scripts/notarize-macos-disk-images.d.mts b/apps/desktop/scripts/notarize-macos-disk-images.d.mts new file mode 100644 index 0000000000..78a2b5bdd4 --- /dev/null +++ b/apps/desktop/scripts/notarize-macos-disk-images.d.mts @@ -0,0 +1,23 @@ +import type { NotarizeOptions } from '@electron/notarize' +import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs' + +/** Completed electron-builder artifact needed for disk-image notarization. */ +export interface DesktopBuildArtifact { + readonly file: string +} + +/** + * Submit one generated DMG to Apple, staple its ticket, and verify Gatekeeper acceptance. + * @param artifact - Completed electron-builder artifact. + * @param env - Packaging environment. + * @param expected - Public release identity. + * @param submit - Notary submission implementation. + * @param verify - Disk-image qualification implementation. + */ +export function notarizeMacOSDiskImageArtifact( + artifact: DesktopBuildArtifact, + env: NodeJS.ProcessEnv, + expected: MacOSSigningEnvironment, + submit?: (options: NotarizeOptions) => Promise, + verify?: (path: string, expected: MacOSSigningEnvironment) => void, +): Promise diff --git a/apps/desktop/scripts/notarize-macos-disk-images.mjs b/apps/desktop/scripts/notarize-macos-disk-images.mjs new file mode 100644 index 0000000000..de0a230a04 --- /dev/null +++ b/apps/desktop/scripts/notarize-macos-disk-images.mjs @@ -0,0 +1,30 @@ +/** Notarize and qualify macOS disk images after electron-builder creates them. */ + +import { notarize } from '@electron/notarize' +import { rmSync } from 'node:fs' +import { resolveMacOSNotarizationEnvironment } from './desktop-release-environment.mjs' +import { verifyMacOSDiskImage } from './verify-macos-signature.mjs' + +/** + * Submit one generated DMG to Apple, staple its ticket, and verify Gatekeeper acceptance. + * @param {{ file: string }} artifact - Completed electron-builder artifact. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. + * @param {(options: object) => Promise} submit - Notary submission implementation. + * @param {(path: string, expected: object) => void} verify - Disk-image qualification implementation. + * @returns {Promise} + */ +export async function notarizeMacOSDiskImageArtifact( + artifact, + env, + expected, + submit = notarize, + verify = verifyMacOSDiskImage, +) { + if (!artifact.file.endsWith('.dmg')) return + rmSync(`${artifact.file}.blockmap`, { force: true }) + const credentials = resolveMacOSNotarizationEnvironment(env) + await submit({ appPath: artifact.file, ...credentials }) + verify(artifact.file, expected) + process.stdout.write(`desktop macOS notarization: verified disk image ${artifact.file}\n`) +} diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts new file mode 100644 index 0000000000..1f32123ec0 --- /dev/null +++ b/apps/desktop/scripts/package-target.ts @@ -0,0 +1,285 @@ +/** Build one release target with matching Electron, Node.js, and seed architecture. */ + +import { spawn } from 'node:child_process' +import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { parseArgs } from 'node:util' +import { join, resolve } from 'node:path' +import { + desktopBuildRecordFilename, + resolveDesktopAutoUpdateConfig, +} from './desktop-auto-update-environment.mjs' +import { desktopTargetBuildPaths } from './desktop-build-paths.mjs' + +const APP_ROOT = resolve(import.meta.dirname, '..') +const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..') +const WINDOWS_SIGNING_ENV_PREFIX = 'DSH_DESKTOP_WINDOWS_' +const WINDOWS_SIGNING_ENV_NAMES = [ + 'DSH_DESKTOP_WINDOWS_CER_FILE', + 'DSH_DESKTOP_WINDOWS_KEY_CONTAINER', + 'DSH_DESKTOP_WINDOWS_SIGNTOOL', + 'DSH_DESKTOP_WINDOWS_TOKEN_PIN', +] as const +const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([ + 'DOWNLOAD_TEST_COS_SECRET_ID', + 'DOWNLOAD_TEST_COS_SECRET_KEY', + 'DOWNLOAD_PROD_COS_SECRET_ID', + 'DOWNLOAD_PROD_COS_SECRET_KEY', +]) + +/** Fixed platform and architecture identifiers exposed by package scripts. */ +export type DesktopPackageTargetName = 'mac-arm64' | 'mac-x64' | 'win-x64' + +/** One supported release target and its electron-builder selectors. */ +export interface DesktopPackageTarget { + readonly name: DesktopPackageTargetName + readonly platform: 'darwin' | 'win32' + readonly arch: 'arm64' | 'x64' + readonly builderPlatform: '--mac' | '--win' + readonly builderArch: '--arm64' | '--x64' +} + +const TARGETS: Record = { + 'mac-arm64': { + name: 'mac-arm64', + platform: 'darwin', + arch: 'arm64', + builderPlatform: '--mac', + builderArch: '--arm64', + }, + 'mac-x64': { + name: 'mac-x64', + platform: 'darwin', + arch: 'x64', + builderPlatform: '--mac', + builderArch: '--x64', + }, + 'win-x64': { + name: 'win-x64', + platform: 'win32', + arch: 'x64', + builderPlatform: '--win', + builderArch: '--x64', + }, +} + +/** + * Remove Windows signing configuration from package preparation subprocesses. + * @param environment - Packaging command environment. + * @returns A copy without Windows signing fields. + */ +export function withoutWindowsSigningEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return Object.fromEntries(Object.entries(environment) + .filter(([name]) => !name.startsWith(WINDOWS_SIGNING_ENV_PREFIX))) +} + +/** + * Remove upload-only COS credentials from every packaging subprocess. + * @param environment - Packaging command environment. + * @returns A copy without Desktop upload credentials. + */ +export function withoutDesktopUploadCredentials(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return Object.fromEntries(Object.entries(environment) + .filter(([name]) => !DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES.has(name))) +} + +function isTargetName(value: string): value is DesktopPackageTargetName { + return Object.hasOwn(TARGETS, value) +} + +function packageVersion(path: string, label: string): string { + const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown } + if (typeof manifest.version !== 'string' || manifest.version === '') { + throw new Error(`desktop package: ${label} has no version`) + } + return manifest.version +} + +function writeReleaseRecord( + target: DesktopPackageTarget, + environment: NodeJS.ProcessEnv, + artifactsRoot: string, +): void { + const desktopVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package') + const dshVersion = packageVersion(join(REPOSITORY_ROOT, 'package.json'), 'dsh package') + if (desktopVersion !== dshVersion) { + throw new Error(`desktop package: desktop version ${desktopVersion} does not match dsh version ${dshVersion}`) + } + const update = resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch) + const recordPath = join(artifactsRoot, desktopBuildRecordFilename(target.name)) + const temporaryPath = `${recordPath}.tmp` + writeFileSync(temporaryPath, `${JSON.stringify({ + schemaVersion: 1, + target: target.name, + version: dshVersion, + environment: update.environment, + publicUrl: update.publicUrl, + }, null, 2)}\n`) + renameSync(temporaryPath, recordPath) +} + +/** + * Resolve a named release target and reject hosts that cannot execute its packaged runtime. + * @param name - One of the fixed Desktop release target names. + * @param hostPlatform - Build-host Node.js platform. + * @param hostArch - Build-host Node.js architecture. + * @returns The target selectors shared by runtime preparation and electron-builder. + */ +export function resolveDesktopPackageTarget( + name: string, + hostPlatform: NodeJS.Platform = process.platform, + hostArch: string = process.arch, +): DesktopPackageTarget { + if (!isTargetName(name)) { + throw new Error(`desktop package: unsupported target ${JSON.stringify(name)}; expected ${Object.keys(TARGETS).join(', ')}`) + } + const target = TARGETS[name] + if (target.platform === 'win32' && (hostPlatform !== 'win32' || hostArch !== 'x64')) { + throw new Error('desktop package: win-x64 requires a Windows x64 build host') + } + if (target.platform === 'darwin' && hostPlatform !== 'darwin') { + throw new Error(`desktop package: ${name} requires a macOS build host`) + } + if (name === 'mac-arm64' && hostArch !== 'arm64') { + throw new Error('desktop package: mac-arm64 requires an Apple Silicon build host') + } + if (name === 'mac-x64' && hostArch !== 'arm64' && hostArch !== 'x64') { + throw new Error('desktop package: mac-x64 requires an Intel Mac or Apple Silicon with Rosetta') + } + return target +} + +interface DesktopPackageInvocation { + readonly target: DesktopPackageTarget + readonly directory: boolean + readonly prepareOnly: boolean +} + +function hostTargetName(platform: NodeJS.Platform, arch: string): DesktopPackageTargetName { + const name = `${platform === 'darwin' ? 'mac' : platform === 'win32' ? 'win' : platform}-${arch}` + if (!isTargetName(name)) throw new Error(`desktop package: unsupported build host ${platform}-${arch}`) + return name +} + +/** + * Parse the fixed-target packaging command line. + * @param argv - Arguments after the script entry point. + * @param hostPlatform - Build-host Node.js platform. + * @param hostArch - Build-host Node.js architecture. + * @returns The validated target and whether to emit an unpacked directory. + */ +export function parseDesktopPackageInvocation( + argv: readonly string[], + hostPlatform: NodeJS.Platform = process.platform, + hostArch: string = process.arch, +): DesktopPackageInvocation { + const { values, positionals } = parseArgs({ + args: [...argv], + allowPositionals: true, + options: { + dir: { type: 'boolean', default: false }, + 'prepare-only': { type: 'boolean', default: false }, + }, + }) + if (positionals.length > 1) throw new Error('desktop package: expected at most one target') + const name = positionals[0] ?? hostTargetName(hostPlatform, hostArch) + return { + target: resolveDesktopPackageTarget(name, hostPlatform, hostArch), + directory: values.dir, + prepareOnly: values['prepare-only'], + } +} + +/** + * Build the electron-builder command arguments for one validated target. + * @param target - Supported release target. + * @param directory - Whether to stop at an unpacked application directory. + * @returns Arguments that keep publishing under the separate validated upload command. + */ +export function desktopElectronBuilderArguments( + target: DesktopPackageTarget, + directory: boolean, +): readonly string[] { + return [ + 'exec', + 'electron-builder', + '--config', + 'electron-builder.config.mjs', + target.builderPlatform, + target.builderArch, + '--publish', + 'never', + ...(directory ? ['--dir'] : []), + ] +} + +function runPnpm( + args: readonly string[], + env: NodeJS.ProcessEnv = process.env, + cwd: string = APP_ROOT, +): Promise { + const pnpmEntry = process.env.npm_execpath + if (pnpmEntry === undefined || pnpmEntry === '') { + throw new Error('desktop package: invoke this script through a pnpm package command') + } + return new Promise((resolvePromise, reject) => { + const child = spawn(process.execPath, [pnpmEntry, ...args], { + cwd, + env, + stdio: 'inherit', + }) + child.once('error', reject) + child.once('close', (code, signal) => { + if (code === 0) resolvePromise() + else reject(new Error(`desktop package: pnpm ${args.join(' ')} exited with ${String(code ?? signal)}`)) + }) + }) +} + +async function main(): Promise { + const invocation = parseDesktopPackageInvocation(process.argv.slice(2)) + const { target } = invocation + const buildPaths = desktopTargetBuildPaths(target.name) + const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name)) + if (!invocation.prepareOnly) { + rmSync(releaseRecordPath, { force: true }) + rmSync(`${releaseRecordPath}.tmp`, { force: true }) + } + const buildEnv = withoutWindowsSigningEnvironment(withoutDesktopUploadCredentials(process.env)) + const targetEnv: NodeJS.ProcessEnv = { + ...buildEnv, + DSH_DESKTOP_TARGET_PLATFORM: target.platform, + DSH_DESKTOP_TARGET_ARCH: target.arch, + } + const electronBuilderEnv = { ...targetEnv } + for (const name of WINDOWS_SIGNING_ENV_NAMES) { + if (process.env[name] !== undefined) electronBuilderEnv[name] = process.env[name] + } + await runPnpm(['run', 'build:official'], buildEnv, REPOSITORY_ROOT) + await runPnpm(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT) + await runPnpm([ + '--dir', + 'apps/desktop-host', + 'pack', + '--pack-destination', + buildPaths.packedDsh, + ], buildEnv, REPOSITORY_ROOT) + await runPnpm(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor], buildEnv, REPOSITORY_ROOT) + rmSync(buildPaths.packedLandlock, { recursive: true, force: true }) + mkdirSync(buildPaths.packedLandlock, { recursive: true }) + await runPnpm(['--dir', 'native/landlock-run', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT) + await runPnpm([ + '--dir', + 'native/landlock-run/packages/entry', + 'pack', + '--pack-destination', + buildPaths.packedLandlock, + ], buildEnv, REPOSITORY_ROOT) + await runPnpm(['run', 'prepare:runtime'], targetEnv) + await runPnpm(['run', 'prepare:packages'], targetEnv) + await runPnpm(['run', 'prepare:seed'], targetEnv) + if (invocation.prepareOnly) return + await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv) + if (!invocation.directory) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) +} + +if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main() diff --git a/apps/desktop/scripts/prepare-package-set.ts b/apps/desktop/scripts/prepare-package-set.ts new file mode 100644 index 0000000000..f67d767e81 --- /dev/null +++ b/apps/desktop/scripts/prepare-package-set.ts @@ -0,0 +1,172 @@ +/** Select and copy the local npm tarball closures that supply Desktop dsh and its private Host. */ + +import { createHash } from 'node:crypto' +import { + constants, + copyFileSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import { basename, join, resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { + DESKTOP_HOST_PACKAGE, + DESKTOP_HOST_RUNTIME_FILES, + DESKTOP_PACKAGES_DIR, + DESKTOP_PACKAGE_SET_FILE, + parseDesktopCorePackageSet, + type DesktopCorePackageRecord, +} from '../src/core-package-set.ts' +import { capture } from '../../../scripts/release/process.ts' +import { tarballFiles } from '../../../scripts/release/tarball.ts' +import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' + +const DSH_PACKAGE = '@deepseek-ai/dsh' +const ROOT_PACKAGES = [DSH_PACKAGE, DESKTOP_HOST_PACKAGE] as const +const APP_ROOT = resolve(import.meta.dirname, '..') +const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..') + +const REQUIRED_DEPENDENCY_SECTIONS = ['dependencies', 'peerDependencies'] as const +const OPTIONAL_DEPENDENCY_SECTION = 'optionalDependencies' + +/** Packed package information needed to form the local Desktop closure. */ +export interface PackedDesktopPackage { + readonly tarball: string + readonly manifest: Readonly> +} + +function dependencyNames(manifest: Readonly>, section: string): string[] { + const value = manifest[section] + if (value === undefined) return [] + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`desktop package set: ${String(manifest.name)} has invalid ${section}`) + } + return Object.keys(value).sort() +} + +/** + * Select the complete available first-party dependency closures rooted at dsh and its private Host. + * @param available - Packed packages indexed by package name. + * @returns Selected packages sorted by name. + */ +export function selectDesktopPackageClosure( + available: ReadonlyMap, +): PackedDesktopPackage[] { + const selected = new Map() + const visit = (name: string): void => { + if (selected.has(name)) return + const packed = available.get(name) + if (packed === undefined) throw new Error(`desktop package set: packed inputs omit required package ${name}`) + selected.set(name, packed) + for (const section of REQUIRED_DEPENDENCY_SECTIONS) { + for (const dependency of dependencyNames(packed.manifest, section)) { + if (available.has(dependency)) visit(dependency) + else if (dependency.startsWith('@deepseek-ai/')) { + throw new Error(`desktop package set: ${name} requires unpacked internal package ${dependency}`) + } + } + } + for (const dependency of dependencyNames(packed.manifest, OPTIONAL_DEPENDENCY_SECTION)) { + if (available.has(dependency)) visit(dependency) + } + } + for (const name of ROOT_PACKAGES) { + if (!available.has(name)) throw new Error(`desktop package set: packed inputs omit ${name}`) + visit(name) + } + return [...selected.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, packed]) => packed) +} + +function packedManifest(tarball: string): Record { + const value: unknown = JSON.parse(capture('tar', ['-xOzf', tarball, 'package/package.json'])) + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`desktop package set: ${tarball} has no package manifest`) + } + return value as Record +} + +function packedPackages(inputs: readonly string[]): Map { + const available = new Map() + for (const input of inputs) { + const tarballs = readdirSync(input).filter(file => file.endsWith('.tgz')).sort() + if (tarballs.length === 0) throw new Error(`desktop package set: ${input} contains no tarballs`) + for (const file of tarballs) { + const tarball = join(input, file) + const manifest = packedManifest(tarball) + const name = manifest.name + if (typeof name !== 'string' || name === '') throw new Error(`desktop package set: ${tarball} has no package name`) + if (available.has(name)) throw new Error(`desktop package set: duplicate packed package ${name}`) + available.set(name, { tarball, manifest }) + } + } + return available +} + +/** + * Require every private Host file used before the Desktop profile can pass its health check. + * @param files - Tarball paths rooted at `package/`. + * @returns Nothing. + */ +export function assertDesktopHostPackageFiles(files: readonly string[]): void { + const available = new Set(files) + const missing = DESKTOP_HOST_RUNTIME_FILES + .map(file => `package/${file}`) + .filter(file => !available.has(file)) + if (missing.length > 0) { + throw new Error(`desktop package set: ${DESKTOP_HOST_PACKAGE} tarball omits required file(s): ${missing.join(', ')}`) + } +} + +/** Prepare a package set from release tarball directories. */ +export function prepareDesktopPackageSet(inputs: readonly string[], output: string): void { + const selected = selectDesktopPackageClosure(packedPackages(inputs)) + const host = selected.find(packed => packed.manifest.name === DESKTOP_HOST_PACKAGE) + if (host === undefined) throw new Error(`desktop package set: selected closure omits ${DESKTOP_HOST_PACKAGE}`) + assertDesktopHostPackageFiles(tarballFiles(host.tarball)) + rmSync(output, { recursive: true, force: true }) + const packageDir = join(output, DESKTOP_PACKAGES_DIR) + mkdirSync(packageDir, { recursive: true }) + const records: DesktopCorePackageRecord[] = selected.map((packed) => { + const name = packed.manifest.name + const version = packed.manifest.version + if (typeof name !== 'string' || typeof version !== 'string') { + throw new Error(`desktop package set: ${packed.tarball} has no package identity`) + } + const file = basename(packed.tarball) + const destination = join(packageDir, file) + copyFileSync(packed.tarball, destination, constants.COPYFILE_EXCL) + const body = readFileSync(destination) + return { + name, + version, + file, + bytes: statSync(destination).size, + integrity: `sha512-${createHash('sha512').update(body).digest('base64')}`, + } + }) + const packageSet = parseDesktopCorePackageSet({ schemaVersion: 1, packages: records }) + writeFileSync(join(output, DESKTOP_PACKAGE_SET_FILE), `${JSON.stringify(packageSet, undefined, 2)}\n`, { mode: 0o600 }) +} + +function main(): void { + const buildPaths = resolveDesktopTargetBuildPaths() + const defaultInputs = [ + buildPaths.packedDsh, + buildPaths.packedVendor, + buildPaths.packedLandlock, + ] + const { values } = parseArgs({ + options: { from: { type: 'string', multiple: true }, out: { type: 'string' } }, + allowPositionals: false, + }) + const inputs = (values.from ?? defaultInputs).map(path => resolve(REPOSITORY_ROOT, path)) + const output = values.out === undefined ? buildPaths.packageSet : resolve(REPOSITORY_ROOT, values.out) + prepareDesktopPackageSet(inputs, output) + console.log(`desktop package set: prepared ${output}`) +} + +if (import.meta.main) main() diff --git a/apps/desktop/scripts/prepare-runtime.ts b/apps/desktop/scripts/prepare-runtime.ts new file mode 100644 index 0000000000..1b22293c23 --- /dev/null +++ b/apps/desktop/scripts/prepare-runtime.ts @@ -0,0 +1,107 @@ +/** Download and verify the upstream Node.js runtime and copy the pinned pnpm CLI. */ + +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { cpSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { chmod, readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { pipeline } from 'node:stream/promises' +import extractZip from 'extract-zip' +import { extract } from 'tar' +import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' + +const NODE_VERSION = '24.17.0' +const BUILD_PATHS = resolveDesktopTargetBuildPaths() +const RUNTIME_ROOT = BUILD_PATHS.runtime +const DOWNLOAD_ROOT = BUILD_PATHS.downloads + +type RuntimePlatform = 'darwin' | 'linux' | 'win' +type RuntimeArch = 'arm64' | 'x64' + +function target(): { platform: RuntimePlatform; arch: RuntimeArch } { + const rawPlatform = process.env.DSH_DESKTOP_TARGET_PLATFORM ?? process.env.npm_config_platform ?? process.platform + const rawArch = process.env.DSH_DESKTOP_TARGET_ARCH ?? process.env.npm_config_arch ?? process.arch + const platform = rawPlatform === 'win32' ? 'win' : rawPlatform + if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win') { + throw new Error(`desktop runtime: unsupported platform ${rawPlatform}`) + } + if (rawArch !== 'arm64' && rawArch !== 'x64') throw new Error(`desktop runtime: unsupported architecture ${rawArch}`) + return { platform, arch: rawArch } +} + +async function download(url: string, path: string): Promise { + const response = await fetch(url) + if (!response.ok) throw new Error(`desktop runtime: ${url} returned HTTP ${String(response.status)}`) + writeFileSync(path, new Uint8Array(await response.arrayBuffer()), { mode: 0o600 }) +} + +async function prepareNode(platform: RuntimePlatform, arch: RuntimeArch): Promise { + const extension = platform === 'win' ? 'zip' : 'tar.gz' + const folder = `node-v${NODE_VERSION}-${platform}-${arch}` + const archiveName = `${folder}.${extension}` + const releaseRoot = `https://nodejs.org/download/release/v${NODE_VERSION}` + const archive = join(DOWNLOAD_ROOT, archiveName) + const sums = join(DOWNLOAD_ROOT, `node-v${NODE_VERSION}-SHASUMS256.txt`) + if (!existsSync(archive)) await download(`${releaseRoot}/${archiveName}`, archive) + if (!existsSync(sums)) await download(`${releaseRoot}/SHASUMS256.txt`, sums) + const line = (await readFile(sums, 'utf8')).split(/\r?\n/u) + .find(candidate => candidate.endsWith(` ${archiveName}`)) + if (line === undefined) throw new Error(`desktop runtime: ${archiveName} is absent from Node.js SHASUMS256.txt`) + const expected = line.split(/\s+/u)[0] + const actual = createHash('sha256').update(await readFile(archive)).digest('hex') + if (actual !== expected) throw new Error(`desktop runtime: checksum mismatch for ${archiveName}`) + + const extraction = BUILD_PATHS.nodeExtract + rmSync(extraction, { recursive: true, force: true }) + mkdirSync(extraction, { recursive: true }) + if (platform === 'win') await extractZip(archive, { dir: extraction }) + else await extract({ cwd: extraction, file: archive }) + const source = join(extraction, folder, platform === 'win' ? 'node.exe' : 'bin/node') + const destinationRoot = join(RUNTIME_ROOT, 'node') + const destination = join(destinationRoot, platform === 'win' ? 'node.exe' : 'node') + rmSync(destinationRoot, { recursive: true, force: true }) + mkdirSync(destinationRoot, { recursive: true }) + // A fresh write prevents macOS from retaining invalid code-signature vnode state from a tar-extracted Mach-O clone. + await pipeline(createReadStream(source), createWriteStream(destination, { flags: 'wx' })) + if (platform !== 'win') await chmod(destination, 0o755) + const hostPlatform = process.platform === 'win32' ? 'win' : process.platform + const hostCanExecute = platform === hostPlatform + && (arch === process.arch || (platform === 'darwin' && arch === 'x64' && process.arch === 'arm64')) + if (hostCanExecute) { + const result = spawnSync(destination, ['--version'], { encoding: 'utf8' }) + if (result.error !== undefined || result.status !== 0 || result.stdout.trim() !== `v${NODE_VERSION}`) { + const detail = result.error?.message ?? result.signal ?? result.stderr.trim() + const outcome = detail === '' ? `exit ${String(result.status)}` : detail + throw new Error(`desktop runtime: prepared Node.js ${NODE_VERSION} failed executable verification: ${outcome}`) + } + } + rmSync(extraction, { recursive: true, force: true }) +} + +function preparePnpm(): string { + const require = createRequire(import.meta.url) + const manifestPath = require.resolve('pnpm') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { version?: unknown } + if (typeof manifest.version !== 'string') throw new Error('desktop runtime: pnpm manifest has no version') + const packageDir = dirname(manifestPath) + const destination = join(RUNTIME_ROOT, 'pnpm') + rmSync(destination, { recursive: true, force: true }) + cpSync(packageDir, destination, { recursive: true }) + return manifest.version +} + +async function main(): Promise { + const { platform, arch } = target() + mkdirSync(DOWNLOAD_ROOT, { recursive: true }) + mkdirSync(RUNTIME_ROOT, { recursive: true }) + await prepareNode(platform, arch) + const pnpmVersion = preparePnpm() + writeFileSync(join(RUNTIME_ROOT, 'versions.json'), `${JSON.stringify({ + schemaVersion: 1, + node: NODE_VERSION, + pnpm: pnpmVersion, + }, undefined, 2)}\n`) +} + +await main() diff --git a/apps/desktop/scripts/prepare-seed.ts b/apps/desktop/scripts/prepare-seed.ts new file mode 100644 index 0000000000..73da078ef2 --- /dev/null +++ b/apps/desktop/scripts/prepare-seed.ts @@ -0,0 +1,200 @@ +/** Build the release seed through the same embedded pnpm used on first launch. */ + +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, dirname, join, relative, resolve, sep } from 'node:path' +import { createSeedMetadata } from '../src/project-manager.ts' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import { parseDesktopRelease, type DesktopRelease } from '../src/release.ts' +import { + DESKTOP_HOST_PACKAGE, + DESKTOP_HOST_RUNTIME_FILES, + DESKTOP_PACKAGES_DIR, + DESKTOP_PACKAGE_SET_FILE, + readDesktopCorePackageSet, + verifyDesktopCoreLockfile, +} from '../src/core-package-set.ts' +import { + archivePnpmStore, + extractPnpmStoreArchives, + removePnpmProjectRegistrations, +} from '../src/seed-store.ts' +import { + resolveDesktopAppId, + resolveMacOSSigningEnvironment, +} from './desktop-release-environment.mjs' +import { + signMacOSSeedStore, + verifyMacOSSeedStore, +} from './macos-seed-store.ts' +import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' + +const APP_ROOT = resolve(import.meta.dirname, '..') +const BUILD_PATHS = resolveDesktopTargetBuildPaths() +const SEED_OUTPUT_ROOT = BUILD_PATHS.seed +const SEED_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-')) +const STORE_ROOT = join(SEED_ROOT, 'store') +const RUNTIME_ROOT = BUILD_PATHS.runtime +const PNPM_BUILD_STATE = BUILD_PATHS.seedPnpm +const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet +const NODE = join(RUNTIME_ROOT, 'node', process.platform === 'win32' ? 'node.exe' : 'node') +const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs') + +function manifestVersion(path: string, subject: string): string { + const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown } + if (typeof manifest.version !== 'string') throw new Error(`desktop seed: ${subject} has no version`) + return manifest.version +} + +function desktopRelease(): DesktopRelease { + const version = manifestVersion(join(APP_ROOT, 'package.json'), 'desktop package') + const dshVersion = manifestVersion(resolve(APP_ROOT, '..', '..', 'package.json'), 'root dsh package') + if (version !== dshVersion) { + throw new Error(`desktop seed: Electron ${version} must bind the same version of @deepseek-ai/dsh, found ${dshVersion}`) + } + const runtime = JSON.parse(readFileSync(join(RUNTIME_ROOT, 'versions.json'), 'utf8')) as Record + return parseDesktopRelease({ + schemaVersion: 1, + version, + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: runtime.node, + pnpmVersion: runtime.pnpm, + }) +} + +function runPnpm(args: readonly string[]): Promise { + return new Promise((resolvePromise, reject) => { + const [command, ...commandArgs] = args + if (command === undefined) throw new Error('desktop seed: pnpm command is required') + const config = join(PNPM_BUILD_STATE, 'config') + const userConfig = join(config, 'npmrc') + mkdirSync(config, { recursive: true }) + writeFileSync(userConfig, '') + const child = spawn(NODE, [ + PNPM, + '--config.registry=https://registry.npmjs.org/', + `--config.store-dir=${STORE_ROOT}`, + '--config.enable-global-virtual-store=false', + `--config.userconfig=${userConfig}`, + command, + ...commandArgs, + ], { + cwd: SEED_ROOT, + env: { + ...Object.fromEntries(Object.entries(process.env).filter(([name]) => ( + !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) + ))), + NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/', + NPM_CONFIG_STORE_DIR: STORE_ROOT, + NPM_CONFIG_USERCONFIG: userConfig, + PATH: `${dirname(NODE)}${delimiter}${process.env.PATH ?? ''}`, + XDG_CACHE_HOME: join(PNPM_BUILD_STATE, 'cache'), + XDG_CONFIG_HOME: config, + XDG_STATE_HOME: join(PNPM_BUILD_STATE, 'state'), + }, + stdio: 'inherit', + }) + child.once('error', reject) + child.once('close', (code, signal) => { + if (code === 0) resolvePromise() + else reject(new Error(`desktop seed: pnpm exited with ${String(code ?? signal)}`)) + }) + }) +} + +function inventory(root: string): readonly { path: string; bytes: number; sha256: string }[] { + const files: string[] = [] + const visit = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) visit(path) + else if (entry.isFile()) files.push(path) + else throw new Error(`desktop seed: unsupported filesystem entry ${relative(root, path)}`) + } + } + visit(root) + return files.sort().map((path) => { + const body = readFileSync(path) + return { + path: relative(root, path).split(sep).join('/'), + bytes: statSync(path).size, + sha256: createHash('sha256').update(body).digest('hex'), + } + }) +} + +async function verifyOfflineInstallation(release: DesktopRelease): Promise { + const installedModules = join(SEED_ROOT, 'node_modules') + try { + await runPnpm(['install', '--offline', '--frozen-lockfile', '--trust-lockfile']) + const hostRoot = join(installedModules, ...DESKTOP_HOST_PACKAGE.split('/')) + for (const file of DESKTOP_HOST_RUNTIME_FILES) { + if (!existsSync(join(hostRoot, file))) { + throw new Error(`desktop seed: local ${DESKTOP_HOST_PACKAGE}@${release.version} does not contain ${file}`) + } + } + } finally { + rmSync(installedModules, { recursive: true, force: true }) + } +} + +async function main(): Promise { + rmSync(SEED_OUTPUT_ROOT, { recursive: true, force: true }) + rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) + mkdirSync(STORE_ROOT, { recursive: true }) + try { + const release = desktopRelease() + copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(SEED_ROOT, DESKTOP_PACKAGE_SET_FILE)) + cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(SEED_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true }) + createSeedMetadata(SEED_ROOT, release) + await runPnpm(['install', '--lockfile-only']) + verifyDesktopCoreLockfile( + readFileSync(join(SEED_ROOT, 'pnpm-lock.yaml'), 'utf8'), + readDesktopCorePackageSet(SEED_ROOT, release.version), + ) + const installedModules = join(SEED_ROOT, 'node_modules') + await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile', '--ignore-scripts']) + rmSync(installedModules, { recursive: true, force: true }) + rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) + await verifyOfflineInstallation(release) + const targetPlatform = process.env.DSH_DESKTOP_TARGET_PLATFORM ?? process.platform + let signedMachOFiles: number | undefined + let macOSSigning: ReturnType | undefined + if (targetPlatform === 'darwin') { + macOSSigning = resolveMacOSSigningEnvironment(process.env) + const signing = await signMacOSSeedStore( + STORE_ROOT, + resolveDesktopAppId(process.env), + macOSSigning, + ) + signedMachOFiles = signing.signedFiles + process.stdout.write( + `desktop seed: signed ${signing.signedFiles} Mach-O files, updated ${signing.updatedIndexRows} pnpm index records, and pruned ${signing.prunedOrphans} native orphans\n`, + ) + await verifyOfflineInstallation(release) + } + removePnpmProjectRegistrations(STORE_ROOT) + archivePnpmStore(SEED_ROOT, STORE_ROOT) + if (macOSSigning !== undefined && signedMachOFiles !== undefined) { + const extractedStore = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-verification-')) + try { + extractPnpmStoreArchives(SEED_ROOT, extractedStore) + const verified = verifyMacOSSeedStore(extractedStore, macOSSigning) + if (verified !== signedMachOFiles) { + throw new Error(`desktop seed: archived store contains ${verified} signed Mach-O files; expected ${signedMachOFiles}`) + } + } finally { + rmSync(extractedStore, { recursive: true, force: true }) + } + } + const records = inventory(SEED_ROOT).filter(entry => entry.path !== 'integrity.json') + writeFileSync(join(SEED_ROOT, 'integrity.json'), `${JSON.stringify({ schemaVersion: 2, files: records }, undefined, 2)}\n`) + cpSync(SEED_ROOT, SEED_OUTPUT_ROOT, { recursive: true }) + } finally { + rmSync(SEED_ROOT, { recursive: true, force: true }) + } +} + +await main() diff --git a/apps/desktop/scripts/upload-target.ts b/apps/desktop/scripts/upload-target.ts new file mode 100644 index 0000000000..f2d436499d --- /dev/null +++ b/apps/desktop/scripts/upload-target.ts @@ -0,0 +1,83 @@ +/** Upload one validated Desktop release to its Tencent COS update directory. */ + +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3' +import type { DesktopPackageTargetName } from './package-target.ts' +import { + createDesktopUploadPlan, + type DesktopUploadArtifact, +} from './desktop-upload-plan.ts' + +const SUPPORTED_TARGETS = new Set(['mac-arm64', 'mac-x64', 'win-x64']) + +function targetName(value: string): DesktopPackageTargetName { + if (!SUPPORTED_TARGETS.has(value as DesktopPackageTargetName)) { + throw new Error(`desktop upload: unsupported target ${JSON.stringify(value)}; expected ${[...SUPPORTED_TARGETS].join(', ')}`) + } + return value as DesktopPackageTargetName +} + +function requiredEnvironmentValue(environment: NodeJS.ProcessEnv, name: string): string { + const value = environment[name]?.trim() + if (value === undefined || value === '') { + throw new Error(`desktop upload: ${name} must be set to a non-empty value`) + } + return value +} + +async function putArtifact( + client: S3Client, + bucket: string, + artifact: DesktopUploadArtifact, +): Promise { + const details = await stat(artifact.path) + const body = createReadStream(artifact.path) + try { + await client.send(new PutObjectCommand({ + Bucket: bucket, + Key: artifact.key, + Body: body, + ContentLength: details.size, + ContentType: artifact.contentType, + CacheControl: artifact.cacheControl, + })) + } + finally { + body.destroy() + } + process.stdout.write(`desktop upload: uploaded ${artifact.key}\n`) +} + +async function main(): Promise { + const { positionals } = parseArgs({ args: process.argv.slice(2), allowPositionals: true }) + const target = positionals[0] + if (target === undefined || positionals.length !== 1) { + throw new Error('desktop upload: expected exactly one target') + } + const plan = await createDesktopUploadPlan(targetName(target)) + const client = new S3Client({ + region: 'Auto', + endpoint: 'https://cos.ap-beijing.myqcloud.com', + credentials: { + accessKeyId: requiredEnvironmentValue(process.env, plan.secretIdEnvName), + secretAccessKey: requiredEnvironmentValue(process.env, plan.secretKeyEnvName), + }, + }) + process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`) + try { + for (const artifact of plan.artifacts) await putArtifact(client, plan.bucket, artifact) + } + finally { + client.destroy() + } +} + +if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) { + main().catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/apps/desktop/scripts/verify-macos-signature.d.mts b/apps/desktop/scripts/verify-macos-signature.d.mts new file mode 100644 index 0000000000..b408e1bd54 --- /dev/null +++ b/apps/desktop/scripts/verify-macos-signature.d.mts @@ -0,0 +1,73 @@ +import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs' + +/** + * Reject signature metadata that does not name the company release authority and team. + * @param details - Output from `codesign --display --verbose=4`. + * @param expected - Public release identity. + */ +export function assertMacOSSignatureDetails(details: string, expected: MacOSSigningEnvironment): void + +/** + * Require the signature properties Apple validates for executable seed content. + * @param details - Output from `codesign --display --verbose=4`. + * @param expected - Public release identity. + */ +export function assertMacOSSeedSignatureDetails(details: string, expected: MacOSSigningEnvironment): void + +/** + * Sign one Mach-O file embedded in the seed store. + * @param path - Writable standalone Mach-O file. + * @param identifier - Stable code-signing identifier derived from the release app ID and CAS digest. + * @param expected - Public release identity. + * @returns Resolves after codesign exits successfully. + */ +export function signMacOSSeedCode( + path: string, + identifier: string, + expected: MacOSSigningEnvironment, +): Promise + +/** + * Verify one Mach-O file embedded in the seed store. + * @param path - Mach-O file to inspect. + * @param expected - Public release identity. + */ +export function verifyMacOSSeedCode(path: string, expected: MacOSSigningEnvironment): void + +/** + * Verify the full application signature and its release owner. + * @param appPath - Path to the packaged `.app` directory. + * @param expected - Public release identity. + */ +export function verifyMacOSSignature(appPath: string, expected: MacOSSigningEnvironment): void + +/** + * Verify the release identity, stapled ticket, and Gatekeeper acceptance of one disk image. + * @param diskImagePath - Path to the packaged `.dmg` file. + * @param expected - Public release identity. + */ +export function verifyMacOSDiskImage( + diskImagePath: string, + expected: MacOSSigningEnvironment, +): void + +/** Electron-builder fields required to locate a signed macOS application. */ +export interface MacOSAfterSignContext { + readonly electronPlatformName: string + readonly appOutDir: string + readonly packager: { + readonly appInfo: { + readonly productFilename: string + } + } +} + +/** + * Verify the macOS application produced by electron-builder's signing phase. + * @param context - electron-builder hook context. + * @param expected - Public release identity. + */ +export function verifyMacOSSignatureAfterSign( + context: MacOSAfterSignContext, + expected: MacOSSigningEnvironment, +): void diff --git a/apps/desktop/scripts/verify-macos-signature.mjs b/apps/desktop/scripts/verify-macos-signature.mjs new file mode 100644 index 0000000000..48b254dedd --- /dev/null +++ b/apps/desktop/scripts/verify-macos-signature.mjs @@ -0,0 +1,186 @@ +/** Sign seed code and verify that packaged macOS artifacts carry the company release identity. */ + +import { spawn, spawnSync } from 'node:child_process' +import { resolve } from 'node:path' +import { resolveMacOSSigningEnvironment } from './desktop-release-environment.mjs' + +/** + * Reject signature metadata that does not name the company release authority and team. + * @param {string} details - Output from `codesign --display --verbose=4`. + * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. + * @returns {void} + */ +export function assertMacOSSignatureDetails(details, expected) { + const fields = new Set(details.split(/\r?\n/u).map(line => line.trim())) + const expectedAuthority = `Authority=Developer ID Application: ${expected.signingIdentity}` + const expectedTeam = `TeamIdentifier=${expected.teamId}` + const missing = [expectedAuthority, expectedTeam].filter(field => !fields.has(field)) + if (missing.length > 0) { + throw new Error(`desktop macOS signing: signature does not match the release identity; missing ${missing.join(', ')}`) + } +} + +/** + * Require the signature properties Apple validates for executable seed content. + * @param {string} details - Output from `codesign --display --verbose=4`. + * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. + * @returns {void} + */ +export function assertMacOSSeedSignatureDetails(details, expected) { + assertMacOSSignatureDetails(details, expected) + const fields = details.split(/\r?\n/u).map(line => line.trim()) + if (!fields.some(line => /^Timestamp=.+/u.test(line))) { + throw new Error('desktop macOS signing: seed signature has no secure timestamp') + } + if (!fields.some(line => /\bflags=0x[0-9a-f]+\(runtime\)(?:\s|$)/iu.test(line))) { + throw new Error('desktop macOS signing: seed signature does not enable hardened runtime') + } +} + +/** + * Execute one Apple release tool and return its diagnostic streams. + * @param {string} command - Absolute executable path. + * @param {readonly string[]} args - Tool arguments. + * @param {string} label - Stable diagnostic name. + * @returns {string} Combined stdout and stderr. + */ +function runAppleCommand(command, args, label) { + const result = spawnSync(command, args, { encoding: 'utf8' }) + if (result.error !== undefined) { + throw new Error(`desktop macOS signing: could not execute ${label}: ${result.error.message}`) + } + if (result.signal !== null) { + throw new Error(`desktop macOS signing: ${label} was terminated by ${result.signal}`) + } + if (result.status !== 0) { + const diagnostic = `${result.stdout}${result.stderr}`.trim() + throw new Error(`desktop macOS signing: ${label} exited with ${String(result.status)}${diagnostic === '' ? '' : `: ${diagnostic}`}`) + } + return `${result.stdout}${result.stderr}` +} + +/** + * Execute one Apple release tool without blocking other independent seed signers. + * @param {string} command - Absolute executable path. + * @param {readonly string[]} args - Tool arguments. + * @param {string} label - Stable diagnostic name. + * @returns {Promise} Combined stdout and stderr after process exit. + */ +function runAppleCommandAsync(command, args, label) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + let spawnError + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', chunk => { stdout += chunk }) + child.stderr.on('data', chunk => { stderr += chunk }) + child.once('error', error => { spawnError = error }) + child.once('close', (code, signal) => { + if (spawnError !== undefined) { + reject(new Error(`desktop macOS signing: could not execute ${label}: ${spawnError.message}`)) + return + } + if (signal !== null) { + reject(new Error(`desktop macOS signing: ${label} was terminated by ${signal}`)) + return + } + if (code !== 0) { + const diagnostic = `${stdout}${stderr}`.trim() + reject(new Error(`desktop macOS signing: ${label} exited with ${String(code)}${diagnostic === '' ? '' : `: ${diagnostic}`}`)) + return + } + resolvePromise(`${stdout}${stderr}`) + }) + }) +} + +/** + * Execute Apple's code-signing tool and return its diagnostic streams. + * @param {readonly string[]} args - Arguments passed to `/usr/bin/codesign`. + * @returns {string} Combined stdout and stderr. + */ +function runCodeSign(args) { + return runAppleCommand('/usr/bin/codesign', args, 'codesign') +} + +/** + * Sign one Mach-O file embedded in the seed store. + * @param {string} path - Writable standalone Mach-O file. + * @param {string} identifier - Stable code-signing identifier derived from the release app ID and CAS digest. + * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. + * @returns {Promise} Resolves after codesign exits successfully. + */ +export async function signMacOSSeedCode(path, identifier, expected) { + await runAppleCommandAsync('/usr/bin/codesign', [ + '--force', + '--sign', expected.signingIdentity, + '--identifier', identifier, + '--timestamp', + '--options', 'runtime', + path, + ], 'codesign') +} + +/** + * Verify one Mach-O file embedded in the seed store. + * @param {string} path - Mach-O file to inspect. + * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. + * @returns {void} + */ +export function verifyMacOSSeedCode(path, expected) { + runCodeSign(['--verify', '--strict', '--verbose=2', path]) + const details = runCodeSign(['--display', '--verbose=4', path]) + assertMacOSSeedSignatureDetails(details, expected) +} + +/** + * Verify the full application signature and its release owner. + * @param {string} appPath - Path to the packaged `.app` directory. + * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. + * @returns {void} + */ +export function verifyMacOSSignature(appPath, expected) { + runCodeSign(['--verify', '--deep', '--strict', '--verbose=2', appPath]) + const details = runCodeSign(['--display', '--verbose=4', appPath]) + assertMacOSSignatureDetails(details, expected) +} + +/** + * Verify the release identity, stapled ticket, and Gatekeeper acceptance of one disk image. + * @param {string} diskImagePath - Path to the packaged `.dmg` file. + * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. + * @returns {void} + */ +export function verifyMacOSDiskImage(diskImagePath, expected) { + runCodeSign(['--verify', '--strict', '--verbose=2', diskImagePath]) + const details = runCodeSign(['--display', '--verbose=4', diskImagePath]) + assertMacOSSignatureDetails(details, expected) + runAppleCommand('/usr/bin/xcrun', ['stapler', 'validate', diskImagePath], 'stapler validate') + runAppleCommand('/usr/sbin/spctl', ['--assess', '--type', 'install', '--verbose=4', diskImagePath], 'spctl') +} + +/** + * Verify the macOS application produced by electron-builder's signing phase. + * @param {{ electronPlatformName: string, appOutDir: string, packager: { appInfo: { productFilename: string } } }} context - electron-builder hook context. + * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. + * @returns {void} + */ +export function verifyMacOSSignatureAfterSign(context, expected) { + if (context.electronPlatformName !== 'darwin') return + const appPath = resolve(context.appOutDir, `${context.packager.appInfo.productFilename}.app`) + verifyMacOSSignature(appPath, expected) + process.stdout.write(`desktop macOS signing: verified Developer ID Application: ${expected.signingIdentity} (${expected.teamId})\n`) +} + +if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) { + const cliArgs = process.argv[2] === '--' ? process.argv.slice(3) : process.argv.slice(2) + const appPath = cliArgs[0] + if (appPath === undefined || cliArgs.length !== 1) { + throw new Error('usage: node scripts/verify-macos-signature.mjs ') + } + const expected = resolveMacOSSigningEnvironment(process.env) + verifyMacOSSignature(resolve(appPath), expected) + process.stdout.write(`desktop macOS signing: verified Developer ID Application: ${expected.signingIdentity} (${expected.teamId})\n`) +} diff --git a/apps/desktop/scripts/windows-sign.cmd b/apps/desktop/scripts/windows-sign.cmd new file mode 100644 index 0000000000..366f787f57 --- /dev/null +++ b/apps/desktop/scripts/windows-sign.cmd @@ -0,0 +1,17 @@ +@echo off +setlocal DisableDelayedExpansion +set "signTool=%DSH_DESKTOP_WINDOWS_SIGNTOOL%" +set "certificateFile=%DSH_DESKTOP_WINDOWS_CER_FILE%" +set "tokenPin=%DSH_DESKTOP_WINDOWS_TOKEN_PIN%" +set "keyContainer=%DSH_DESKTOP_WINDOWS_KEY_CONTAINER%" +set "targetFile=%DSH_DESKTOP_WINDOWS_SIGN_TARGET%" +set "appendSignature=" +if "%DSH_DESKTOP_WINDOWS_SIGN_APPEND%"=="1" set "appendSignature=/as" +set "DSH_DESKTOP_WINDOWS_SIGNTOOL=" +set "DSH_DESKTOP_WINDOWS_CER_FILE=" +set "DSH_DESKTOP_WINDOWS_TOKEN_PIN=" +set "DSH_DESKTOP_WINDOWS_KEY_CONTAINER=" +set "DSH_DESKTOP_WINDOWS_SIGN_TARGET=" +set "DSH_DESKTOP_WINDOWS_SIGN_APPEND=" +set "signTool=" & set "certificateFile=" & set "tokenPin=" & set "keyContainer=" & set "targetFile=" & set "appendSignature=" & "%signTool%" sign /v /fd sha256 /f "%certificateFile%" /kc "[{{%tokenPin%}}]=%keyContainer%" /csp "eToken Base Cryptographic Provider" %appendSignature% /tr http://timestamp.digicert.com /td sha256 "%targetFile%" +exit /b %errorlevel% diff --git a/apps/desktop/scripts/windows-sign.d.mts b/apps/desktop/scripts/windows-sign.d.mts new file mode 100644 index 0000000000..4532f32099 --- /dev/null +++ b/apps/desktop/scripts/windows-sign.d.mts @@ -0,0 +1,91 @@ +/** + * Build the minimal CMD environment for one Electron artifact. + * + * @param environment Parent environment. + * @param input Validated signing identity and task. + * @returns Scrubbed environment plus the fields consumed and cleared by the signing CMD. + */ +export function buildWindowsSigningEnvironment(environment: NodeJS.ProcessEnv, input: { + certificateFile: string + signTool: string + path: string + isNest: boolean + tokenPin: string + keyContainer: string +}): NodeJS.ProcessEnv + +/** + * Create the electron-builder hook for a hardware-backed Windows code-signing certificate. + * + * @param options Release signing configuration. + * @returns The signing hook. + */ +export function createWindowsTokenSigner(options: { + certificateFile?: string | undefined + signTool?: string | undefined + tokenPin?: string | undefined + keyContainer?: string | undefined + commandInterpreter?: string | undefined +}): ( + configuration: { + path: string + hash: string + isNest: boolean + }, +) => Promise + +/** + * Remove inherited credentials before starting a signing-related subprocess. + * + * @param environment Parent environment. + * @returns Environment without credential-shaped names. + */ +export function scrubWindowsSigningEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv + +/** + * Replace a SignTool failure with a diagnostic that cannot retain its command line. + * + * @param error SignTool process failure. + * @param path Artifact that failed signing. + * @param secrets Values that must not appear in the diagnostic. + * @returns Sanitized signing failure without the original error as its cause. + */ +export function createRedactedWindowsSigningError( + error: unknown, + path: string, + secrets: readonly string[], +): Error + +/** + * Clear a certificate-table entry that points beyond the end of a generated executable. + * + * @param path Executable to inspect. + * @returns Whether an invalid certificate-table entry was cleared. + */ +export function repairDanglingAuthenticodeDirectory(path: string): Promise + +/** + * Sign electron-builder's temporary NSIS executable before enterprise code integrity evaluates it. + * + * @param options Signing hook and injectable host values. + * @returns Nothing. + */ +export function installWindowsNsisBootstrapSigner(options: { + sign: (configuration: { + path: string + hash: string + isNest: boolean + }) => Promise + wineVmManager?: { + prototype: { + exec: ( + file: string, + args: string[], + options?: { env?: NodeJS.ProcessEnv }, + isLogOutIfDebug?: boolean, + ) => unknown + } + } + platform?: NodeJS.Platform + environment?: NodeJS.ProcessEnv +}): void diff --git a/apps/desktop/scripts/windows-sign.mjs b/apps/desktop/scripts/windows-sign.mjs new file mode 100644 index 0000000000..ac38a35601 --- /dev/null +++ b/apps/desktop/scripts/windows-sign.mjs @@ -0,0 +1,266 @@ +import { execFile } from 'node:child_process' +import { X509Certificate } from 'node:crypto' +import { readFileSync, realpathSync, statSync } from 'node:fs' +import { open } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import wineVmModule from 'app-builder-lib/out/vm/WineVm.js' + +const execFileAsync = promisify(execFile) +const { WineVmManager } = wineVmModule +const CODE_SIGNING_EKU = '1.3.6.1.5.5.7.3.3' +const NSIS_RUN_AS_INVOKER = 'RunAsInvoker' +const NSIS_BOOTSTRAP_PATCH = Symbol.for('@deepseek-ai/dsh-desktop/nsis-bootstrap-signing') +const WINDOWS_SIGN_SCRIPT = 'windows-sign.cmd' +const WINDOWS_SIGN_SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url)) +const PE_HEADER_READ_SIZE = 4096 +const PE32_MAGIC = 0x10B +const PE32_PLUS_MAGIC = 0x20B +const SENSITIVE_ENVIRONMENT_NAME = /(?:KEY|SECRET|TOKEN|PASSWORD)/iu +const WINDOWS_SIGNING_ENVIRONMENT_PREFIX = 'DSH_DESKTOP_WINDOWS_' + +/** + * Remove inherited credentials before starting a signing-related subprocess. + * + * @param {NodeJS.ProcessEnv} environment Parent environment. + * @returns {NodeJS.ProcessEnv} Environment without credential-shaped names. + */ +export function scrubWindowsSigningEnvironment(environment) { + return Object.fromEntries(Object.entries(environment) + .filter(([name]) => !SENSITIVE_ENVIRONMENT_NAME.test(name) + && !name.startsWith(WINDOWS_SIGNING_ENVIRONMENT_PREFIX))) +} + +function resolveTokenIdentity(input) { + const keyContainer = input.keyContainer?.trim() + if (!keyContainer) { + throw new Error('DSH_DESKTOP_WINDOWS_KEY_CONTAINER must contain the SafeNet private-key container name') + } + if (/["\r\n]/u.test(keyContainer)) { + throw new Error('DSH_DESKTOP_WINDOWS_KEY_CONTAINER cannot contain quotes or line breaks') + } + const tokenPin = input.tokenPin + if (tokenPin === undefined || tokenPin.length === 0) { + throw new Error('DSH_DESKTOP_WINDOWS_TOKEN_PIN must contain the SafeNet Token Password') + } + if (/[\]"\r\n]/u.test(tokenPin)) { + throw new Error('DSH_DESKTOP_WINDOWS_TOKEN_PIN cannot contain "]", quotes, or line breaks because the SafeNet key-container syntax uses them as delimiters') + } + return { keyContainer, tokenPin } +} + +function resolveCertificateFile(value) { + const candidate = value?.trim() + if (!candidate) { + throw new Error('DSH_DESKTOP_WINDOWS_CER_FILE must identify the public X.509 leaf certificate file') + } + let path + let certificate + try { + path = realpathSync(candidate) + certificate = new X509Certificate(readFileSync(path)) + } + catch { + throw new Error(`Windows code-signing certificate file is missing or invalid: ${candidate}`) + } + if (certificate.ca || !certificate.keyUsage?.includes(CODE_SIGNING_EKU)) { + throw new Error(`Windows code-signing certificate file must contain a non-CA Code Signing certificate: ${path}`) + } + return path +} + +function resolveSignTool(value) { + const candidate = value?.trim() + if (!candidate) { + throw new Error('DSH_DESKTOP_WINDOWS_SIGNTOOL must identify the SafeNet-compatible SignTool executable') + } + let path + try { + path = realpathSync(candidate) + if (!statSync(path).isFile() || !path.toLowerCase().endsWith('.exe')) throw new Error('not an executable file') + } + catch { + throw new Error(`DSH_DESKTOP_WINDOWS_SIGNTOOL is missing or is not an executable file: ${candidate}`) + } + return path +} + +function redactedSigningOutput(value, secrets) { + let output = Buffer.isBuffer(value) ? value.toString('utf8') : typeof value === 'string' ? value : '' + for (const secret of secrets) { + if (secret !== '') output = output.replaceAll(secret, '') + } + return output +} + +/** + * Replace a SignTool failure with a diagnostic that cannot retain its command line. + * + * @param {unknown} error SignTool process failure. + * @param {string} path Artifact that failed signing. + * @param {readonly string[]} secrets Values that must not appear in the diagnostic. + * @returns {Error} Sanitized signing failure without the original error as its cause. + */ +export function createRedactedWindowsSigningError(error, path, secrets) { + const record = error !== null && typeof error === 'object' ? error : undefined + const code = record !== undefined && 'code' in record + && (typeof record.code === 'number' || typeof record.code === 'string') + ? ` (exit ${String(record.code)})` + : '' + const stderr = record !== undefined && 'stderr' in record + ? redactedSigningOutput(record.stderr, secrets).trim() + : '' + return new Error(`Windows release signing failed for ${path}${code}${stderr === '' ? '' : `: ${stderr}`}`) +} + +/** + * Build the minimal CMD environment for one Electron artifact. + * + * @param {NodeJS.ProcessEnv} environment Parent environment. + * @param {{ certificateFile: string, signTool: string, path: string, isNest: boolean, tokenPin: string, keyContainer: string }} input Validated signing identity and task. + * @returns {NodeJS.ProcessEnv} Scrubbed environment plus fields consumed and cleared by the signing CMD. + */ +export function buildWindowsSigningEnvironment(environment, input) { + return { + ...scrubWindowsSigningEnvironment(environment), + DSH_DESKTOP_WINDOWS_SIGNTOOL: input.signTool, + DSH_DESKTOP_WINDOWS_CER_FILE: input.certificateFile, + DSH_DESKTOP_WINDOWS_TOKEN_PIN: input.tokenPin, + DSH_DESKTOP_WINDOWS_KEY_CONTAINER: input.keyContainer, + DSH_DESKTOP_WINDOWS_SIGN_TARGET: input.path, + DSH_DESKTOP_WINDOWS_SIGN_APPEND: input.isNest ? '1' : '', + } +} + +/** + * Create the electron-builder hook for a SafeNet-backed Windows code-signing certificate. + * + * @param {{ certificateFile?: string, signTool?: string, tokenPin?: string, keyContainer?: string, commandInterpreter?: string }} options Release signing configuration. + * @returns {(configuration: { path: string, hash: string, isNest: boolean }) => Promise} The signing hook. + */ +export function createWindowsTokenSigner(options) { + const certificateFile = resolveCertificateFile(options.certificateFile) + const signTool = resolveSignTool(options.signTool) + const { keyContainer, tokenPin } = resolveTokenIdentity(options) + const commandInterpreter = options.commandInterpreter + ?? process.env.ComSpec + ?? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe') + return async (configuration) => { + if (configuration.hash !== 'sha256') { + throw new Error(`Windows release signing requires SHA-256, received ${configuration.hash}`) + } + await repairDanglingAuthenticodeDirectory(configuration.path) + const secrets = [tokenPin] + let result + try { + result = await execFileAsync(commandInterpreter, [ + '/d', + '/v:off', + '/c', + WINDOWS_SIGN_SCRIPT, + ], { + cwd: WINDOWS_SIGN_SCRIPT_DIRECTORY, + env: buildWindowsSigningEnvironment(process.env, { + certificateFile, + signTool, + path: configuration.path, + isNest: configuration.isNest, + tokenPin, + keyContainer, + }), + windowsHide: false, + }) + } + catch (error) { + throw createRedactedWindowsSigningError(error, configuration.path, secrets) + } + const stdout = redactedSigningOutput(result.stdout, secrets) + const stderr = redactedSigningOutput(result.stderr, secrets) + if (stdout !== '') process.stdout.write(stdout) + if (stderr !== '') process.stderr.write(stderr) + } +} + +/** + * Clear a certificate-table entry that points beyond the end of a generated executable. + * + * @param {string} path Executable to inspect. + * @returns {Promise} Whether an invalid certificate-table entry was cleared. + */ +export async function repairDanglingAuthenticodeDirectory(path) { + const file = await open(path, 'r+') + try { + const { size } = await file.stat() + const header = Buffer.alloc(Math.min(PE_HEADER_READ_SIZE, size)) + await file.read(header, 0, header.length, 0) + const directoryOffset = findDanglingAuthenticodeDirectory(header, size) + if (directoryOffset === undefined) return false + await file.write(Buffer.alloc(8), 0, 8, directoryOffset) + return true + } + finally { + await file.close() + } +} + +/** + * Locate an Authenticode certificate-table entry whose declared bytes are outside the file. + * + * @param {Buffer} header Initial executable bytes. + * @param {number} fileSize Complete file size. + * @returns {number | undefined} File offset of the invalid data-directory entry. + */ +function findDanglingAuthenticodeDirectory(header, fileSize) { + if (header.length < 64 || header.toString('ascii', 0, 2) !== 'MZ') return undefined + const peOffset = header.readUInt32LE(60) + const optionalHeaderOffset = peOffset + 24 + if (optionalHeaderOffset + 2 > header.length + || header.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') return undefined + const magic = header.readUInt16LE(optionalHeaderOffset) + const dataDirectoryOffset = magic === PE32_MAGIC + ? optionalHeaderOffset + 96 + : magic === PE32_PLUS_MAGIC + ? optionalHeaderOffset + 112 + : undefined + if (dataDirectoryOffset === undefined) return undefined + const certificateDirectoryOffset = dataDirectoryOffset + (4 * 8) + if (certificateDirectoryOffset + 8 > header.length) return undefined + const certificateOffset = header.readUInt32LE(certificateDirectoryOffset) + const certificateSize = header.readUInt32LE(certificateDirectoryOffset + 4) + if (certificateOffset === 0 && certificateSize === 0) return undefined + return certificateOffset > 0 + && certificateSize > 0 + && certificateOffset + certificateSize <= fileSize + ? undefined + : certificateDirectoryOffset +} + +/** + * Sign electron-builder's temporary NSIS executable before enterprise code integrity evaluates it. + * + * @param {{ sign: (configuration: { path: string, hash: string, isNest: boolean }) => Promise, wineVmManager?: typeof WineVmManager, platform?: NodeJS.Platform, environment?: NodeJS.ProcessEnv }} options Signing hook and injectable host values. + * @returns {void} + */ +export function installWindowsNsisBootstrapSigner(options) { + if ((options.platform ?? process.platform) !== 'win32') return + const prototype = (options.wineVmManager ?? WineVmManager).prototype + if (prototype[NSIS_BOOTSTRAP_PATCH] === true) return + const originalExec = prototype.exec + prototype.exec = async function (file, args, execOptions, isLogOutIfDebug) { + const isNsisBootstrap = file.toLowerCase().endsWith('.exe') + && execOptions?.env?.__COMPAT_LAYER === NSIS_RUN_AS_INVOKER + if (!isNsisBootstrap) { + return originalExec.call(this, file, args, execOptions, isLogOutIfDebug) + } + await options.sign({ path: file, hash: 'sha256', isNest: false }) + return originalExec.call(this, file, args, { + ...execOptions, + env: scrubWindowsSigningEnvironment({ + ...(options.environment ?? process.env), + ...execOptions.env, + }), + }, isLogOutIfDebug) + } + Object.defineProperty(prototype, NSIS_BOOTSTRAP_PATCH, { value: true }) +} diff --git a/apps/desktop/src/core-package-set.ts b/apps/desktop/src/core-package-set.ts new file mode 100644 index 0000000000..ff93bd5605 --- /dev/null +++ b/apps/desktop/src/core-package-set.ts @@ -0,0 +1,183 @@ +/** Signed local npm package set that supplies the Desktop-owned dsh runtime and private Host. */ + +import { createHash } from 'node:crypto' +import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +/** Descriptor copied beside every Desktop profile's local core tarballs. */ +export const DESKTOP_PACKAGE_SET_FILE = 'desktop-packages.json' + +/** Profile-relative directory containing immutable core npm tarballs. */ +export const DESKTOP_PACKAGES_DIR = 'desktop-packages' + +/** Private package installed beside dsh to boot the Desktop Host process. */ +export const DESKTOP_HOST_PACKAGE = '@deepseek-ai/dsh-desktop-host' + +/** Package-relative Desktop Host files required before a profile can boot. */ +export const DESKTOP_HOST_RUNTIME_FILES = [ + 'lib/index.js', + 'config/desktop.cordis.patch.yml', +] as const + +/** One immutable npm tarball in the Desktop core package set. */ +export interface DesktopCorePackageRecord { + readonly name: string + readonly version: string + readonly file: string + readonly bytes: number + readonly integrity: string +} + +/** Complete union of the first-party package closures rooted at dsh and its private Desktop Host. */ +export interface DesktopCorePackageSet { + readonly schemaVersion: 1 + readonly packages: readonly DesktopCorePackageRecord[] +} + +const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u +const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.+_-]*$/u +const FILE_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*\.tgz$/u +const INTEGRITY_PATTERN = /^sha512-[A-Za-z0-9+/]+={0,2}$/u +const DSH_PACKAGE = '@deepseek-ai/dsh' +const RELEASE_PACKAGES = [DSH_PACKAGE, DESKTOP_HOST_PACKAGE] as const + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Validate package-set data read from a release artifact or active profile. + * @param value - Parsed descriptor JSON. + * @param expectedReleaseVersion - Required dsh and Desktop Host version when validating one release. + * @returns The normalized package set in deterministic name order. + */ +export function parseDesktopCorePackageSet( + value: unknown, + expectedReleaseVersion?: string, +): DesktopCorePackageSet { + if (!isRecord(value) || value.schemaVersion !== 1 || !Array.isArray(value.packages)) { + throw new Error('desktop package set: invalid descriptor') + } + const packages = value.packages.map((entry): DesktopCorePackageRecord => { + if (!isRecord(entry) || typeof entry.name !== 'string' || !PACKAGE_NAME_PATTERN.test(entry.name) + || typeof entry.version !== 'string' || !VERSION_PATTERN.test(entry.version) + || typeof entry.file !== 'string' || !FILE_PATTERN.test(entry.file) + || typeof entry.bytes !== 'number' || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0 + || typeof entry.integrity !== 'string' || !INTEGRITY_PATTERN.test(entry.integrity)) { + throw new Error('desktop package set: invalid package record') + } + return { + name: entry.name, + version: entry.version, + file: entry.file, + bytes: entry.bytes, + integrity: entry.integrity, + } + }) + const names = new Set(packages.map(entry => entry.name)) + const files = new Set(packages.map(entry => entry.file)) + if (names.size !== packages.length || files.size !== packages.length) { + throw new Error('desktop package set: duplicate package name or filename') + } + const sorted = [...packages].sort((left, right) => left.name.localeCompare(right.name)) + if (JSON.stringify(sorted) !== JSON.stringify(packages)) { + throw new Error('desktop package set: packages must be sorted by name') + } + for (const name of RELEASE_PACKAGES) { + const entry = packages.find(candidate => candidate.name === name) + if (entry === undefined) throw new Error(`desktop package set: missing ${name}`) + if (expectedReleaseVersion !== undefined && entry.version !== expectedReleaseVersion) { + throw new Error(`desktop package set: ${name}@${entry.version} does not match Desktop ${expectedReleaseVersion}`) + } + } + return { schemaVersion: 1, packages } +} + +/** Read and structurally validate one profile's core package descriptor. */ +export function readDesktopCorePackageSet(projectDir: string, expectedReleaseVersion?: string): DesktopCorePackageSet { + const path = join(projectDir, DESKTOP_PACKAGE_SET_FILE) + let value: unknown + try { + value = JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + throw new Error(`desktop package set: failed to read ${path}: ${String(error)}`) + } + return parseDesktopCorePackageSet(value, expectedReleaseVersion) +} + +/** Return the project-relative `file:` spec for one local core tarball. */ +export function desktopCorePackageSpec(record: DesktopCorePackageRecord): string { + return `file:./${DESKTOP_PACKAGES_DIR}/${record.file}` +} + +/** Return the exact pnpm override map that keeps every core package off registries. */ +export function desktopCorePackageOverrides(packageSet: DesktopCorePackageSet): Record { + return Object.fromEntries(packageSet.packages.map(record => [record.name, desktopCorePackageSpec(record)])) +} + +/** Return the local direct dependency spec for the dsh package. */ +export function desktopDshPackageSpec(packageSet: DesktopCorePackageSet): string { + const record = packageSet.packages.find(entry => entry.name === DSH_PACKAGE) + if (record === undefined) throw new Error(`desktop package set: missing ${DSH_PACKAGE}`) + return desktopCorePackageSpec(record) +} + +/** + * Verify every local tarball and reject extra package files before pnpm executes them. + * @param projectDir - Seed or profile directory containing the package set. + * @param expectedReleaseVersion - Exact dsh and Desktop Host version bound to Electron. + * @returns The verified package set. + */ +export function verifyDesktopCorePackageSet( + projectDir: string, + expectedReleaseVersion: string, +): DesktopCorePackageSet { + const packageSet = readDesktopCorePackageSet(projectDir, expectedReleaseVersion) + const packageDir = join(projectDir, DESKTOP_PACKAGES_DIR) + const expectedFiles = packageSet.packages.map(entry => entry.file).sort() + let actualFiles: string[] + try { + actualFiles = readdirSync(packageDir).sort() + } catch (error) { + throw new Error(`desktop package set: failed to read ${packageDir}: ${String(error)}`) + } + if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { + throw new Error('desktop package set: package directory does not match its descriptor') + } + for (const record of packageSet.packages) { + const path = join(packageDir, record.file) + if (!existsSync(path) || !lstatSync(path).isFile()) { + throw new Error(`desktop package set: ${record.file} is not a regular file`) + } + const body = readFileSync(path) + const integrity = `sha512-${createHash('sha512').update(body).digest('base64')}` + if (body.byteLength !== record.bytes || integrity !== record.integrity) { + throw new Error(`desktop package set: integrity check failed for ${record.file}`) + } + } + return packageSet +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&') +} + +/** + * Reject a lockfile that resolved any packaged core name through a registry version. + * @param lockfile - Generated pnpm lockfile text. + * @param packageSet - Verified local core package set. + */ +export function verifyDesktopCoreLockfile( + lockfile: string, + packageSet: DesktopCorePackageSet, +): void { + for (const record of packageSet.packages) { + const registryResolution = new RegExp( + `^ ['"]?${escapeRegExp(record.name)}@${escapeRegExp(record.version)}(?:\\([^\\r\\n]*\\))?['"]?:`, + 'mu', + ) + if (registryResolution.test(lockfile)) { + throw new Error(`desktop package set: lockfile resolved ${record.name}@${record.version} outside the local package set`) + } + } +} diff --git a/apps/desktop/src/host-process.ts b/apps/desktop/src/host-process.ts new file mode 100644 index 0000000000..ed18d0c7d6 --- /dev/null +++ b/apps/desktop/src/host-process.ts @@ -0,0 +1,413 @@ +/** Upstream-Node child lifecycle and streaming custom-protocol carrier. */ + +import { spawn, type ChildProcess } from 'node:child_process' +import { once } from 'node:events' +import { join } from 'node:path' +import { Readable, Writable } from 'node:stream' +import { + DESKTOP_HOST_PROTOCOL_VERSION, + DESKTOP_PIPE_CHUNK_BYTES, + DESKTOP_REQUEST_PIPE_FD, + DESKTOP_RESPONSE_PIPE_FD, + DesktopHostResponseDecoder, + encodeDesktopRequestCancel, + encodeDesktopRequestData, + encodeDesktopRequestEnd, + encodeDesktopRequestStart, + type DesktopHostCommand, + type DesktopHostEvent, + type DesktopHostResponseFrame, +} from './host-protocol.ts' + +interface PendingResponse { + readonly resolve: (response: Response) => void + readonly reject: (error: Error) => void + responseStarted: boolean + uploadOpen: boolean + controller?: ReadableStreamDefaultController + requestReader?: ReadableStreamDefaultReader + removeAbort?: () => void +} + +function isDesktopHostEvent(message: unknown): message is DesktopHostEvent { + if (typeof message !== 'object' || message === null || !('type' in message)) return false + const candidate = message as Record + switch (candidate.type) { + case 'ready': + return candidate.protocolVersion === DESKTOP_HOST_PROTOCOL_VERSION && typeof candidate.dshVersion === 'string' + case 'fatal': + return typeof candidate.message === 'string' + default: + return false + } +} + +function errorOf(reason: unknown, fallback: string): Error { + return reason instanceof Error ? reason : new Error(fallback) +} + +async function exitsWithin(exit: Promise, milliseconds: number): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((resolve) => { + timer = setTimeout(() => { resolve(false) }, milliseconds) + timer.unref() + }) + try { + return await Promise.race([exit.then(() => true), timeout]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + +/** Ready facts reported by one installed dsh child. */ +export interface DesktopHostReady { + readonly protocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION + readonly dshVersion: string +} + +/** One dsh backend running under the bundled upstream Node.js executable. */ +export class DesktopHostProcess { + private child: ChildProcess | undefined + private requestPipe: Writable | undefined + private responsePipe: Readable | undefined + private readonly responseDecoder = new DesktopHostResponseDecoder() + private requestWriteTail: Promise = Promise.resolve() + private nextStreamId = 1 + private readonly pending = new Map() + private readonly blockedResponses = new Set() + private readyResolve!: (ready: DesktopHostReady) => void + private readyReject!: (error: Error) => void + private readonly readyPromise = new Promise((resolve, reject) => { + this.readyResolve = resolve + this.readyReject = reject + }) + private exitPromise: Promise | undefined + private stderr = '' + + /** + * @param node - absolute bundled upstream Node.js executable. + * @param projectDir - active or staged desktop npm project. + * @param inspectPort - optional loopback inspector port for workspace development. + */ + constructor( + private readonly node: string, + private readonly projectDir: string, + private readonly inspectPort?: number, + ) {} + + /** Start the child once and resolve only after its complete composition is active. */ + async start(): Promise { + if (this.child !== undefined) return this.readyPromise + const entry = join(this.projectDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js') + const child = spawn(this.node, [ + ...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]), + entry, + this.projectDir, + ...(this.inspectPort === undefined ? [] : ['--allow-linked-profile']), + ], { + cwd: this.projectDir, + env: Object.fromEntries(Object.entries(process.env).filter(([name]) => ( + name !== 'NODE_OPTIONS' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) + ))), + stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe', 'ipc'], + }) + const requestPipe = child.stdio[DESKTOP_REQUEST_PIPE_FD] + const responsePipe = child.stdio[DESKTOP_RESPONSE_PIPE_FD] + if (!(requestPipe instanceof Writable) || !(responsePipe instanceof Readable)) { + child.kill('SIGTERM') + throw new Error('dsh desktop host did not expose the required byte pipes and IPC channel') + } + this.child = child + this.requestPipe = requestPipe + this.responsePipe = responsePipe + child.stderr?.setEncoding('utf8') + child.stderr?.on('data', (chunk: string) => { this.stderr += chunk }) + child.stdout?.pipe(process.stdout) + responsePipe.on('data', (chunk: Buffer) => { this.acceptResponseBytes(chunk) }) + responsePipe.once('end', () => { + try { + this.responseDecoder.finish() + this.fail(new Error('dsh desktop host response pipe ended')) + } catch (error) { + this.fail(errorOf(error, 'dsh desktop host response pipe failed')) + } + }) + requestPipe.once('error', (error) => { this.fail(error) }) + responsePipe.once('error', (error) => { this.fail(error) }) + child.on('message', (message: unknown) => { + if (!isDesktopHostEvent(message)) { + this.fail(new Error('dsh desktop host sent an invalid IPC event')) + child.kill('SIGTERM') + return + } + this.handleMessage(message) + }) + child.once('error', (error) => { this.fail(error) }) + this.exitPromise = new Promise((resolve) => { + child.once('exit', (code) => { + const suffix = this.stderr.trim() === '' ? '' : `: ${this.stderr.trim()}` + if (code !== 0 && code !== null) this.fail(new Error(`dsh desktop host exited with ${String(code)}${suffix}`)) + else this.fail(new Error(`dsh desktop host stopped${suffix}`)) + resolve() + }) + }) + return this.readyPromise + } + + /** Forward one `dsh-app://app` request to the child without buffering its body. */ + async fetch(request: Request): Promise { + await this.start() + const child = this.child + if (child === undefined || !child.connected || this.requestPipe === undefined) { + throw new Error('dsh desktop host is unavailable') + } + if (this.nextStreamId > 0xffff_ffff) throw new Error('dsh desktop host exhausted its request stream ids') + const streamId = this.nextStreamId++ + const method = request.method.toUpperCase() + const hasBody = method !== 'GET' && method !== 'HEAD' && request.body !== null + return new Promise((resolve, reject) => { + const pending: PendingResponse = { + resolve, + reject, + responseStarted: false, + uploadOpen: hasBody, + } + const abort = (): void => { + if (!this.pending.has(streamId)) return + const error = errorOf(request.signal.reason, 'request aborted') + pending.uploadOpen = false + void pending.requestReader?.cancel(error).catch(() => undefined) + this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((pipeError: unknown) => { + this.fail(errorOf(pipeError, 'dsh desktop request pipe failed')) + }) + if (pending.controller === undefined) pending.reject(error) + else pending.controller.error(error) + this.finishPending(streamId, false) + } + if (request.signal.aborted) { + reject(errorOf(request.signal.reason, 'request aborted')) + return + } + request.signal.addEventListener('abort', abort, { once: true }) + pending.removeAbort = () => { request.signal.removeEventListener('abort', abort) } + this.pending.set(streamId, pending) + this.pumpRequest(streamId, request, hasBody).catch((error: unknown) => { + this.failPending(streamId, errorOf(error, 'dsh desktop request upload failed')) + }) + }) + } + + /** Request graceful teardown, then wait for child exit. */ + async stop(): Promise { + const child = this.child + if (child === undefined) return + this.blockedResponses.clear() + this.responsePipe?.resume() + if (child.connected) this.send({ type: 'shutdown' }) + // Closing the parent-owned write end releases the Host's pending Windows pipe read. + this.requestPipe?.destroy() + const exited = this.exitPromise ?? Promise.resolve() + if (!await exitsWithin(exited, 10_000)) child.kill('SIGTERM') + if (!await exitsWithin(exited, 5_000)) { + child.kill('SIGKILL') + if (!await exitsWithin(exited, 5_000)) { + throw new Error('dsh desktop host did not exit after SIGKILL') + } + } + this.child = undefined + this.requestPipe = undefined + this.responsePipe = undefined + } + + private async pumpRequest(streamId: number, request: Request, hasBody: boolean): Promise { + await this.enqueueRequestFrame(encodeDesktopRequestStart(streamId, { + url: request.url, + method: request.method.toUpperCase(), + headers: [...request.headers.entries()], + hasBody, + })) + if (!hasBody) return + const body = request.body + if (body === null) throw new Error('dsh desktop request body disappeared before upload') + const reader = body.getReader() + const pending = this.pending.get(streamId) + if (pending === undefined) { + await reader.cancel() + return + } + pending.requestReader = reader + try { + for (;;) { + const next = await reader.read() + if (next.done) break + for (let offset = 0; offset < next.value.byteLength; offset += DESKTOP_PIPE_CHUNK_BYTES) { + if (!this.pending.has(streamId)) return + await this.enqueueRequestFrame(encodeDesktopRequestData( + streamId, + next.value.subarray(offset, offset + DESKTOP_PIPE_CHUNK_BYTES), + )) + } + } + const live = this.pending.get(streamId) + if (live !== undefined) { + await this.enqueueRequestFrame(encodeDesktopRequestEnd(streamId)) + live.uploadOpen = false + } + } finally { + reader.releaseLock() + const live = this.pending.get(streamId) + if (live?.requestReader === reader) delete live.requestReader + } + } + + private enqueueRequestFrame(frame: Buffer): Promise { + const write = this.requestWriteTail.then(async () => { + const pipe = this.requestPipe + if (pipe === undefined || pipe.destroyed) throw new Error('dsh desktop host request pipe is unavailable') + if (!pipe.write(frame)) await once(pipe, 'drain') + }) + this.requestWriteTail = write.catch(() => undefined) + return write + } + + private send(message: DesktopHostCommand): void { + const child = this.child + if (child === undefined || !child.connected) throw new Error('dsh desktop host IPC is unavailable') + child.send(message) + } + + private acceptResponseBytes(chunk: Buffer): void { + try { + for (const frame of this.responseDecoder.push(chunk)) this.handleResponseFrame(frame) + } catch (error) { + this.fail(errorOf(error, 'dsh desktop host response pipe failed')) + this.child?.kill('SIGTERM') + } + } + + private handleResponseFrame(frame: DesktopHostResponseFrame): void { + const pending = this.pending.get(frame.streamId) + if (pending === undefined) { + if (frame.streamId >= this.nextStreamId) { + throw new Error(`dsh desktop host responded for unknown stream ${String(frame.streamId)}`) + } + return + } + switch (frame.type) { + case 'start': { + if (pending.responseStarted) throw new Error(`dsh desktop host started stream ${String(frame.streamId)} twice`) + pending.responseStarted = true + let body: ReadableStream | null = null + if (frame.hasBody) { + body = new ReadableStream({ + start: (controller) => { pending.controller = controller }, + pull: () => { + this.blockedResponses.delete(frame.streamId) + this.resumeResponsePipe() + }, + cancel: (reason) => { this.cancelResponse(frame.streamId, reason) }, + }) + } + pending.resolve(new Response(body, { + status: frame.status, + headers: new Headers(frame.headers.map(([name, value]) => [name, value] as [string, string])), + })) + return + } + case 'data': { + const controller = pending.controller + if (!pending.responseStarted || controller === undefined) { + throw new Error(`dsh desktop host sent body data before a body start for stream ${String(frame.streamId)}`) + } + controller.enqueue(frame.data) + if ((controller.desiredSize ?? 0) <= 0) { + this.blockedResponses.add(frame.streamId) + this.responsePipe?.pause() + } + return + } + case 'end': + if (!pending.responseStarted) { + throw new Error(`dsh desktop host ended stream ${String(frame.streamId)} before its response start`) + } + pending.controller?.close() + this.finishPending(frame.streamId, true) + return + case 'error': + this.failPending(frame.streamId, new Error(frame.message)) + return + default: + frame satisfies never + } + } + + private cancelResponse(streamId: number, reason: unknown): void { + const pending = this.pending.get(streamId) + if (pending === undefined) return + pending.uploadOpen = false + void pending.requestReader?.cancel(reason).catch(() => undefined) + this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((error: unknown) => { + this.fail(errorOf(error, 'dsh desktop request pipe failed')) + }) + this.finishPending(streamId, false) + } + + private failPending(streamId: number, error: Error): void { + const pending = this.pending.get(streamId) + if (pending === undefined) return + pending.uploadOpen = false + void pending.requestReader?.cancel(error).catch(() => undefined) + if (pending.controller === undefined) pending.reject(error) + else pending.controller.error(error) + this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((pipeError: unknown) => { + this.fail(errorOf(pipeError, 'dsh desktop request pipe failed')) + }) + this.finishPending(streamId, false) + } + + private finishPending(streamId: number, cancelOpenUpload: boolean): void { + const pending = this.pending.get(streamId) + if (pending === undefined) return + if (cancelOpenUpload && pending.uploadOpen) { + pending.uploadOpen = false + void pending.requestReader?.cancel().catch(() => undefined) + this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((error: unknown) => { + this.fail(errorOf(error, 'dsh desktop request pipe failed')) + }) + } + pending.removeAbort?.() + this.pending.delete(streamId) + this.blockedResponses.delete(streamId) + this.resumeResponsePipe() + } + + private resumeResponsePipe(): void { + if (this.blockedResponses.size === 0) this.responsePipe?.resume() + } + + private handleMessage(message: DesktopHostEvent): void { + switch (message.type) { + case 'ready': + this.readyResolve(message) + return + case 'fatal': + this.fail(new Error(message.message)) + return + default: + message satisfies never + } + } + + private fail(error: Error): void { + this.readyReject(error) + for (const pending of this.pending.values()) { + void pending.requestReader?.cancel(error).catch(() => undefined) + if (pending.controller === undefined) pending.reject(error) + else pending.controller.error(error) + pending.removeAbort?.() + } + this.pending.clear() + this.blockedResponses.clear() + this.responsePipe?.resume() + } +} diff --git a/apps/desktop/src/host-protocol.ts b/apps/desktop/src/host-protocol.ts new file mode 100644 index 0000000000..d057d6d16d --- /dev/null +++ b/apps/desktop/src/host-protocol.ts @@ -0,0 +1,215 @@ +/** Versioned control messages and framed byte transport for the Desktop Host child. */ + +/** Protocol version implemented by the Electron shell and installed dsh Host. */ +export const DESKTOP_HOST_PROTOCOL_VERSION = 3 as const + +/** Child descriptor Electron writes request frames to. */ +export const DESKTOP_REQUEST_PIPE_FD = 3 + +/** Child descriptor Electron reads response frames from. */ +export const DESKTOP_RESPONSE_PIPE_FD = 4 + +/** Child descriptor reserved for Node's lifecycle IPC channel. */ +export const DESKTOP_CONTROL_IPC_FD = 5 + +/** Maximum raw body bytes carried by one data frame. */ +export const DESKTOP_PIPE_CHUNK_BYTES = 64 * 1024 + +const FRAME_MAGIC = 0x44534833 +const FRAME_HEADER_BYTES = 13 +const MAX_CONTROL_PAYLOAD_BYTES = 1024 * 1024 + +const REQUEST_FRAME_START = 1 +const REQUEST_FRAME_DATA = 2 +const REQUEST_FRAME_END = 3 +const REQUEST_FRAME_CANCEL = 4 +type RequestFrameType = typeof REQUEST_FRAME_START | typeof REQUEST_FRAME_DATA + | typeof REQUEST_FRAME_END | typeof REQUEST_FRAME_CANCEL + +const RESPONSE_FRAME_START = 1 +const RESPONSE_FRAME_DATA = 2 +const RESPONSE_FRAME_END = 3 +const RESPONSE_FRAME_ERROR = 4 + +/** Metadata that precedes one optional request body on the request pipe. */ +export interface DesktopHostRequestStart { + readonly url: string + readonly method: string + readonly headers: readonly [string, string][] + readonly hasBody: boolean +} + +/** Commands retained on Node IPC because they do not carry Fetch payload bytes. */ +export type DesktopHostCommand = { + readonly type: 'shutdown' +} + +/** Lifecycle events retained on Node IPC. */ +export type DesktopHostEvent = { + readonly type: 'ready' + readonly protocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION + readonly dshVersion: string +} | { + readonly type: 'fatal' + readonly message: string +} + +/** One decoded response-pipe frame. */ +export type DesktopHostResponseFrame = { + readonly type: 'start' + readonly streamId: number + readonly status: number + readonly headers: readonly [string, string][] + readonly hasBody: boolean +} | { + readonly type: 'data' + readonly streamId: number + readonly data: Buffer +} | { + readonly type: 'end' + readonly streamId: number +} | { + readonly type: 'error' + readonly streamId: number + readonly message: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isHeaders(value: unknown): value is readonly [string, string][] { + return Array.isArray(value) && value.every(header => Array.isArray(header) && header.length === 2 + && typeof header[0] === 'string' && typeof header[1] === 'string') +} + +function assertStreamId(streamId: number): void { + if (!Number.isInteger(streamId) || streamId < 1 || streamId > 0xffff_ffff) { + throw new Error(`dsh desktop: invalid pipe stream id ${String(streamId)}`) + } +} + +function encodeFrame(type: RequestFrameType, streamId: number, payload: Buffer): Buffer { + assertStreamId(streamId) + const limit = type === REQUEST_FRAME_DATA ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES + if (payload.byteLength > limit) { + throw new Error(`dsh desktop: request pipe frame exceeds the ${String(limit)}-byte limit`) + } + const frame = Buffer.allocUnsafe(FRAME_HEADER_BYTES + payload.byteLength) + frame.writeUInt32BE(FRAME_MAGIC, 0) + frame.writeUInt8(type, 4) + frame.writeUInt32BE(streamId, 5) + frame.writeUInt32BE(payload.byteLength, 9) + payload.copy(frame, FRAME_HEADER_BYTES) + return frame +} + +function encodeJsonFrame(type: RequestFrameType, streamId: number, value: unknown): Buffer { + return encodeFrame(type, streamId, Buffer.from(JSON.stringify(value), 'utf8')) +} + +/** Encode the metadata opening one request stream. */ +export function encodeDesktopRequestStart(streamId: number, request: DesktopHostRequestStart): Buffer { + return encodeJsonFrame(REQUEST_FRAME_START, streamId, request) +} + +/** Encode one bounded raw request-body chunk. */ +export function encodeDesktopRequestData(streamId: number, data: Uint8Array): Buffer { + return encodeFrame(REQUEST_FRAME_DATA, streamId, Buffer.from(data)) +} + +/** Encode normal request-body completion. */ +export function encodeDesktopRequestEnd(streamId: number): Buffer { + return encodeFrame(REQUEST_FRAME_END, streamId, Buffer.alloc(0)) +} + +/** Encode cancellation of one request and its response. */ +export function encodeDesktopRequestCancel(streamId: number): Buffer { + return encodeFrame(REQUEST_FRAME_CANCEL, streamId, Buffer.alloc(0)) +} + +/** Incrementally decode validated response frames from the Host byte pipe. */ +export class DesktopHostResponseDecoder { + private buffer: Buffer = Buffer.alloc(0) + + /** + * Append bytes and return every complete response frame. + * @param chunk - next bytes read from the Host response pipe. + * @returns complete frames in pipe order. + */ + push(chunk: Buffer): DesktopHostResponseFrame[] { + this.buffer = this.buffer.byteLength === 0 ? chunk : Buffer.concat([this.buffer, chunk]) + const frames: DesktopHostResponseFrame[] = [] + for (;;) { + const frame = this.next() + if (frame === undefined) return frames + frames.push(frame) + } + } + + /** Reject EOF that splits a frame. */ + finish(): void { + if (this.buffer.byteLength !== 0) throw new Error('dsh desktop: Host response pipe ended inside a frame') + } + + private next(): DesktopHostResponseFrame | undefined { + if (this.buffer.byteLength < FRAME_HEADER_BYTES) return undefined + if (this.buffer.readUInt32BE(0) !== FRAME_MAGIC) throw new Error('dsh desktop: invalid Host response frame marker') + const rawType = this.buffer.readUInt8(4) + const streamId = this.buffer.readUInt32BE(5) + const payloadLength = this.buffer.readUInt32BE(9) + assertStreamId(streamId) + const limit = rawType === RESPONSE_FRAME_DATA ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES + if (payloadLength > limit) { + throw new Error(`dsh desktop: Host response frame exceeds the ${String(limit)}-byte limit`) + } + const frameLength = FRAME_HEADER_BYTES + payloadLength + if (this.buffer.byteLength < frameLength) return undefined + const payload = this.buffer.subarray(FRAME_HEADER_BYTES, frameLength) + this.buffer = this.buffer.subarray(frameLength) + switch (rawType) { + case RESPONSE_FRAME_START: + return this.parseStart(streamId, payload) + case RESPONSE_FRAME_DATA: + return { type: 'data', streamId, data: payload } + case RESPONSE_FRAME_END: + if (payloadLength !== 0) throw new Error('dsh desktop: Host response end frame carried a payload') + return { type: 'end', streamId } + case RESPONSE_FRAME_ERROR: + return this.parseError(streamId, payload) + default: + throw new Error(`dsh desktop: unknown Host response frame type ${String(rawType)}`) + } + } + + private parseStart(streamId: number, payload: Buffer): DesktopHostResponseFrame { + const value = this.parseJson(payload, 'start') + if (!isRecord(value) || !Number.isInteger(value.status) || (value.status as number) < 100 + || (value.status as number) > 599 || !isHeaders(value.headers) || typeof value.hasBody !== 'boolean') { + throw new Error('dsh desktop: invalid Host response start payload') + } + return { + type: 'start', + streamId, + status: value.status as number, + headers: value.headers, + hasBody: value.hasBody, + } + } + + private parseError(streamId: number, payload: Buffer): DesktopHostResponseFrame { + const value = this.parseJson(payload, 'error') + if (!isRecord(value) || typeof value.message !== 'string') { + throw new Error('dsh desktop: invalid Host response error payload') + } + return { type: 'error', streamId, message: value.message } + } + + private parseJson(payload: Buffer, subject: string): unknown { + try { + return JSON.parse(payload.toString('utf8')) as unknown + } catch (error) { + throw new Error(`dsh desktop: Host response ${subject} payload is not JSON: ${error instanceof Error ? error.message : String(error)}`) + } + } +} diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts new file mode 100644 index 0000000000..4fb946a2a5 --- /dev/null +++ b/apps/desktop/src/ipc.ts @@ -0,0 +1,40 @@ +/** Typed preload operations exposed only by the Electron shell. */ + +import type { DesktopPluginRecord } from './project-manager.ts' +import type { DesktopLocale } from './locale.ts' + +/** IPC channel names kept private to the desktop application bundle. */ +export const DESKTOP_IPC = { + localeGet: 'dsh-desktop:locale-get', + pluginsList: 'dsh-desktop:plugins-list', + pluginsAdd: 'dsh-desktop:plugins-add', + pluginsRemove: 'dsh-desktop:plugins-remove', + pluginsUpdate: 'dsh-desktop:plugins-update', + updatesCheck: 'dsh-desktop:updates-check', + updatesInstall: 'dsh-desktop:updates-install', + updatesState: 'dsh-desktop:updates-state', +} as const + +/** Desktop release update state rendered by desktop-owned UI. */ +export interface DesktopUpdateState { + readonly phase: 'idle' | 'checking' | 'available' | 'installing' | 'ready' | 'error' + readonly version?: string + readonly message?: string +} + +/** Narrow bridge exposed through context isolation. */ +export interface DshDesktopApi { + readonly protocolVersion: 1 + locale(): Promise + readonly plugins: { + list(): Promise + add(spec: string): Promise + remove(name: string): Promise + update(name: string, version: string): Promise + } + readonly updates: { + check(): Promise + install(): Promise + subscribe(listener: (state: DesktopUpdateState) => void): () => void + } +} diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts new file mode 100644 index 0000000000..b666cfd559 --- /dev/null +++ b/apps/desktop/src/locale.ts @@ -0,0 +1,97 @@ +/** Typed English and Chinese copy owned by the Electron shell. */ + +export const en = { + application: 'Application', + startupFailed: 'DeepSeek Harness could not start', + pluginsMenu: 'Desktop Plugins…', + pluginsMenuPackagedOnly: 'Desktop Plugins… (available in packaged applications)', + checkUpdatesMenu: 'Check for Updates…', + updateCheckFailedTitle: 'Update Check Failed', + unknownError: 'Unknown error', + updateCheckTitle: 'Check for Updates', + updateCurrent: 'You already have the latest version.', + updateTitle: 'DeepSeek Harness Update', + updateAvailable: 'An update is available', + updateDetail: 'DeepSeek Harness {version}\n\nThis release includes its matching dsh version. The application will restart after installation.', + installAndRestart: 'Install and Restart', + later: 'Later', + updateFailedTitle: 'Update Failed', + pluginManagerTitle: 'Desktop Plugins', + pluginWindowTitle: 'DeepSeek Harness — Desktop Plugins', + pluginManagerDescription: 'Plugins are installed only in the Desktop node_modules and are managed by the bundled pnpm.', + refresh: 'Refresh', + npmPackage: 'npm package', + install: 'Install', + installed: 'Installed', + noPlugins: 'No Desktop plugins are installed.', + remove: 'Remove', + update: 'Update', + targetVersion: 'Enter the target version for {name}', + removing: 'Removing {name}…', + updating: 'Updating {name}…', + installing: 'Installing {spec}…', + operationComplete: 'Done. The Desktop backend has restarted.', + refreshing: 'Refreshing…', + refreshed: 'Plugin list refreshed.', + loadingPlugins: 'Reading Desktop plugins…', +} as const + +/** Every Desktop locale supplies the complete English key set. */ +export type DesktopMessages = { readonly [Key in keyof typeof en]: string } + +export const zh = { + application: '应用', + startupFailed: 'DeepSeek Harness 无法启动', + pluginsMenu: '桌面插件…', + pluginsMenuPackagedOnly: '桌面插件…(打包应用中可用)', + checkUpdatesMenu: '检查更新…', + updateCheckFailedTitle: '更新检查失败', + unknownError: '未知错误', + updateCheckTitle: '检查更新', + updateCurrent: '当前已是最新版本。', + updateTitle: 'DeepSeek Harness 更新', + updateAvailable: '发现可用更新', + updateDetail: 'DeepSeek Harness {version}\n\n新版本绑定匹配的 dsh,安装后将重新启动。', + installAndRestart: '安装并重启', + later: '稍后', + updateFailedTitle: '更新失败', + pluginManagerTitle: '桌面插件', + pluginWindowTitle: 'DeepSeek Harness — 桌面插件', + pluginManagerDescription: '插件只安装到桌面端自己的 node_modules,并由内置 pnpm 管理。', + refresh: '刷新', + npmPackage: 'npm 包', + install: '安装', + installed: '已安装', + noPlugins: '还没有安装桌面插件。', + remove: '移除', + update: '更新', + targetVersion: '输入 {name} 的目标版本', + removing: '正在移除 {name}…', + updating: '正在更新 {name}…', + installing: '正在安装 {spec}…', + operationComplete: '操作完成,桌面后端已重新启动。', + refreshing: '正在刷新…', + refreshed: '插件列表已刷新。', + loadingPlugins: '正在读取桌面插件…', +} as const satisfies DesktopMessages + +/** Locale payload exposed to the Desktop-owned renderer. */ +export interface DesktopLocale { + readonly id: 'en' | 'zh-CN' + readonly messages: DesktopMessages +} + +/** Resolve Electron's locale to one shipped Desktop dictionary. */ +export function resolveDesktopLocale(locale: string): DesktopLocale { + return locale.toLowerCase().startsWith('zh') + ? { id: 'zh-CN', messages: zh } + : { id: 'en', messages: en } +} + +/** Replace named placeholders in one locale-owned message. */ +export function formatDesktopMessage( + message: string, + values: Readonly>, +): string { + return message.replaceAll(/\{([^{}]+)\}/gu, (placeholder, key: string) => values[key] ?? placeholder) +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts new file mode 100644 index 0000000000..8bc9fdcefd --- /dev/null +++ b/apps/desktop/src/main.ts @@ -0,0 +1,397 @@ +/** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */ + +import { readFile, writeFile } from 'node:fs/promises' +import { extname, join, normalize, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + app, + BrowserWindow, + dialog, + ipcMain, + Menu, + protocol, + type IpcMainInvokeEvent, +} from 'electron' +import { resolveDesktopPaths } from './paths.ts' +import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts' +import { DesktopHostProcess } from './host-process.ts' +import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts' +import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts' +import { claimDesktopSingleInstance } from './single-instance.ts' +import { DesktopUpdateCoordinator } from './update-coordinator.ts' + +const SCHEME = 'dsh-app' +let focusPrimaryWindow = (): void => {} + +function errorOf(reason: unknown, fallback: string): Error { + return reason instanceof Error ? reason : new Error(fallback) +} + +protocol.registerSchemesAsPrivileged([{ + scheme: SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: false, + stream: true, + codeCache: true, + }, +}]) + +const MIME: Readonly> = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.svg': 'image/svg+xml', +} + +interface RuntimeResources { + readonly node: string + readonly pnpm: string + readonly seed: string +} + +function runtimeResources(): RuntimeResources { + const development = !app.isPackaged + const node = (development ? process.env.DSH_DESKTOP_NODE_BINARY : undefined) + ?? join(process.resourcesPath, 'runtime', 'node', process.platform === 'win32' ? 'node.exe' : 'node') + const pnpm = (development ? process.env.DSH_DESKTOP_PNPM_ENTRY : undefined) + ?? join(process.resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs') + const seed = (development ? process.env.DSH_DESKTOP_SEED_DIR : undefined) ?? join(process.resourcesPath, 'seed') + return { node, pnpm, seed } +} + +function developmentProject(): string | undefined { + const configured = process.env.DSH_DESKTOP_DEV_PROJECT_DIR + if (configured === undefined || configured === '') return undefined + if (app.isPackaged) throw new Error('dsh desktop: development project override is unavailable in packaged applications') + return resolve(configured) +} + +function developmentHostInspectPort(enabled: boolean): number | undefined { + const configured = process.env.DSH_DESKTOP_HOST_INSPECT_PORT + if (!enabled || configured === undefined || configured === '') return undefined + const port = Number(configured) + if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { + throw new Error('dsh desktop: DSH_DESKTOP_HOST_INSPECT_PORT must be an integer from 1 through 65535') + } + return port +} + +function createWindow(preload: string): BrowserWindow { + const window = new BrowserWindow({ + width: 1280, + height: 840, + minWidth: 880, + minHeight: 600, + show: false, + webPreferences: { + preload, + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + }, + }) + window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + window.webContents.on('will-navigate', (event, url) => { + if (new URL(url).protocol !== `${SCHEME}:`) event.preventDefault() + }) + return window +} + +function assertDesktopSender(event: IpcMainInvokeEvent, hostnames: readonly string[]): void { + const senderFrame = event.senderFrame + if (senderFrame === null) throw new Error('dsh desktop: rejected IPC without a sender frame') + const url = new URL(senderFrame.url) + if (url.protocol !== `${SCHEME}:` || !hostnames.includes(url.hostname)) { + throw new Error('dsh desktop: rejected IPC from an unowned renderer') + } +} + +async function serveShellAsset(request: Request): Promise { + if (request.method !== 'GET' && request.method !== 'HEAD') return new Response(null, { status: 405 }) + const root = resolve(app.getAppPath(), 'renderer') + const url = new URL(request.url) + let pathname: string + try { + pathname = decodeURIComponent(url.pathname) + } catch { + return new Response(null, { status: 400 }) + } + const target = resolve(normalize(join(root, pathname))) + if (target !== root && !target.startsWith(root + sep)) return new Response(null, { status: 403 }) + try { + const body = request.method === 'HEAD' ? null : await readFile(target) + return new Response(body, { headers: { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' } }) + } catch { + return new Response(null, { status: 404 }) + } +} + +async function main(): Promise { + const resources = runtimeResources() + const paths = resolveDesktopPaths() + const development = developmentProject() + const activeProject = development ?? paths.profile + const hostInspectPort = developmentHostInspectPort(development !== undefined) + const manager = new DesktopProjectManager(paths, resources) + if (development === undefined) manager.recover() + let host: DesktopHostProcess | undefined + let mainWindow: BrowserWindow | undefined + let pluginWindow: BrowserWindow | undefined + let shellInstallerOwnsQuit = false + let updateState: DesktopUpdateState = { phase: 'idle' } + const locale = resolveDesktopLocale(app.getLocale()) + const messages = locale.messages + const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url)) + const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url)) + + const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => { + updateState = state + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send(DESKTOP_IPC.updatesState, state) + } + return state + } + + const startHost = async (projectDir = activeProject): Promise => { + const next = new DesktopHostProcess(resources.node, projectDir, hostInspectPort) + await next.start() + return next + } + const hooks: DesktopProjectHooks = { + healthCheck: async (projectDir) => { + const active = host + host = undefined + await active?.stop() + let healthFailure: unknown + let probe: DesktopHostProcess | undefined + try { + probe = await startHost(projectDir) + await probe.stop() + } catch (error) { + healthFailure = error + await probe?.stop().catch(() => undefined) + } + let restartFailure: unknown + if (active !== undefined) { + try { + host = await startHost() + } catch (error) { + restartFailure = error + } + } + if (healthFailure !== undefined && restartFailure !== undefined) { + throw new AggregateError([ + errorOf(healthFailure, 'desktop project: staged health check failed'), + errorOf(restartFailure, 'desktop project: active backend restart failed'), + ], 'desktop project: staged health check and active backend restart failed') + } + if (healthFailure !== undefined) throw errorOf(healthFailure, 'desktop project: staged health check failed') + if (restartFailure !== undefined) throw errorOf(restartFailure, 'desktop project: active backend restart failed') + }, + beforeActivate: async () => { + const active = host + host = undefined + await active?.stop() + }, + afterActivate: async () => { + host = await startHost() + }, + } + + if (development === undefined) { + await manager.applyRelease(resources.seed, app.getVersion(), { + ...hooks, + beforeActivate: async () => {}, + afterActivate: async () => {}, + }) + } + host = await startHost() + + const updates = new DesktopUpdateCoordinator( + publishUpdate, + async () => { + shellInstallerOwnsQuit = true + const active = host + host = undefined + await active?.stop() + }, + ) + + protocol.handle(SCHEME, (request) => { + const url = new URL(request.url) + if (url.hostname === 'shell') return serveShellAsset(request) + if (url.hostname !== 'app') return Promise.resolve(new Response(null, { status: 404 })) + const active = host + if (active === undefined) return Promise.resolve(new Response('backend unavailable', { status: 503 })) + return active.fetch(request) + }) + + const mutate = async (event: IpcMainInvokeEvent, mutation: Parameters[0]): Promise => { + assertDesktopSender(event, ['shell']) + if (development !== undefined) { + throw new Error('dsh desktop: plugin package changes require a packaged application') + } + await manager.mutate(mutation, hooks) + if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.webContents.reload() + } + ipcMain.handle(DESKTOP_IPC.localeGet, (event) => { + assertDesktopSender(event, ['shell']) + return locale + }) + ipcMain.handle(DESKTOP_IPC.pluginsList, (event) => { + assertDesktopSender(event, ['shell']) + if (development !== undefined) return [] + return manager.listPlugins() + }) + ipcMain.handle(DESKTOP_IPC.pluginsAdd, (event, spec: unknown) => { + if (typeof spec !== 'string') throw new Error('dsh desktop: plugin spec must be a string') + return mutate(event, { type: 'plugin-add', spec }) + }) + ipcMain.handle(DESKTOP_IPC.pluginsRemove, (event, name: unknown) => { + if (typeof name !== 'string') throw new Error('dsh desktop: plugin name must be a string') + return mutate(event, { type: 'plugin-remove', name }) + }) + ipcMain.handle(DESKTOP_IPC.pluginsUpdate, (event, name: unknown, version: unknown) => { + if (typeof name !== 'string' || typeof version !== 'string') { + throw new Error('dsh desktop: plugin name and version must be strings') + } + return mutate(event, { type: 'plugin-update', name, version }) + }) + ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => { + assertDesktopSender(event, ['shell']) + return updates.check() + }) + ipcMain.handle(DESKTOP_IPC.updatesInstall, async (event) => { + assertDesktopSender(event, ['shell']) + await updates.install() + }) + + const checkAndPrompt = async (manual: boolean): Promise => { + const state = await updates.check() + if (state.phase === 'error') { + if (manual) { + await dialog.showMessageBox({ + type: 'error', + title: messages.updateCheckFailedTitle, + message: state.message ?? messages.unknownError, + }) + } + return + } + if (state.phase !== 'available') { + if (manual) { + await dialog.showMessageBox({ + type: 'info', + title: messages.updateCheckTitle, + message: state.message ?? messages.updateCurrent, + }) + } + return + } + const result = await dialog.showMessageBox({ + type: 'info', + title: messages.updateTitle, + message: messages.updateAvailable, + detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }), + buttons: [messages.installAndRestart, messages.later], + defaultId: 0, + cancelId: 1, + }) + if (result.response !== 0) return + const installed = await updates.install() + if (installed.phase === 'error') { + await dialog.showMessageBox({ + type: 'error', + title: messages.updateFailedTitle, + message: installed.message ?? messages.unknownError, + }) + } + } + + const openPluginWindow = (): void => { + if (pluginWindow !== undefined && !pluginWindow.isDestroyed()) { + pluginWindow.focus() + return + } + pluginWindow = createWindow(managementPreload) + pluginWindow.setSize(900, 620) + pluginWindow.setTitle(messages.pluginWindowTitle) + pluginWindow.once('ready-to-show', () => { pluginWindow?.show() }) + pluginWindow.once('closed', () => { pluginWindow = undefined }) + void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`) + } + + Menu.setApplicationMenu(Menu.buildFromTemplate([{ + label: process.platform === 'darwin' ? app.name : messages.application, + submenu: [ + { + label: development === undefined ? messages.pluginsMenu : messages.pluginsMenuPackagedOnly, + accelerator: 'CmdOrCtrl+,', + enabled: development === undefined, + click: openPluginWindow, + }, + { label: messages.checkUpdatesMenu, click: () => { void checkAndPrompt(true) } }, + { type: 'separator' }, + { role: 'quit' }, + ], + }])) + + const createMainWindow = (): BrowserWindow => { + const window = createWindow(appPreload) + mainWindow = window + window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() }) + window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) + return window + } + focusPrimaryWindow = () => { + const window = mainWindow + if (window === undefined || window.isDestroyed()) { + const replacement = createMainWindow() + void replacement.loadURL(`${SCHEME}://app/index.html`) + return + } + if (window.isMinimized()) window.restore() + window.show() + window.focus() + } + + mainWindow = createMainWindow() + await mainWindow.loadURL(`${SCHEME}://app/index.html`) + if (development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { + mainWindow.webContents.openDevTools({ mode: 'detach' }) + } + publishUpdate(updateState) + setTimeout(() => { void checkAndPrompt(false) }, 10_000) + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) focusPrimaryWindow() + }) + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit() + }) + app.on('before-quit', (event) => { + if (shellInstallerOwnsQuit) return + if (host === undefined) return + event.preventDefault() + const active = host + host = undefined + void active.stop().finally(() => { app.quit() }) + }) +} + +const ownsDesktopInstance = claimDesktopSingleInstance(app, () => { focusPrimaryWindow() }) + +if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + console.error(error) + const diagnosticFile = process.env.DSH_DESKTOP_DIAGNOSTIC_FILE + if (diagnosticFile !== undefined) { + await writeFile(diagnosticFile, `${error instanceof Error ? error.stack ?? message : message}\n`).catch(() => undefined) + } + dialog.showErrorBox(resolveDesktopLocale(app.getLocale()).messages.startupFailed, message) + app.exit(1) +}) diff --git a/apps/desktop/src/paths.ts b/apps/desktop/src/paths.ts new file mode 100644 index 0000000000..4f883929db --- /dev/null +++ b/apps/desktop/src/paths.ts @@ -0,0 +1,48 @@ +/** Filesystem ownership for the Electron-managed desktop installation. */ + +import { join } from 'node:path' +import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' + +/** Stable desktop installation paths under the shared Harness home. */ +export interface DesktopPaths { + readonly root: string + readonly profile: string + readonly staging: string + readonly rollback: string + readonly pending: string + readonly lock: string + readonly pnpm: { + readonly root: string + readonly store: string + readonly cache: string + readonly state: string + readonly config: string + readonly home: string + } +} + +/** + * Resolve every Electron-owned path without changing the shared data roots. + * @param dshHome - Harness home shared with npm-installed dsh. + * @returns immutable desktop path set. + */ +export function resolveDesktopPaths(dshHome: string = resolveDshHome()): DesktopPaths { + const root = join(dshHome, 'desktop') + const pnpm = join(root, 'pnpm') + return { + root, + profile: join(dshHome, 'profiles', 'desktop'), + staging: join(root, 'staging'), + rollback: join(root, 'rollback', 'profile'), + pending: join(root, 'pending.json'), + lock: join(root, 'lock'), + pnpm: { + root: pnpm, + store: join(pnpm, 'store'), + cache: join(pnpm, 'cache'), + state: join(pnpm, 'state'), + config: join(pnpm, 'config'), + home: join(pnpm, 'home'), + }, + } +} diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts new file mode 100644 index 0000000000..c6a839aba6 --- /dev/null +++ b/apps/desktop/src/preload-app.ts @@ -0,0 +1,5 @@ +/** Minimal marker that selects the desktop custom-protocol API carrier. */ + +import { contextBridge } from 'electron' + +contextBridge.exposeInMainWorld('dshDesktop', { protocolVersion: 1 }) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts new file mode 100644 index 0000000000..2fc4dc8ee0 --- /dev/null +++ b/apps/desktop/src/preload.ts @@ -0,0 +1,26 @@ +/** Context-isolated renderer bridge for desktop package and update operations. */ + +import { contextBridge, ipcRenderer } from 'electron' +import { DESKTOP_IPC, type DshDesktopApi, type DesktopUpdateState } from './ipc.ts' + +const api: DshDesktopApi = { + protocolVersion: 1, + locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as Promise extends Promise ? T : never>, + plugins: { + list: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsList) as Promise extends Promise ? T : never>, + add: spec => ipcRenderer.invoke(DESKTOP_IPC.pluginsAdd, spec) as Promise, + remove: name => ipcRenderer.invoke(DESKTOP_IPC.pluginsRemove, name) as Promise, + update: (name, version) => ipcRenderer.invoke(DESKTOP_IPC.pluginsUpdate, name, version) as Promise, + }, + updates: { + check: () => ipcRenderer.invoke(DESKTOP_IPC.updatesCheck) as Promise, + install: () => ipcRenderer.invoke(DESKTOP_IPC.updatesInstall) as Promise, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, state: DesktopUpdateState): void => { listener(state) } + ipcRenderer.on(DESKTOP_IPC.updatesState, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.updatesState, handle) } + }, + }, +} + +contextBridge.exposeInMainWorld('dshDesktop', api) diff --git a/apps/desktop/src/project-manager.ts b/apps/desktop/src/project-manager.ts new file mode 100644 index 0000000000..b9b94f7ac8 --- /dev/null +++ b/apps/desktop/src/project-manager.ts @@ -0,0 +1,725 @@ +/** Transactional owner of the reserved desktop profile and its private pnpm state. */ + +import { spawn } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { + constants, + copyFileSync, + cpSync, + existsSync, + fsyncSync, + ftruncateSync, + lstatSync, + mkdirSync, + openSync, + closeSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + unlinkSync, + writeFileSync, + writeSync, +} from 'node:fs' +import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { + DESKTOP_PACKAGES_DIR, + DESKTOP_PACKAGE_SET_FILE, + DESKTOP_HOST_PACKAGE, + desktopCorePackageOverrides, + desktopDshPackageSpec, + readDesktopCorePackageSet, + verifyDesktopCorePackageSet, +} from './core-package-set.ts' +import type { DesktopPaths } from './paths.ts' +import { parseDesktopRelease, type DesktopRelease } from './release.ts' +import { extractPnpmStoreArchives, mergePnpmStore } from './seed-store.ts' + +/** Files the package transaction copies between active and staging projects. */ +const DESKTOP_PROJECT_FILES = [ + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'desktop-release.json', + DESKTOP_PACKAGE_SET_FILE, +] as const + +/** Desktop plugin record derived from the installed profile. */ +export interface DesktopPluginRecord { + readonly name: string + readonly version: string +} + +/** Installed desktop project manifest slice. */ +interface DesktopProjectManifest { + readonly name: string + readonly private: true + readonly version: string + readonly dependencies: Record + readonly dsh: { + readonly profile: { + readonly bundles: string[] + } + } +} + +/** Journaled activation step used for crash recovery. */ +interface DesktopPendingTransaction { + readonly schemaVersion: 1 + readonly id: string + readonly stagingProfile: string + readonly step: 'prepared' | 'active-moved' | 'staging-activated' +} + +/** Exact executables the desktop shell bundles. */ +export interface DesktopRuntimeExecutables { + readonly node: string + readonly pnpm: string +} + +/** Hooks that bind project replacement to backend lifecycle and health. */ +export interface DesktopProjectHooks { + /** Prove the staged dependency graph while the active backend is stopped. */ + healthCheck(projectDir: string): Promise + /** Stop the active backend and await process exit before directory moves. */ + beforeActivate(): Promise + /** Start the selected active project after commit or rollback. */ + afterActivate(): Promise +} + +/** Supported dependency mutation. */ +export type DesktopProjectMutation = + | { readonly type: 'plugin-add'; readonly spec: string } + | { readonly type: 'plugin-remove'; readonly name: string } + | { readonly type: 'plugin-update'; readonly name: string; readonly version: string } + +interface DesktopSeedIntegrityRecord { + readonly path: string + readonly bytes: number + readonly sha256: string +} + +const PROJECT_NAME = '@deepseek-ai/dsh-desktop-runtime' +const DSH_PACKAGE = '@deepseek-ai/dsh' +const CORE_BUILD_PACKAGE = '@deepseek-ai/dsh-subprocess-local' +const DESKTOP_PROFILE_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'] as const +const WORKSPACE_SETTINGS = 'nodeLinker: hoisted\nautoInstallPeers: false\nstrictDepBuilds: true\n' +const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u +const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.+_-]*$/u +const MAX_PNPM_DIAGNOSTIC_BYTES = 64 * 1024 +const DESKTOP_REGISTRY = 'https://registry.npmjs.org/' + +function errorOf(reason: unknown, fallback: string): Error { + return reason instanceof Error ? reason : new Error(fallback) +} + +function writeJson(path: string, value: unknown): void { + writeFileSync(path, `${JSON.stringify(value, undefined, 2)}\n`, { mode: 0o600 }) +} + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, 'utf8')) +} + +function workspaceFile(overrides: Readonly> = {}): string { + const entries = Object.entries(overrides).sort(([left], [right]) => left.localeCompare(right)) + const overrideSection = entries.length === 0 + ? '' + : `overrides:\n${entries.map(([name, spec]) => ` ${JSON.stringify(name)}: ${JSON.stringify(spec)}`).join('\n')}\n` + const coreBuildSpec = overrides[CORE_BUILD_PACKAGE] + const coreBuildKey = coreBuildSpec === undefined + ? CORE_BUILD_PACKAGE + : `${CORE_BUILD_PACKAGE}@${coreBuildSpec.replace('file:./', 'file:')}` + return `packages:\n - .\n\n${overrideSection}${WORKSPACE_SETTINGS}allowBuilds:\n node-pty: true\n koffi: true\n fs-ext: true\n ${JSON.stringify(coreBuildKey)}: true\n '@google/genai': false\n protobufjs: false\n node-addon-require-builtin: false\n` +} + +function releaseFile(projectDir: string): DesktopRelease { + return parseDesktopRelease(readJson(join(projectDir, 'desktop-release.json'))) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isDescendant(root: string, target: string): boolean { + const child = relative(root, target) + return child !== '' && child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child) +} + +function assertPackageName(name: string): void { + if (!PACKAGE_NAME_PATTERN.test(name)) throw new Error(`desktop project: invalid npm package name ${JSON.stringify(name)}`) +} + +function assertVersion(version: string): void { + if (!VERSION_PATTERN.test(version)) throw new Error(`desktop project: invalid exact version ${JSON.stringify(version)}`) +} + +/** + * Validate one registry package spec and return its requested package name when explicit. + * @param spec - npm registry name with an optional version or tag. + * @returns package name, or undefined when the spec's final name is registry-resolved. + */ +export function packageNameFromSpec(spec: string): string | undefined { + if (spec === '' || spec.startsWith('-') || /[\s\\]/u.test(spec) || spec.includes('://') || spec.startsWith('file:')) { + throw new Error(`desktop project: unsupported npm package spec ${JSON.stringify(spec)}`) + } + if (spec.startsWith('@')) { + const slash = spec.indexOf('/') + if (slash === -1) throw new Error(`desktop project: invalid scoped package spec ${JSON.stringify(spec)}`) + const versionAt = spec.indexOf('@', slash) + const name = versionAt === -1 ? spec : spec.slice(0, versionAt) + assertPackageName(name) + if (versionAt !== -1) assertVersion(spec.slice(versionAt + 1)) + return name + } + const versionAt = spec.indexOf('@') + const name = versionAt === -1 ? spec : spec.slice(0, versionAt) + assertPackageName(name) + if (versionAt !== -1) assertVersion(spec.slice(versionAt + 1)) + return name +} + +function removeOwnedDirectory(path: string): void { + if (!existsSync(path)) return + const stat = lstatSync(path) + if (stat.isSymbolicLink()) { + unlinkSync(path) + return + } + if (!stat.isDirectory()) throw new Error(`desktop project: owned directory path is not a directory: ${path}`) + rmSync(path, { recursive: true }) +} + +function copyMetadata(source: string, target: string): void { + mkdirSync(target, { recursive: true, mode: 0o700 }) + for (const filename of DESKTOP_PROJECT_FILES) { + const from = join(source, filename) + if (existsSync(from)) copyFileSync(from, join(target, filename), constants.COPYFILE_EXCL) + } + cpSync(join(source, DESKTOP_PACKAGES_DIR), join(target, DESKTOP_PACKAGES_DIR), { + recursive: true, + force: false, + errorOnExist: true, + }) +} + +function seedFiles(root: string): readonly DesktopSeedIntegrityRecord[] { + const files: DesktopSeedIntegrityRecord[] = [] + const visit = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + const relativePath = path.slice(root.length + 1).split(sep).join('/') + if (relativePath === 'integrity.json') continue + if (entry.isSymbolicLink()) throw new Error(`desktop seed: symbolic link is not allowed: ${relativePath}`) + if (entry.isDirectory()) { + visit(path) + continue + } + if (!entry.isFile()) throw new Error(`desktop seed: unsupported file type: ${relativePath}`) + const body = readFileSync(path) + files.push({ + path: relativePath, + bytes: body.byteLength, + sha256: createHash('sha256').update(body).digest('hex'), + }) + } + } + visit(root) + return files.sort((left, right) => left.path.localeCompare(right.path)) +} + +/** Verify the packaged offline seed before any content enters writable desktop state. */ +export function verifySeedIntegrity(seedDir: string): void { + const integrityPath = join(seedDir, 'integrity.json') + const integrity = readJson(integrityPath) + if (!isRecord(integrity) || integrity.schemaVersion !== 2 || !Array.isArray(integrity.files)) { + throw new Error(`desktop seed: invalid integrity inventory ${integrityPath}`) + } + const expected: DesktopSeedIntegrityRecord[] = integrity.files.map((record) => { + if (!isRecord(record) || typeof record.path !== 'string' || record.path === '' || record.path.startsWith('/') + || record.path.split('/').includes('..') || typeof record.bytes !== 'number' + || !Number.isSafeInteger(record.bytes) || record.bytes < 0 + || typeof record.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(record.sha256)) { + throw new Error(`desktop seed: invalid integrity record in ${integrityPath}`) + } + return { path: record.path, bytes: record.bytes, sha256: record.sha256 } + }).sort((left, right) => left.path.localeCompare(right.path)) + const actual = seedFiles(seedDir) + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error('desktop seed: integrity verification failed') + } +} + +function projectManifest(projectDir: string): DesktopProjectManifest { + const path = join(projectDir, 'package.json') + const value = readJson(path) + const dsh = isRecord(value) && isRecord(value.dsh) ? value.dsh : undefined + const profile = isRecord(dsh?.profile) ? dsh.profile : undefined + if (!isRecord(value) || value.name !== PROJECT_NAME || value.private !== true + || typeof value.version !== 'string' || !isRecord(value.dependencies) + || !Array.isArray(profile?.bundles) || !profile.bundles.every(bundle => typeof bundle === 'string')) { + throw new Error(`desktop project: invalid desktop profile manifest ${path}`) + } + const manifest = value as unknown as DesktopProjectManifest + const packageSet = readDesktopCorePackageSet(projectDir, releaseFile(projectDir).version) + const expectedOverrides = desktopCorePackageOverrides(packageSet) + if (manifest.dependencies[DSH_PACKAGE] !== desktopDshPackageSpec(packageSet) + || Object.entries(expectedOverrides).some(([name, spec]) => manifest.dependencies[name] !== spec) + || readFileSync(join(projectDir, 'pnpm-workspace.yaml'), 'utf8') !== workspaceFile(expectedOverrides)) { + throw new Error(`desktop project: core package mapping does not match ${DESKTOP_PACKAGE_SET_FILE}`) + } + return manifest +} + +function profilePluginNames(projectDir: string): readonly string[] { + const bundles = projectManifest(projectDir).dsh.profile.bundles + if (!DESKTOP_PROFILE_BUNDLES.every((bundle, index) => bundles[index] === bundle)) { + throw new Error('desktop project: profile must begin with the built-in desktop bundle list') + } + const plugins = bundles.slice(DESKTOP_PROFILE_BUNDLES.length) + if (new Set(bundles).size !== bundles.length) { + throw new Error('desktop project: profile bundle list contains a duplicate package') + } + for (const plugin of plugins) assertPackageName(plugin) + return plugins +} + +function pluginRecords(projectDir: string): readonly DesktopPluginRecord[] { + return profilePluginNames(projectDir).map(name => inspectPlugin(projectDir, name)) +} + +function writeProfilePlugins(projectDir: string, plugins: readonly DesktopPluginRecord[]): void { + const manifest = projectManifest(projectDir) + writeJson(join(projectDir, 'package.json'), { + ...manifest, + dsh: { + ...manifest.dsh, + profile: { + ...manifest.dsh.profile, + bundles: [...DESKTOP_PROFILE_BUNDLES, ...plugins.map(plugin => plugin.name)], + }, + }, + } satisfies DesktopProjectManifest) +} + +function inspectPlugin(projectDir: string, requestedName: string): DesktopPluginRecord { + const manifestPath = join(projectDir, 'node_modules', ...requestedName.split('/'), 'package.json') + if (!existsSync(manifestPath)) { + throw new Error(`desktop project: installed package ${JSON.stringify(requestedName)} has no manifest`) + } + const manifest = readJson(manifestPath) + if (!isRecord(manifest) || manifest.name !== requestedName || typeof manifest.version !== 'string') { + throw new Error(`desktop project: installed package ${JSON.stringify(requestedName)} has inconsistent name or version`) + } + const dsh = manifest.dsh + const bundle = isRecord(dsh) ? dsh.bundle : undefined + const patch = isRecord(bundle) ? bundle.patch : undefined + if (typeof patch !== 'string' || patch === '') { + throw new Error(`desktop project: ${requestedName}@${manifest.version} does not declare dsh.bundle.patch`) + } + const packageDir = dirname(manifestPath) + const patchPath = resolve(packageDir, patch) + if ((patchPath !== packageDir && !patchPath.startsWith(packageDir + sep)) || !existsSync(patchPath)) { + throw new Error(`desktop project: ${requestedName}@${manifest.version} declares an invalid bundle patch`) + } + return { name: requestedName, version: manifest.version } +} + +/** Transactional desktop npm project manager. */ +export class DesktopProjectManager { + private lockDescriptor: number | undefined + + /** + * @param paths - Electron-owned package state and reserved desktop profile paths. + * @param runtime - absolute bundled Node.js and pnpm entry paths. + */ + constructor( + readonly paths: DesktopPaths, + readonly runtime: DesktopRuntimeExecutables, + ) {} + + /** Recover an interrupted directory replacement before reading the active project. */ + recover(): void { + if (!existsSync(this.paths.pending)) return + const value = readJson(this.paths.pending) + if (!isRecord(value) || value.schemaVersion !== 1 + || typeof value.id !== 'string' || typeof value.stagingProfile !== 'string' + || !isDescendant(this.paths.staging, value.stagingProfile) + || (value.step !== 'prepared' && value.step !== 'active-moved' && value.step !== 'staging-activated')) { + throw new Error(`desktop project: invalid activation journal ${this.paths.pending}`) + } + const pending: DesktopPendingTransaction = { + schemaVersion: 1, + id: value.id, + stagingProfile: value.stagingProfile, + step: value.step, + } + if (!existsSync(this.paths.profile) && existsSync(this.paths.rollback)) { + mkdirSync(dirname(this.paths.profile), { recursive: true }) + renameSync(this.paths.rollback, this.paths.profile) + } + removeOwnedDirectory(pending.stagingProfile) + unlinkSync(this.paths.pending) + } + + /** Read the active desktop plugin inventory. */ + listPlugins(): readonly DesktopPluginRecord[] { + if (!existsSync(this.paths.profile)) return [] + return pluginRecords(this.paths.profile) + } + + /** Read the exact dsh version installed in the active desktop project. */ + dshVersion(): string { + if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') + return this.installedPackageVersion(DSH_PACKAGE) + } + + private installedPackageVersion(packageName: string): string { + const manifestPath = join(this.paths.profile, 'node_modules', ...packageName.split('/'), 'package.json') + const manifest = readJson(manifestPath) + if (!isRecord(manifest) || typeof manifest.version !== 'string') { + throw new Error(`desktop project: installed ${packageName} package has no version`) + } + assertVersion(manifest.version) + return manifest.version + } + + /** Read the release version applied to the active desktop project. */ + releaseVersion(): string { + if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') + return releaseFile(this.paths.profile).version + } + + /** Install or reconcile the active project to the Electron package's exact release. */ + async applyRelease(seedDir: string, electronVersion: string, hooks: DesktopProjectHooks): Promise { + return this.withLock(async () => { + this.recover() + verifySeedIntegrity(seedDir) + const target = releaseFile(seedDir) + verifyDesktopCorePackageSet(seedDir, target.version) + if (target.version !== electronVersion) { + throw new Error(`desktop project: seed ${target.version} does not match Electron ${electronVersion}`) + } + if (existsSync(this.paths.profile) && this.releaseVersion() === target.version + && this.dshVersion() === target.version + && this.installedPackageVersion(DESKTOP_HOST_PACKAGE) === target.version) { + verifyDesktopCorePackageSet(this.paths.profile, target.version) + return false + } + this.mergeSeedPnpmState(seedDir) + const stagingProfile = this.newStagingProfile() + try { + if (existsSync(this.paths.profile)) { + const plugins = pluginRecords(this.paths.profile) + copyMetadata(seedDir, stagingProfile) + await this.runPnpm(stagingProfile, ['install', '--offline', '--frozen-lockfile', '--trust-lockfile']) + if (plugins.length > 0) { + await this.runPnpm(stagingProfile, [ + 'add', + ...plugins.map(plugin => `${plugin.name}@${plugin.version}`), + '--save-exact', + '--offline', + ]) + writeProfilePlugins(stagingProfile, plugins) + } + } else { + copyMetadata(seedDir, stagingProfile) + await this.runPnpm(stagingProfile, ['install', '--offline', '--frozen-lockfile', '--trust-lockfile']) + } + await hooks.healthCheck(stagingProfile) + await this.activate(stagingProfile, hooks) + return true + } catch (error) { + removeOwnedDirectory(stagingProfile) + throw error + } + }) + } + + /** Apply one exact dependency mutation through a staging project. */ + async mutate(mutation: DesktopProjectMutation, hooks: DesktopProjectHooks): Promise { + await this.withLock(async () => { + this.recover() + if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') + verifyDesktopCorePackageSet(this.paths.profile, this.releaseVersion()) + const stagingProfile = this.newStagingProfile() + try { + copyMetadata(this.paths.profile, stagingProfile) + await this.applyMutation(stagingProfile, mutation) + await hooks.healthCheck(stagingProfile) + await this.activate(stagingProfile, hooks) + } catch (error) { + removeOwnedDirectory(stagingProfile) + throw error + } + }) + } + + private newStagingProfile(): string { + const path = join(this.paths.staging, randomUUID(), 'profile') + mkdirSync(path, { recursive: true, mode: 0o700 }) + return path + } + + private async applyMutation(projectDir: string, mutation: DesktopProjectMutation): Promise { + switch (mutation.type) { + case 'plugin-add': { + const requestedName = packageNameFromSpec(mutation.spec) + if (requestedName === undefined) throw new Error('desktop project: plugin package name is required') + await this.runPnpm(projectDir, ['add', mutation.spec, '--save-exact']) + const installed = inspectPlugin(projectDir, requestedName) + const current = pluginRecords(projectDir).filter(plugin => plugin.name !== installed.name) + writeProfilePlugins( + projectDir, + [...current, installed].sort((left, right) => left.name.localeCompare(right.name)), + ) + return + } + case 'plugin-remove': { + assertPackageName(mutation.name) + if (!profilePluginNames(projectDir).includes(mutation.name)) { + throw new Error(`desktop project: plugin ${JSON.stringify(mutation.name)} is not installed`) + } + const remaining = pluginRecords(projectDir).filter(plugin => plugin.name !== mutation.name) + await this.runPnpm(projectDir, ['remove', mutation.name]) + writeProfilePlugins(projectDir, remaining) + return + } + case 'plugin-update': + assertPackageName(mutation.name) + assertVersion(mutation.version) + if (!profilePluginNames(projectDir).includes(mutation.name)) { + throw new Error(`desktop project: plugin ${JSON.stringify(mutation.name)} is not installed`) + } + await this.runPnpm(projectDir, ['add', `${mutation.name}@${mutation.version}`, '--save-exact']) + { + const installed = inspectPlugin(projectDir, mutation.name) + writeProfilePlugins( + projectDir, + pluginRecords(projectDir).map(plugin => plugin.name === installed.name ? installed : plugin), + ) + } + return + default: + mutation satisfies never + } + } + + private mergeSeedPnpmState(seedDir: string): void { + const transactionRoot = join(this.paths.staging, randomUUID()) + const extractedStore = join(transactionRoot, 'store') + try { + extractPnpmStoreArchives(seedDir, extractedStore) + mergePnpmStore(extractedStore, this.paths.pnpm.store) + } finally { + removeOwnedDirectory(transactionRoot) + } + } + + private async activate(stagingProfile: string, hooks: DesktopProjectHooks): Promise { + const pending: DesktopPendingTransaction = { + schemaVersion: 1, + id: basename(dirname(stagingProfile)), + stagingProfile, + step: 'prepared', + } + writeJson(this.paths.pending, pending) + await hooks.beforeActivate() + let activeMoved = false + try { + removeOwnedDirectory(this.paths.rollback) + mkdirSync(dirname(this.paths.rollback), { recursive: true, mode: 0o700 }) + writeJson(this.paths.pending, { ...pending, step: 'active-moved' } satisfies DesktopPendingTransaction) + if (existsSync(this.paths.profile)) { + renameSync(this.paths.profile, this.paths.rollback) + activeMoved = true + } + mkdirSync(dirname(this.paths.profile), { recursive: true, mode: 0o700 }) + writeJson(this.paths.pending, { ...pending, step: 'staging-activated' } satisfies DesktopPendingTransaction) + renameSync(stagingProfile, this.paths.profile) + await hooks.afterActivate() + unlinkSync(this.paths.pending) + } catch (error) { + if (existsSync(this.paths.profile)) removeOwnedDirectory(this.paths.profile) + if (activeMoved && existsSync(this.paths.rollback)) renameSync(this.paths.rollback, this.paths.profile) + if (existsSync(this.paths.pending)) unlinkSync(this.paths.pending) + await hooks.afterActivate().catch(() => undefined) + throw error + } + } + + private async runPnpm(projectDir: string, args: readonly string[]): Promise { + const [command, ...commandArgs] = args + if (command === undefined) throw new Error('desktop project: pnpm command is required') + for (const path of [this.paths.root, this.paths.pnpm.store, this.paths.pnpm.cache, + this.paths.pnpm.state, this.paths.pnpm.config, this.paths.pnpm.home]) { + mkdirSync(path, { recursive: true, mode: 0o700 }) + } + const npmrc = join(this.paths.pnpm.config, 'npmrc') + if (!existsSync(npmrc)) writeFileSync(npmrc, '', { mode: 0o600 }) + const inherited = Object.fromEntries(Object.entries(process.env).filter(([name]) => ( + !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) + ))) + await new Promise((settle, reject) => { + const child = spawn(this.runtime.node, [ + this.runtime.pnpm, + `--config.registry=${DESKTOP_REGISTRY}`, + `--config.store-dir=${this.paths.pnpm.store}`, + '--config.enable-global-virtual-store=false', + `--config.userconfig=${npmrc}`, + command, + ...commandArgs, + ], { + cwd: projectDir, + env: { + ...inherited, + COREPACK_HOME: this.paths.pnpm.home, + NPM_CONFIG_REGISTRY: DESKTOP_REGISTRY, + NPM_CONFIG_STORE_DIR: this.paths.pnpm.store, + NPM_CONFIG_USERCONFIG: npmrc, + PATH: `${dirname(this.runtime.node)}${delimiter}${process.env.PATH ?? ''}`, + PNPM_HOME: this.paths.pnpm.home, + XDG_CACHE_HOME: this.paths.pnpm.cache, + XDG_CONFIG_HOME: this.paths.pnpm.config, + XDG_STATE_HOME: this.paths.pnpm.state, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const childPid = child.pid + if (childPid === undefined) { + child.kill('SIGKILL') + reject(new Error('desktop project: pnpm did not report a process id')) + return + } + try { + this.writeLockOwner(childPid) + } catch (error) { + child.kill('SIGKILL') + reject(errorOf(error, 'desktop project: failed to assign the package transaction lock to pnpm')) + return + } + let diagnostics = '' + let completed = false + const appendDiagnostics = (chunk: string): void => { + diagnostics = (diagnostics + chunk).slice(-MAX_PNPM_DIAGNOSTIC_BYTES) + } + child.stdout.setEncoding('utf8') + child.stdout.on('data', appendDiagnostics) + child.stderr.setEncoding('utf8') + child.stderr.on('data', appendDiagnostics) + const complete = (settleChild: () => void): void => { + if (completed) return + completed = true + try { + this.writeLockOwner(process.pid) + } catch (error) { + reject(errorOf(error, 'desktop project: failed to return the package transaction lock to Electron')) + return + } + settleChild() + } + child.once('error', (error) => { complete(() => { reject(error) }) }) + child.once('close', (code, signal) => { + complete(() => { + if (code === 0) { + settle() + return + } + reject(new Error( + `desktop project: pnpm exited with ${String(code ?? signal)}${diagnostics.trim() === '' ? '' : `: ${diagnostics.trim()}`}`, + )) + }) + }) + }) + } + + private writeLockOwner(pid: number): void { + const descriptor = this.lockDescriptor + if (descriptor === undefined) throw new Error('desktop project: package transaction lost its lock') + const content = Buffer.from(`${String(pid)}\n`) + ftruncateSync(descriptor, 0) + writeSync(descriptor, content, 0, content.byteLength, 0) + fsyncSync(descriptor) + } + + private async withLock(operation: () => Promise): Promise { + mkdirSync(this.paths.root, { recursive: true, mode: 0o700 }) + let descriptor: number + try { + descriptor = openSync(this.paths.lock, 'wx', 0o600) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + const lock = lstatSync(this.paths.lock) + if (lock.isSymbolicLink() || !lock.isFile()) { + throw new Error('desktop project: package transaction lock is not a regular file') + } + const owner = Number.parseInt(readFileSync(this.paths.lock, 'utf8').trim(), 10) + let active = !Number.isSafeInteger(owner) || owner <= 0 + if (!active) { + try { + process.kill(owner, 0) + active = true + } catch (signalError) { + active = (signalError as NodeJS.ErrnoException).code !== 'ESRCH' + } + } + if (active) throw new Error('desktop project: another package transaction is active') + unlinkSync(this.paths.lock) + descriptor = openSync(this.paths.lock, 'wx', 0o600) + } else { + throw error + } + } + try { + this.lockDescriptor = descriptor + this.writeLockOwner(process.pid) + return await operation() + } finally { + this.lockDescriptor = undefined + closeSync(descriptor) + unlinkSync(this.paths.lock) + } + } +} + +/** Create seed metadata for one exact Electron and dsh release. */ +export function createSeedMetadata(seedDir: string, release: DesktopRelease): void { + mkdirSync(seedDir, { recursive: true, mode: 0o700 }) + const packageSet = verifyDesktopCorePackageSet(seedDir, release.version) + const manifest: DesktopProjectManifest = { + name: PROJECT_NAME, + private: true, + version: '0.0.0', + dependencies: desktopCorePackageOverrides(packageSet), + dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } }, + } + writeJson(join(seedDir, 'package.json'), manifest) + writeFileSync( + join(seedDir, 'pnpm-workspace.yaml'), + workspaceFile(desktopCorePackageOverrides(packageSet)), + { mode: 0o600 }, + ) + writeJson(join(seedDir, 'desktop-release.json'), release) +} + +/** + * Create metadata for the unpackaged development project that links the current workspace. + * @param projectDir - Disposable development profile directory. + * @param release - Release identity shared by the linked CLI package and Electron shell. + */ +export function createDevelopmentProjectMetadata(projectDir: string, release: DesktopRelease): void { + mkdirSync(projectDir, { recursive: true, mode: 0o700 }) + const manifest = { + name: PROJECT_NAME, + private: true, + version: '0.0.0', + dependencies: { + [DSH_PACKAGE]: release.version, + [DESKTOP_HOST_PACKAGE]: release.version, + }, + dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } }, + } + writeJson(join(projectDir, 'package.json'), manifest) + writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 }) + writeJson(join(projectDir, 'desktop-release.json'), release) +} diff --git a/apps/desktop/src/release.ts b/apps/desktop/src/release.ts new file mode 100644 index 0000000000..f78d922e8c --- /dev/null +++ b/apps/desktop/src/release.ts @@ -0,0 +1,35 @@ +/** Immutable version identity shared by one Electron shell and its dsh seed. */ + +import { valid } from 'semver' +import { DESKTOP_HOST_PROTOCOL_VERSION } from './host-protocol.ts' + +/** Release facts embedded in the seed and copied into the active desktop project. */ +export interface DesktopRelease { + readonly schemaVersion: 1 + /** Exact version used by both Electron and `@deepseek-ai/dsh`. */ + readonly version: string + readonly hostProtocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION + readonly nodeVersion: string + readonly pnpmVersion: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** Validate release data read from an installed or packaged filesystem resource. */ +export function parseDesktopRelease(value: unknown): DesktopRelease { + if (!isRecord(value) || value.schemaVersion !== 1 || typeof value.version !== 'string' + || valid(value.version) === null || value.hostProtocolVersion !== DESKTOP_HOST_PROTOCOL_VERSION + || typeof value.nodeVersion !== 'string' || valid(value.nodeVersion) === null + || typeof value.pnpmVersion !== 'string' || valid(value.pnpmVersion) === null) { + throw new Error('dsh desktop: invalid desktop release metadata') + } + return { + schemaVersion: 1, + version: value.version, + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: value.nodeVersion, + pnpmVersion: value.pnpmVersion, + } +} diff --git a/apps/desktop/src/seed-store.ts b/apps/desktop/src/seed-store.ts new file mode 100644 index 0000000000..e4b4759fd1 --- /dev/null +++ b/apps/desktop/src/seed-store.ts @@ -0,0 +1,271 @@ +/** Deterministic archive transport for the desktop seed's pnpm store. */ + +import { createHash } from 'node:crypto' +import { + chmodSync, + copyFileSync, + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { join, relative, sep } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { create, extract, list } from 'tar' + +/** Directory containing the seed's uncompressed pnpm store archives. */ +export const SEED_STORE_ARCHIVE_DIR = 'store-archives' + +/** Manifest describing the deterministic pnpm store archive set. */ +export const SEED_STORE_ARCHIVE_MANIFEST = 'store-archives.json' + +const DEFAULT_SHARD_COUNT = 16 +const ARCHIVE_NAME_PATTERN = /^store-[0-9a-f]{2}\.tar$/u +const STORE_VERSION_PATTERN = /^v\d+$/u + +interface SeedStoreArchiveRecord { + readonly file: string + readonly entries: number +} + +interface SeedStoreArchiveManifest { + readonly schemaVersion: 1 + readonly shardCount: number + readonly archives: readonly SeedStoreArchiveRecord[] +} + +function storeFiles(storeRoot: string): readonly string[] { + const files: string[] = [] + const visit = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isSymbolicLink()) { + throw new Error(`desktop seed: pnpm store contains a symbolic link: ${relative(storeRoot, path)}`) + } + if (entry.isDirectory()) { + visit(path) + continue + } + if (!entry.isFile()) { + throw new Error(`desktop seed: pnpm store contains an unsupported file: ${relative(storeRoot, path)}`) + } + files.push(relative(storeRoot, path).split(sep).join('/')) + } + } + visit(storeRoot) + return files.sort((left, right) => left.localeCompare(right)) +} + +function shardFor(path: string, shardCount: number): number { + return createHash('sha256').update(path).digest().readUInt32BE(0) % shardCount +} + +function readArchiveManifest(seedRoot: string): SeedStoreArchiveManifest { + const path = join(seedRoot, SEED_STORE_ARCHIVE_MANIFEST) + const value = JSON.parse(readFileSync(path, 'utf8')) as unknown + if (typeof value !== 'object' || value === null) { + throw new Error(`desktop seed: invalid pnpm store archive manifest ${path}`) + } + const candidate = value as Record + if (candidate.schemaVersion !== 1 || !Number.isSafeInteger(candidate.shardCount) + || (candidate.shardCount as number) < 1 || (candidate.shardCount as number) > 256 + || !Array.isArray(candidate.archives) || candidate.archives.length === 0) { + throw new Error(`desktop seed: invalid pnpm store archive manifest ${path}`) + } + const names = new Set() + const archives = candidate.archives.map((entry): SeedStoreArchiveRecord => { + if (typeof entry !== 'object' || entry === null) { + throw new Error(`desktop seed: invalid pnpm store archive record in ${path}`) + } + const record = entry as Record + if (typeof record.file !== 'string' || !ARCHIVE_NAME_PATTERN.test(record.file) + || names.has(record.file) || !Number.isSafeInteger(record.entries) || (record.entries as number) < 1) { + throw new Error(`desktop seed: invalid pnpm store archive record in ${path}`) + } + const shard = Number.parseInt(record.file.slice('store-'.length, -'.tar'.length), 16) + if (shard >= (candidate.shardCount as number)) { + throw new Error(`desktop seed: pnpm store archive shard is outside the manifest range in ${path}`) + } + names.add(record.file) + return { file: record.file, entries: record.entries as number } + }) + return { + schemaVersion: 1, + shardCount: candidate.shardCount as number, + archives, + } +} + +function assertArchivePath(path: string): void { + if (path === '' || path.startsWith('/') || path.includes('\\') || path.includes('\0') + || path.split('/').some(part => part === '' || part === '.' || part === '..')) { + throw new Error(`desktop seed: unsafe pnpm store archive path ${JSON.stringify(path)}`) + } +} + +/** + * Remove pnpm's registrations for projects that populated the seed store. + * @param storeRoot - pnpm store directory included in the desktop seed. + */ +export function removePnpmProjectRegistrations(storeRoot: string): void { + for (const entry of readdirSync(storeRoot, { withFileTypes: true })) { + if (!entry.isDirectory() || !/^v\d+$/u.test(entry.name)) continue + rmSync(join(storeRoot, entry.name, 'projects'), { recursive: true, force: true }) + } +} + +function mergeStoreIndex(source: string, destination: string): void { + if (!existsSync(destination)) { + copyFileSync(source, destination) + return + } + const database = new DatabaseSync(destination) + let attached = false + try { + database.exec('PRAGMA busy_timeout=5000') + database.prepare('ATTACH DATABASE ? AS seed').run(source) + attached = true + database.exec('BEGIN IMMEDIATE') + let committed = false + try { + database.exec('INSERT OR REPLACE INTO package_index (key, data) SELECT key, data FROM seed.package_index') + database.exec('COMMIT') + committed = true + } finally { + if (!committed) database.exec('ROLLBACK') + } + } finally { + if (attached) database.exec('DETACH DATABASE seed') + database.close() + } +} + +/** + * Merge a completely extracted seed store into Desktop's persistent pnpm store. + * @param source - Verified temporary store extraction. + * @param destination - Desktop-owned persistent pnpm store. + */ +export function mergePnpmStore(source: string, destination: string): void { + mkdirSync(destination, { recursive: true, mode: 0o700 }) + const indexPaths = readdirSync(source, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && STORE_VERSION_PATTERN.test(entry.name) + && existsSync(join(source, entry.name, 'index.db'))) + .map(entry => `${entry.name}/index.db`) + const indexes = new Set(indexPaths) + cpSync(source, destination, { + recursive: true, + force: true, + filter: path => !indexes.has(relative(source, path).split(sep).join('/')), + }) + for (const path of indexPaths) { + mergeStoreIndex(join(source, ...path.split('/')), join(destination, ...path.split('/'))) + } +} + +/** + * Replace a prepared loose pnpm store with deterministic uncompressed archive shards. + * @param seedRoot - seed directory that owns the archive output. + * @param storeRoot - populated pnpm store to archive and remove after success. + * @param shardCount - stable shard count used to limit update churn. + */ +export function archivePnpmStore( + seedRoot: string, + storeRoot: string, + shardCount = DEFAULT_SHARD_COUNT, +): void { + if (!Number.isSafeInteger(shardCount) || shardCount < 1 || shardCount > 256) { + throw new Error(`desktop seed: invalid pnpm store shard count ${shardCount}`) + } + const archiveRoot = join(seedRoot, SEED_STORE_ARCHIVE_DIR) + const manifestPath = join(seedRoot, SEED_STORE_ARCHIVE_MANIFEST) + rmSync(archiveRoot, { recursive: true, force: true }) + rmSync(manifestPath, { force: true }) + mkdirSync(archiveRoot, { recursive: true }) + const shards = Array.from({ length: shardCount }, (): string[] => []) + for (const path of storeFiles(storeRoot)) (shards[shardFor(path, shardCount)] as string[]).push(path) + const archives: SeedStoreArchiveRecord[] = [] + for (const [index, paths] of shards.entries()) { + if (paths.length === 0) continue + const file = `store-${index.toString(16).padStart(2, '0')}.tar` + create({ + cwd: storeRoot, + file: join(archiveRoot, file), + noDirRecurse: true, + noMtime: true, + portable: true, + sync: true, + }, paths) + chmodSync(join(archiveRoot, file), 0o644) + archives.push({ file, entries: paths.length }) + } + if (archives.length === 0) throw new Error('desktop seed: pnpm store is empty') + writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, shardCount, archives }, undefined, 2)}\n`) + rmSync(storeRoot, { recursive: true }) +} + +/** + * Validate and extract a packaged pnpm store archive set into an empty directory. + * @param seedRoot - verified packaged seed directory. + * @param destination - empty Desktop-owned temporary extraction directory. + */ +export function extractPnpmStoreArchives(seedRoot: string, destination: string): void { + const manifest = readArchiveManifest(seedRoot) + const archiveRoot = join(seedRoot, SEED_STORE_ARCHIVE_DIR) + const actualFiles = readdirSync(archiveRoot, { withFileTypes: true }).map((entry) => { + if (!entry.isFile() || entry.isSymbolicLink()) { + throw new Error(`desktop seed: invalid pnpm store archive entry ${entry.name}`) + } + return entry.name + }).sort() + const expectedFiles = manifest.archives.map(archive => archive.file).sort() + if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { + throw new Error('desktop seed: pnpm store archive set does not match its manifest') + } + if (existsSync(destination) && readdirSync(destination).length !== 0) { + throw new Error(`desktop seed: pnpm store extraction directory is not empty: ${destination}`) + } + mkdirSync(destination, { recursive: true, mode: 0o700 }) + const paths = new Set() + for (const archive of manifest.archives) { + const archivePath = join(archiveRoot, archive.file) + const archiveShard = Number.parseInt(archive.file.slice('store-'.length, -'.tar'.length), 16) + let entries = 0 + list({ + file: archivePath, + onReadEntry: (entry) => { + if (entry.type !== 'File' && entry.type !== 'OldFile') { + throw new Error(`desktop seed: unsupported pnpm store archive entry type ${entry.type}`) + } + assertArchivePath(entry.path) + if (shardFor(entry.path, manifest.shardCount) !== archiveShard) { + throw new Error(`desktop seed: pnpm store path is assigned to the wrong archive shard: ${entry.path}`) + } + if (paths.has(entry.path)) { + throw new Error(`desktop seed: duplicate pnpm store archive path ${entry.path}`) + } + paths.add(entry.path) + entries += 1 + }, + strict: true, + sync: true, + }) + if (entries !== archive.entries) { + throw new Error(`desktop seed: pnpm store archive ${archive.file} has an unexpected entry count`) + } + } + for (const archive of manifest.archives) { + extract({ + chmod: true, + cwd: destination, + file: join(archiveRoot, archive.file), + noMtime: true, + preservePaths: false, + processUmask: 0, + strict: true, + sync: true, + }) + } +} diff --git a/apps/desktop/src/single-instance.ts b/apps/desktop/src/single-instance.ts new file mode 100644 index 0000000000..7911b46c5b --- /dev/null +++ b/apps/desktop/src/single-instance.ts @@ -0,0 +1,26 @@ +/** Electron single-instance ownership before any Desktop profile lifecycle begins. */ + +/** Minimal Electron application operations needed for instance ownership. */ +export interface DesktopSingleInstanceApplication { + requestSingleInstanceLock(): boolean + quit(): void + on(event: 'second-instance', listener: () => void): unknown +} + +/** + * Claim the process-lifetime Desktop lock and route later launches to the owner. + * @param application - Electron application singleton. + * @param focusOwner - focus or recreate the primary window after a later launch. + * @returns true only in the process that may access the Desktop profile. + */ +export function claimDesktopSingleInstance( + application: DesktopSingleInstanceApplication, + focusOwner: () => void, +): boolean { + if (!application.requestSingleInstanceLock()) { + application.quit() + return false + } + application.on('second-instance', focusOwner) + return true +} diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts new file mode 100644 index 0000000000..c31dcc3093 --- /dev/null +++ b/apps/desktop/src/update-coordinator.ts @@ -0,0 +1,95 @@ +/** One Electron release stream for the version-bound shell and dsh seed. */ + +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { app } from 'electron' +import electronUpdater, { type AppUpdater } from 'electron-updater' +import type { DesktopUpdateState } from './ipc.ts' +const { autoUpdater } = electronUpdater + +/** Checks, downloads, and installs one complete Desktop release. */ +export class DesktopUpdateCoordinator { + private availableVersion: string | undefined + private checkOperation: Promise | undefined + private installOperation: Promise | undefined + + /** + * @param publish - state sink for every desktop window. + * @param beforeRestart - stop application-owned processes before replacement. + * @param updater - Electron artifact updater; replaceable for tests. + * @param enabled - whether this packaged process carries updater configuration. + */ + constructor( + private readonly publish: (state: DesktopUpdateState) => DesktopUpdateState, + private readonly beforeRestart: () => Promise = async () => {}, + private readonly updater: AppUpdater = autoUpdater, + private readonly enabled: () => boolean = () => ( + app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')) + ), + ) { + this.updater.autoDownload = false + this.updater.autoInstallOnAppQuit = false + } + + /** Check the configured Desktop release stream and retain an available version. */ + async check(): Promise { + if (this.installOperation !== undefined) return this.installOperation + if (this.checkOperation !== undefined) return this.checkOperation + this.checkOperation = this.doCheck().finally(() => { this.checkOperation = undefined }) + return this.checkOperation + } + + /** Wait for an in-flight check, then download and install its retained release. */ + async install(): Promise { + if (this.installOperation !== undefined) return this.installOperation + this.installOperation = (async () => { + await this.checkOperation + return this.doInstall() + })().finally(() => { this.installOperation = undefined }) + return this.installOperation + } + + private async doCheck(): Promise { + this.publish({ phase: 'checking' }) + try { + if (!this.enabled()) { + this.availableVersion = undefined + return this.publish({ phase: 'idle' }) + } + const result = await this.updater.checkForUpdates() + const version = result?.isUpdateAvailable === true ? result.updateInfo.version : undefined + this.availableVersion = version + return version === undefined + ? this.publish({ phase: 'idle' }) + : this.publish({ phase: 'available', version }) + } catch (error) { + this.availableVersion = undefined + return this.publish({ + phase: 'error', + message: error instanceof Error ? error.message : String(error), + }) + } + } + + private async doInstall(): Promise { + const version = this.availableVersion + if (version === undefined) { + throw new Error('desktop update: no verified update is available') + } + this.publish({ phase: 'installing', version }) + try { + await this.updater.downloadUpdate() + this.availableVersion = undefined + const ready = this.publish({ phase: 'ready', version }) + await this.beforeRestart() + this.updater.quitAndInstall(false, true) + return ready + } catch (error) { + return this.publish({ + phase: 'error', + version, + message: error instanceof Error ? error.message : String(error), + }) + } + } +} diff --git a/apps/desktop/tests/core-package-set.spec.ts b/apps/desktop/tests/core-package-set.spec.ts new file mode 100644 index 0000000000..0b28a2ec3d --- /dev/null +++ b/apps/desktop/tests/core-package-set.spec.ts @@ -0,0 +1,103 @@ +import { createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + DESKTOP_PACKAGES_DIR, + DESKTOP_PACKAGE_SET_FILE, + desktopCorePackageOverrides, + desktopDshPackageSpec, + parseDesktopCorePackageSet, + verifyDesktopCoreLockfile, + verifyDesktopCorePackageSet, + type DesktopCorePackageRecord, +} from '../src/core-package-set.ts' + +const roots: string[] = [] + +function record(name: string, file: string, body: Buffer, version = '1.2.3'): DesktopCorePackageRecord { + return { + name, + version, + file, + bytes: body.byteLength, + integrity: `sha512-${createHash('sha512').update(body).digest('base64')}`, + } +} + +function packageSetProject(): { + root: string + dsh: DesktopCorePackageRecord + base: DesktopCorePackageRecord + host: DesktopCorePackageRecord +} { + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-package-set-')) + roots.push(root) + const packageDir = join(root, DESKTOP_PACKAGES_DIR) + mkdirSync(packageDir) + const dshBody = Buffer.from('dsh') + const baseBody = Buffer.from('base') + const hostBody = Buffer.from('host') + const dsh = record('@deepseek-ai/dsh', 'dsh.tgz', dshBody) + const base = record('@deepseek-ai/dsh-base', 'dsh-base.tgz', baseBody) + const host = record('@deepseek-ai/dsh-desktop-host', 'dsh-desktop-host.tgz', hostBody) + writeFileSync(join(packageDir, dsh.file), dshBody) + writeFileSync(join(packageDir, base.file), baseBody) + writeFileSync(join(packageDir, host.file), hostBody) + writeFileSync(join(root, DESKTOP_PACKAGE_SET_FILE), `${JSON.stringify({ + schemaVersion: 1, + packages: [dsh, base, host], + })}\n`) + return { root, dsh, base, host } +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('desktop core package set', () => { + it('pins the direct dsh dependency and every internal package to local tarballs', () => { + const { root } = packageSetProject() + const packageSet = verifyDesktopCorePackageSet(root, '1.2.3') + expect(desktopDshPackageSpec(packageSet)).toBe('file:./desktop-packages/dsh.tgz') + expect(desktopCorePackageOverrides(packageSet)).toEqual({ + '@deepseek-ai/dsh': 'file:./desktop-packages/dsh.tgz', + '@deepseek-ai/dsh-base': 'file:./desktop-packages/dsh-base.tgz', + '@deepseek-ai/dsh-desktop-host': 'file:./desktop-packages/dsh-desktop-host.tgz', + }) + }) + + it('rejects version drift, descriptor disorder, corruption, and extra files', () => { + const { root, dsh, base, host } = packageSetProject() + expect(() => verifyDesktopCorePackageSet(root, '2.0.0')).toThrow(/does not match Desktop/u) + expect(() => parseDesktopCorePackageSet({ + schemaVersion: 1, + packages: [dsh, base, { ...host, version: '2.0.0' }], + }, '1.2.3')).toThrow(/dsh-desktop-host@2\.0\.0 does not match Desktop 1\.2\.3/u) + expect(() => parseDesktopCorePackageSet({ schemaVersion: 1, packages: [base, dsh, host] })) + .toThrow(/sorted by name/u) + writeFileSync(join(root, DESKTOP_PACKAGES_DIR, dsh.file), 'changed') + expect(() => verifyDesktopCorePackageSet(root, '1.2.3')).toThrow(/integrity check failed/u) + writeFileSync(join(root, DESKTOP_PACKAGES_DIR, 'extra.tgz'), '') + expect(() => verifyDesktopCorePackageSet(root, '1.2.3')).toThrow(/does not match its descriptor/u) + }) + + it('rejects registry resolutions for names supplied by the local package set', () => { + const dsh = record('@deepseek-ai/dsh', 'dsh.tgz', Buffer.from('dsh')) + const host = record('@deepseek-ai/dsh-desktop-host', 'host.tgz', Buffer.from('host')) + const packageSet = parseDesktopCorePackageSet({ schemaVersion: 1, packages: [dsh, host] }) + expect(() => { + verifyDesktopCoreLockfile( + "packages:\n '@deepseek-ai/dsh@file:desktop-packages/dsh.tgz':\n resolution: {}\n", + packageSet, + ) + }).not.toThrow() + expect(() => { + verifyDesktopCoreLockfile( + "packages:\n '@deepseek-ai/dsh@1.2.3':\n resolution: {integrity: sha512-registry}\n", + packageSet, + ) + }).toThrow(/outside the local package set/u) + }) +}) diff --git a/apps/desktop/tests/desktop-auto-update-environment.spec.ts b/apps/desktop/tests/desktop-auto-update-environment.spec.ts new file mode 100644 index 0000000000..71e368acda --- /dev/null +++ b/apps/desktop/tests/desktop-auto-update-environment.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { + desktopBuildRecordFilename, + desktopUpdateMetadataFilename, + resolveDesktopAutoUpdateConfig, + resolveDesktopAutoUpdateEnvironment, + resolveDesktopAutoUpdateTarget, + resolveDesktopUploadConfig, +} from '../scripts/desktop-auto-update-environment.mjs' + +describe('desktop auto-update environment', () => { + it('defaults packages and uploads to the test deployment', () => { + expect(resolveDesktopAutoUpdateEnvironment({})).toBe('test') + expect(resolveDesktopAutoUpdateConfig({ + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/', + }, 'darwin', 'arm64')).toEqual({ + environment: 'test', + target: 'mac-arm64', + origin: 'https://desktop-updates.example.com', + publicUrl: 'https://desktop-updates.example.com/_/harness/desktop/stable/mac-arm64/', + keyPrefix: '_/harness/desktop/stable/mac-arm64', + }) + expect(resolveDesktopUploadConfig({ + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/', + DOWNLOAD_TEST_COS_BUCKET: 'test-download-bucket', + }, 'darwin', 'arm64')).toMatchObject({ + bucket: 'test-download-bucket', + secretIdEnvName: 'DOWNLOAD_TEST_COS_SECRET_ID', + secretKeyEnvName: 'DOWNLOAD_TEST_COS_SECRET_KEY', + }) + }) + + it('selects the production URL for packages and bucket for uploads', () => { + expect(resolveDesktopAutoUpdateConfig({ + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + }, 'win32', 'x64')).toMatchObject({ + environment: 'production', + target: 'win-x64', + publicUrl: 'https://download.deepseek.com/_/harness/desktop/stable/win-x64/', + }) + expect(resolveDesktopUploadConfig({ + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + DOWNLOAD_PROD_COS_BUCKET: 'production-download-bucket', + }, 'win32', 'x64')).toMatchObject({ + bucket: 'production-download-bucket', + secretIdEnvName: 'DOWNLOAD_PROD_COS_SECRET_ID', + secretKeyEnvName: 'DOWNLOAD_PROD_COS_SECRET_KEY', + }) + }) + + it('requires the selected deployment origin for packages and bucket only for uploads', () => { + expect(() => resolveDesktopAutoUpdateConfig({}, 'darwin', 'arm64')) + .toThrow(/DOWNLOAD_TEST_ORIGIN/u) + expect(resolveDesktopAutoUpdateConfig({ + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + }, 'darwin', 'arm64').publicUrl).toContain('/mac-arm64/') + expect(() => resolveDesktopUploadConfig({ + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + }, 'darwin', 'arm64')).toThrow(/DOWNLOAD_TEST_COS_BUCKET/u) + expect(() => resolveDesktopUploadConfig({ + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + }, 'win32', 'x64')).toThrow(/DOWNLOAD_PROD_COS_BUCKET/u) + }) + + it('rejects a test download URL that is not an HTTPS origin', () => { + expect(() => resolveDesktopAutoUpdateConfig({ + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/releases', + }, 'darwin', 'arm64')).toThrow(/HTTPS origin without a path/u) + expect(() => resolveDesktopAutoUpdateConfig({ + DOWNLOAD_TEST_ORIGIN: 'http://desktop-updates.example.com', + }, 'darwin', 'arm64')).toThrow(/HTTPS origin/u) + }) + + it('rejects unknown deployments and targets', () => { + expect(() => resolveDesktopAutoUpdateEnvironment({ + DSH_DESKTOP_AUTO_UPDATE_ENV: 'staging', + })).toThrow(/test.*production/u) + expect(() => resolveDesktopAutoUpdateTarget('linux', 'x64')).toThrow(/unsupported target/u) + expect(() => desktopBuildRecordFilename('linux-x64' as 'mac-arm64')).toThrow(/unsupported target/u) + }) + + it('matches electron-builder channel metadata names to the Desktop version', () => { + expect(desktopUpdateMetadataFilename('1.2.3', 'darwin')).toBe('latest-mac.yml') + expect(desktopUpdateMetadataFilename('1.2.3-alpha.4', 'darwin')).toBe('alpha-mac.yml') + expect(desktopUpdateMetadataFilename('1.2.3-beta.2', 'win32')).toBe('beta.yml') + expect(() => desktopUpdateMetadataFilename('not-semver', 'darwin')).toThrow(/invalid Desktop version/u) + expect(() => desktopUpdateMetadataFilename('1.2.3', 'linux')).toThrow(/unsupported metadata platform/u) + }) +}) diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts new file mode 100644 index 0000000000..ac1618baef --- /dev/null +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -0,0 +1,50 @@ +import { join, sep } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + desktopTargetBuildPaths, + resolveDesktopBuildTarget, +} from '../scripts/desktop-build-paths.mjs' + +describe('desktop build paths', () => { + it('isolates every mutable build directory by complete target', () => { + const arm64 = desktopTargetBuildPaths('mac-arm64') + const x64 = desktopTargetBuildPaths('mac-x64') + const windows = desktopTargetBuildPaths('win-x64') + const mutableKeys = [ + 'root', + 'artifacts', + 'runtime', + 'packageSet', + 'seed', + 'seedPnpm', + 'nodeExtract', + 'packedDsh', + 'packedVendor', + 'packedLandlock', + ] as const + + for (const key of mutableKeys) { + expect(new Set([arm64[key], x64[key], windows[key]]).size).toBe(3) + } + expect(arm64.artifacts).toContain(join('targets', 'mac-arm64', 'artifacts')) + expect(x64.seed).toContain(join('targets', 'mac-x64', 'seed')) + expect(windows.runtime).toContain(join('targets', 'win-x64', 'runtime')) + }) + + it('shares only the immutable upstream download cache', () => { + const arm64 = desktopTargetBuildPaths('mac-arm64') + const x64 = desktopTargetBuildPaths('mac-x64') + expect(arm64.downloads).toBe(x64.downloads) + expect(arm64.downloads).not.toContain(`${sep}targets${sep}`) + }) + + it('resolves environment overrides and rejects unsupported targets', () => { + expect(resolveDesktopBuildTarget({ + DSH_DESKTOP_TARGET_PLATFORM: 'darwin', + DSH_DESKTOP_TARGET_ARCH: 'x64', + }, 'darwin', 'arm64')).toBe('mac-x64') + expect(resolveDesktopBuildTarget({}, 'win32', 'x64')).toBe('win-x64') + expect(() => resolveDesktopBuildTarget({}, 'linux', 'x64')).toThrow(/unsupported target/u) + expect(() => desktopTargetBuildPaths('linux-x64' as 'mac-x64')).toThrow(/unsupported target/u) + }) +}) diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts new file mode 100644 index 0000000000..4a34b0afbe --- /dev/null +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -0,0 +1,195 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { createDesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' +import { desktopUpdateMetadataFilename } from '../scripts/desktop-auto-update-environment.mjs' +import type { DesktopPackageTargetName } from '../scripts/package-target.ts' + +const temporaryDirectories: string[] = [] +const TEST_ORIGIN = 'https://desktop-updates.example.com' +const TEST_BUCKET = 'test-download-bucket' +const PRODUCTION_BUCKET = 'production-download-bucket' + +interface Fixture { + readonly repositoryRoot: string + readonly appRoot: string + readonly artifactsRoot: string + readonly environment: NodeJS.ProcessEnv +} + +function digest(contents: string): string { + return createHash('sha512').update(contents).digest('base64') +} + +async function fixture( + target: DesktopPackageTargetName, + version = '1.2.3', + environment: 'test' | 'production' = 'test', +): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-desktop-upload-')) + temporaryDirectories.push(root) + const repositoryRoot = join(root, 'repository') + const appRoot = join(repositoryRoot, 'apps', 'desktop') + const artifactsRoot = join(appRoot, '.desktop-build', 'artifacts') + await mkdir(artifactsRoot, { recursive: true }) + await writeFile(join(repositoryRoot, 'package.json'), `${JSON.stringify({ version })}\n`) + await writeFile(join(appRoot, 'package.json'), `${JSON.stringify({ version })}\n`) + + const [os, arch] = target.split('-') as ['mac' | 'win', 'arm64' | 'x64'] + const base = `deepseek-harness-${version}-${os}-${arch}` + const origin = environment === 'test' + ? TEST_ORIGIN + : 'https://download.deepseek.com' + await writeFile(join(artifactsRoot, `${target}-release.json`), `${JSON.stringify({ + schemaVersion: 1, + target, + version, + environment, + publicUrl: `${origin}/_/harness/desktop/stable/${target}/`, + })}\n`) + + if (os === 'mac') { + const zip = 'signed macOS ZIP fixture' + await writeFile(join(artifactsRoot, `${base}.zip`), zip) + await writeFile(join(artifactsRoot, `${base}.zip.blockmap`), 'blockmap') + await writeFile(join(artifactsRoot, `${base}.dmg`), 'notarized DMG fixture') + await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'darwin')), `${JSON.stringify({ + version, + files: [{ url: `${base}.zip`, size: Buffer.byteLength(zip), sha512: digest(zip) }], + })}\n`) + } + else { + const executable = 'signed NSIS executable fixture' + await writeFile(join(artifactsRoot, `${base}.exe`), executable) + await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'win32')), `${JSON.stringify({ + version, + files: [{ + url: `${base}.exe`, + size: Buffer.byteLength(executable), + sha512: digest(executable), + blockMapSize: 128, + }], + })}\n`) + } + return { + repositoryRoot, + appRoot, + artifactsRoot, + environment: environment === 'test' + ? { + DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', + DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN, + DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET, + } + : { + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + DOWNLOAD_PROD_COS_BUCKET: PRODUCTION_BUCKET, + }, + } +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(async path => rm(path, { + recursive: true, + force: true, + }))) +}) + +describe('desktop upload plan', () => { + it('validates macOS artifacts and puts channel metadata last', async () => { + const paths = await fixture('mac-arm64') + const plan = await createDesktopUploadPlan('mac-arm64', paths) + expect(plan).toMatchObject({ + environment: 'test', + version: '1.2.3', + publicUrl: 'https://desktop-updates.example.com/_/harness/desktop/stable/mac-arm64/', + bucket: TEST_BUCKET, + }) + expect(plan.artifacts.map(artifact => artifact.filename)).toEqual([ + 'deepseek-harness-1.2.3-mac-arm64.dmg', + 'deepseek-harness-1.2.3-mac-arm64.zip', + 'deepseek-harness-1.2.3-mac-arm64.zip.blockmap', + 'latest-mac.yml', + ]) + expect(plan.artifacts.at(-1)).toMatchObject({ + channelMetadata: true, + cacheControl: 'no-cache', + }) + }) + + it('uploads the prerelease channel metadata emitted by electron-builder', async () => { + const paths = await fixture('mac-arm64', '1.2.3-alpha.4') + const plan = await createDesktopUploadPlan('mac-arm64', paths) + expect(plan.artifacts.map(artifact => artifact.filename)).toEqual([ + 'deepseek-harness-1.2.3-alpha.4-mac-arm64.dmg', + 'deepseek-harness-1.2.3-alpha.4-mac-arm64.zip', + 'deepseek-harness-1.2.3-alpha.4-mac-arm64.zip.blockmap', + 'alpha-mac.yml', + ]) + }) + + it('validates the Windows installer with its embedded blockmap and production destination', async () => { + const paths = await fixture('win-x64', '2.0.0', 'production') + const plan = await createDesktopUploadPlan('win-x64', paths) + expect(plan.artifacts.map(artifact => artifact.filename)).toEqual([ + 'deepseek-harness-2.0.0-win-x64.exe', + 'latest.yml', + ]) + expect(plan).toMatchObject({ + publicUrl: 'https://download.deepseek.com/_/harness/desktop/stable/win-x64/', + bucket: PRODUCTION_BUCKET, + }) + }) + + it('rejects Windows metadata without an embedded blockmap size', async () => { + const paths = await fixture('win-x64') + const executable = 'signed NSIS executable fixture' + await writeFile(join(paths.artifactsRoot, 'latest.yml'), `${JSON.stringify({ + version: '1.2.3', + files: [{ + url: 'deepseek-harness-1.2.3-win-x64.exe', + size: Buffer.byteLength(executable), + sha512: digest(executable), + }], + })}\n`) + await expect(createDesktopUploadPlan('win-x64', paths)).rejects.toThrow(/blockMapSize/u) + }) + + it('rejects a completed build from another dsh version or deployment', async () => { + const paths = await fixture('mac-x64') + await writeFile(join(paths.repositoryRoot, 'package.json'), '{"version":"1.2.4"}\n') + await writeFile(join(paths.appRoot, 'package.json'), '{"version":"1.2.4"}\n') + await expect(createDesktopUploadPlan('mac-x64', paths)).rejects.toThrow(/completion record.*1\.2\.4/u) + + const productionPaths = await fixture('mac-x64', '1.2.3', 'production') + await expect(createDesktopUploadPlan('mac-x64', { + ...productionPaths, + environment: { + DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', + DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN, + DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET, + }, + })).rejects.toThrow(/completion record.*test/u) + }) + + it('rejects stale architecture metadata and modified updater bytes', async () => { + const paths = await fixture('mac-arm64') + const metadataPath = join(paths.artifactsRoot, 'latest-mac.yml') + const zipPath = join(paths.artifactsRoot, 'deepseek-harness-1.2.3-mac-arm64.zip') + await writeFile(zipPath, 'modified') + await expect(createDesktopUploadPlan('mac-arm64', paths)).rejects.toThrow(/size.*metadata/u) + + const x64 = 'wrong architecture' + await writeFile(metadataPath, `${JSON.stringify({ + version: '1.2.3', + files: [{ + url: 'deepseek-harness-1.2.3-mac-x64.zip', + size: Buffer.byteLength(x64), + sha512: digest(x64), + }], + })}\n`) + await expect(createDesktopUploadPlan('mac-arm64', paths)).rejects.toThrow(/mac-arm64\.zip/u) + }) +}) diff --git a/apps/desktop/tests/development-project.spec.ts b/apps/desktop/tests/development-project.spec.ts new file mode 100644 index 0000000000..dbdfa09347 --- /dev/null +++ b/apps/desktop/tests/development-project.spec.ts @@ -0,0 +1,89 @@ +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { prepareDevelopmentProject } from '../scripts/development-project.ts' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import type { DesktopRelease } from '../src/release.ts' + +const roots: string[] = [] + +function temporaryRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-development-test-')) + roots.push(root) + return root +} + +function release(version = '1.2.3'): DesktopRelease { + return { + schemaVersion: 1, + version, + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: '24.17.0', + pnpmVersion: '11.7.0', + } +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('desktop development project', () => { + it('projects the built dsh and Desktop Host applications with their dependency graph', () => { + const root = temporaryRoot() + const cli = join(root, 'apps', 'cli') + const host = join(root, 'apps', 'desktop-host') + const dependencies = join(root, 'workspace-dependencies') + mkdirSync(join(cli, 'lib'), { recursive: true }) + mkdirSync(join(host, 'lib'), { recursive: true }) + mkdirSync(join(dependencies, '@scope'), { recursive: true }) + mkdirSync(join(dependencies, '@deepseek-ai', 'dsh'), { recursive: true }) + writeFileSync(join(cli, 'package.json'), '{"name":"@deepseek-ai/dsh","version":"1.2.3"}\n') + writeFileSync(join(host, 'package.json'), '{"name":"@deepseek-ai/dsh-desktop-host","version":"1.2.3"}\n') + writeFileSync(join(host, 'lib', 'index.js'), '') + writeFileSync(join(dependencies, '@deepseek-ai', 'dsh', 'package.json'), '{}\n') + mkdirSync(join(dependencies, 'plain-dependency')) + writeFileSync(join(dependencies, 'plain-dependency', 'package.json'), '{}\n') + mkdirSync(join(dependencies, '@scope', 'dependency')) + writeFileSync(join(dependencies, '@scope', 'dependency', 'package.json'), '{}\n') + + const project = prepareDevelopmentProject({ + projectDir: join(root, 'development'), + cliDir: cli, + hostDir: host, + dependencyDir: dependencies, + release: release(), + }) + expect(realpathSync(join(project, 'node_modules', '@deepseek-ai', 'dsh'))).toBe(realpathSync(cli)) + expect(realpathSync(join(project, 'node_modules', '@deepseek-ai', 'dsh-desktop-host'))).toBe(realpathSync(host)) + expect(realpathSync(join(project, 'node_modules', 'plain-dependency'))) + .toBe(realpathSync(join(dependencies, 'plain-dependency'))) + expect(realpathSync(join(project, 'node_modules', '@scope', 'dependency'))) + .toBe(realpathSync(join(dependencies, '@scope', 'dependency'))) + const manifest = JSON.parse(readFileSync(join(project, 'package.json'), 'utf8')) as { + dependencies: Record + } + expect(manifest.dependencies['@deepseek-ai/dsh']).toBe('1.2.3') + expect(manifest.dependencies['@deepseek-ai/dsh-desktop-host']).toBe('1.2.3') + }) + + it('rejects a CLI package from another release', () => { + const root = temporaryRoot() + const cli = join(root, 'apps', 'cli') + const host = join(root, 'apps', 'desktop-host') + const dependencies = join(root, 'workspace-dependencies') + mkdirSync(join(cli, 'lib'), { recursive: true }) + mkdirSync(join(host, 'lib'), { recursive: true }) + mkdirSync(dependencies, { recursive: true }) + writeFileSync(join(cli, 'package.json'), '{"name":"@deepseek-ai/dsh","version":"2.0.0"}\n') + writeFileSync(join(host, 'package.json'), '{"name":"@deepseek-ai/dsh-desktop-host","version":"1.2.3"}\n') + writeFileSync(join(host, 'lib', 'index.js'), '') + expect(() => prepareDevelopmentProject({ + projectDir: join(root, 'development'), + cliDir: cli, + hostDir: host, + dependencyDir: dependencies, + release: release(), + })).toThrow(/must be @deepseek-ai\/dsh@1\.2\.3/u) + }) +}) diff --git a/apps/desktop/tests/host-process.spec.ts b/apps/desktop/tests/host-process.spec.ts new file mode 100644 index 0000000000..d3a08d78e0 --- /dev/null +++ b/apps/desktop/tests/host-process.spec.ts @@ -0,0 +1,216 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { DesktopHostProcess } from '../src/host-process.ts' + +const roots: string[] = [] + +const HOST_WIRE = ` +import { closeSync, createReadStream, createWriteStream } from 'node:fs' +const requestPipe = createReadStream('', { fd: 3, autoClose: false }) +const responsePipe = createWriteStream('', { fd: 4, autoClose: false }) +const MAGIC = 0x44534833 +const HEADER = 13 +function responseFrame(type, streamId, payload = Buffer.alloc(0)) { + const frame = Buffer.allocUnsafe(HEADER + payload.length) + frame.writeUInt32BE(MAGIC, 0) + frame.writeUInt8(type, 4) + frame.writeUInt32BE(streamId, 5) + frame.writeUInt32BE(payload.length, 9) + payload.copy(frame, HEADER) + return frame +} +function responseStart(streamId, options = {}) { + const value = { status: options.status ?? 200, headers: options.headers ?? [], hasBody: options.hasBody ?? true } + responsePipe.write(responseFrame(1, streamId, Buffer.from(JSON.stringify(value)))) +} +function responseData(streamId, data) { + responsePipe.write(responseFrame(2, streamId, Buffer.from(data))) +} +function responseEnd(streamId) { responsePipe.write(responseFrame(3, streamId)) } +function responseError(streamId, message) { + responsePipe.write(responseFrame(4, streamId, Buffer.from(JSON.stringify({ message })))) +} +let requestBuffer = Buffer.alloc(0) +requestPipe.on('data', chunk => { + requestBuffer = requestBuffer.length === 0 ? chunk : Buffer.concat([requestBuffer, chunk]) + while (requestBuffer.length >= HEADER) { + if (requestBuffer.readUInt32BE(0) !== MAGIC) throw new Error('invalid request marker') + const type = requestBuffer.readUInt8(4) + const streamId = requestBuffer.readUInt32BE(5) + const length = requestBuffer.readUInt32BE(9) + if (requestBuffer.length < HEADER + length) return + const payload = requestBuffer.subarray(HEADER, HEADER + length) + requestBuffer = requestBuffer.subarray(HEADER + length) + onRequestFrame({ type, streamId, payload }) + } +}) +process.on('message', message => { + if (message.type === 'shutdown') { + requestPipe.destroy() + closeSync(3) + responsePipe.end(() => { + responsePipe.destroy() + closeSync(4) + process.disconnect() + process.exitCode = 0 + }) + } +}) +` + +function projectWithHost(source: string): string { + const project = mkdtempSync(join(tmpdir(), 'dsh-desktop-host-test-')) + roots.push(project) + const packageRoot = join(project, 'node_modules', '@deepseek-ai', 'dsh-desktop-host') + mkdirSync(join(packageRoot, 'lib'), { recursive: true }) + writeFileSync(join(packageRoot, 'package.json'), '{"name":"@deepseek-ai/dsh-desktop-host","type":"module"}\n') + writeFileSync(join(packageRoot, 'lib', 'index.js'), `${HOST_WIRE}\n${source}`) + return project +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('desktop host process', () => { + it('carries raw request and response bytes and shuts the child down cleanly', async () => { + const project = projectWithHost(` +const bodies = new Map() +process.send({ type: 'ready', protocolVersion: 3, dshVersion: process.env.NODE_OPTIONS ?? 'clean' }) +function onRequestFrame(frame) { + if (frame.type === 1) { + const request = JSON.parse(frame.payload) + bodies.set(frame.streamId, Buffer.alloc(0)) + if (!request.hasBody) answer(frame.streamId) + } else if (frame.type === 2) { + bodies.set(frame.streamId, Buffer.concat([bodies.get(frame.streamId), frame.payload])) + } else if (frame.type === 3) { + answer(frame.streamId) + } +} +function answer(streamId) { + responseStart(streamId, { headers: [['content-type', 'text/plain']] }) + responseData(streamId, Buffer.concat([Buffer.from('desktop:'), bodies.get(streamId)])) + responseEnd(streamId) +} +`) + const previous = process.env.NODE_OPTIONS + process.env.NODE_OPTIONS = '--require /path/that-must-not-reach-the-child' + const host = new DesktopHostProcess(process.execPath, project) + try { + await expect(host.start()).resolves.toMatchObject({ dshVersion: 'clean' }) + const response = await host.fetch(new Request('dsh-app://app/example', { method: 'POST', body: 'request' })) + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('desktop:request') + await expect(host.stop()).resolves.toBeUndefined() + } finally { + if (previous === undefined) delete process.env.NODE_OPTIONS + else process.env.NODE_OPTIONS = previous + await host.stop().catch(() => undefined) + } + }) + + it('streams a large binary response in bounded raw frames', async () => { + const size = 2 * 1024 * 1024 + const project = projectWithHost(` +process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'large-response' }) +function onRequestFrame(frame) { + if (frame.type !== 1) return + responseStart(frame.streamId) + const bytes = Buffer.alloc(${String(64 * 1024)}, 97) + for (let offset = 0; offset < ${String(size)}; offset += bytes.length) responseData(frame.streamId, bytes) + responseEnd(frame.streamId) +} +`) + const host = new DesktopHostProcess(process.execPath, project) + try { + const response = await host.fetch(new Request('dsh-app://app/large')) + const body = new Uint8Array(await response.arrayBuffer()) + expect(body).toHaveLength(size) + expect(body[0]).toBe(97) + expect(body.at(-1)).toBe(97) + } finally { + await host.stop().catch(() => undefined) + } + }) + + it('stops an unfinished upload when the Host completes its response early', async () => { + const project = projectWithHost(` +process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'early-response' }) +function onRequestFrame(frame) { + if (frame.type !== 2) return + responseStart(frame.streamId) + responseData(frame.streamId, 'accepted') + responseEnd(frame.streamId) +} +`) + let canceled = false + const body = new ReadableStream({ + start(controller) { controller.enqueue(Buffer.from('first')) }, + cancel() { canceled = true }, + }) + const host = new DesktopHostProcess(process.execPath, project) + try { + const request = new Request('dsh-app://app/early', { + method: 'POST', + body, + duplex: 'half', + } as RequestInit & { duplex: 'half' }) + const response = await host.fetch(request) + await expect(response.text()).resolves.toBe('accepted') + await expect.poll(() => canceled).toBe(true) + } finally { + await host.stop().catch(() => undefined) + } + }) + + it('ignores a response end that arrives after the renderer cancels its stream', async () => { + const project = projectWithHost(` +process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'cancel-race' }) +const urls = new Map() +function onRequestFrame(frame) { + if (frame.type === 1) { + const request = JSON.parse(frame.payload) + urls.set(frame.streamId, request.url) + responseStart(frame.streamId) + if (request.url.endsWith('/after')) { + responseData(frame.streamId, 'alive') + responseEnd(frame.streamId) + } + } else if (frame.type === 4 && urls.get(frame.streamId).endsWith('/cancel')) { + responseEnd(frame.streamId) + } +} +`) + const host = new DesktopHostProcess(process.execPath, project) + try { + const canceled = await host.fetch(new Request('dsh-app://app/cancel')) + await canceled.body?.cancel() + await new Promise(resolve => setTimeout(resolve, 25)) + const after = await host.fetch(new Request('dsh-app://app/after')) + await expect(after.text()).resolves.toBe('alive') + } finally { + await host.stop().catch(() => undefined) + } + }) + + it('rejects invalid response framing and a clean exit before readiness', async () => { + const invalid = new DesktopHostProcess(process.execPath, projectWithHost(` +process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'invalid-frame' }) +function onRequestFrame(frame) { + if (frame.type === 1) responsePipe.write(Buffer.alloc(13)) +} +`)) + await invalid.start() + await expect(invalid.fetch(new Request('dsh-app://app/invalid'))).rejects.toThrow(/invalid Host response frame marker/u) + await invalid.stop().catch(() => undefined) + + const earlyExit = new DesktopHostProcess(process.execPath, projectWithHost(` +function onRequestFrame() {} +process.exit(0) +`)) + await expect(earlyExit.start()).rejects.toThrow(/response pipe ended/u) + }) +}) diff --git a/apps/desktop/tests/host-protocol.spec.ts b/apps/desktop/tests/host-protocol.spec.ts new file mode 100644 index 0000000000..b0455f2c6f --- /dev/null +++ b/apps/desktop/tests/host-protocol.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { + DesktopHostRequestDecoder, + encodeDesktopResponseData, + encodeDesktopResponseEnd, + encodeDesktopResponseError, + encodeDesktopResponseStart, +} from '../../desktop-host/src/wire.ts' +import { + DesktopHostResponseDecoder, + encodeDesktopRequestCancel, + encodeDesktopRequestData, + encodeDesktopRequestEnd, + encodeDesktopRequestStart, +} from '../src/host-protocol.ts' + +function decodeInPieces(bytes: Buffer, push: (chunk: Buffer) => readonly T[]): T[] { + const values: T[] = [] + for (let offset = 0; offset < bytes.byteLength; offset += 7) { + values.push(...push(bytes.subarray(offset, offset + 7))) + } + return values +} + +describe('desktop Host pipe protocol', () => { + it('keeps Electron request frames compatible with the installed Host decoder', () => { + const decoder = new DesktopHostRequestDecoder() + const bytes = Buffer.concat([ + encodeDesktopRequestStart(7, { + url: 'dsh-app://app/api/session', + method: 'POST', + headers: [['content-type', 'application/json']], + hasBody: true, + }), + encodeDesktopRequestData(7, Buffer.from('{"ok":true}')), + encodeDesktopRequestEnd(7), + encodeDesktopRequestCancel(7), + ]) + + expect(decodeInPieces(bytes, chunk => decoder.push(chunk))).toEqual([ + { + type: 'start', + streamId: 7, + url: 'dsh-app://app/api/session', + method: 'POST', + headers: [['content-type', 'application/json']], + hasBody: true, + }, + { type: 'data', streamId: 7, data: Buffer.from('{"ok":true}') }, + { type: 'end', streamId: 7 }, + { type: 'cancel', streamId: 7 }, + ]) + expect(() => { decoder.finish() }).not.toThrow() + }) + + it('keeps Host response frames compatible with the Electron decoder', () => { + const decoder = new DesktopHostResponseDecoder() + const bytes = Buffer.concat([ + encodeDesktopResponseStart(9, { + status: 201, + headers: [['content-type', 'application/octet-stream']], + hasBody: true, + }), + encodeDesktopResponseData(9, Buffer.from([0, 1, 2, 255])), + encodeDesktopResponseEnd(9), + encodeDesktopResponseError(10, 'failed'), + ]) + + expect(decodeInPieces(bytes, chunk => decoder.push(chunk))).toEqual([ + { + type: 'start', + streamId: 9, + status: 201, + headers: [['content-type', 'application/octet-stream']], + hasBody: true, + }, + { type: 'data', streamId: 9, data: Buffer.from([0, 1, 2, 255]) }, + { type: 'end', streamId: 9 }, + { type: 'error', streamId: 10, message: 'failed' }, + ]) + expect(() => { decoder.finish() }).not.toThrow() + }) + + it('rejects a corrupt marker and truncated EOF on both directions', () => { + const request = new DesktopHostRequestDecoder() + const response = new DesktopHostResponseDecoder() + expect(() => request.push(Buffer.alloc(13))).toThrow(/request frame marker/u) + expect(() => response.push(Buffer.alloc(13))).toThrow(/response frame marker/u) + + const partialRequest = new DesktopHostRequestDecoder() + partialRequest.push(encodeDesktopRequestEnd(1).subarray(0, 5)) + expect(() => { partialRequest.finish() }).toThrow(/ended inside a frame/u) + + const partialResponse = new DesktopHostResponseDecoder() + partialResponse.push(encodeDesktopResponseEnd(1).subarray(0, 5)) + expect(() => { partialResponse.finish() }).toThrow(/ended inside a frame/u) + }) +}) diff --git a/apps/desktop/tests/locale.spec.ts b/apps/desktop/tests/locale.spec.ts new file mode 100644 index 0000000000..de7a7a7048 --- /dev/null +++ b/apps/desktop/tests/locale.spec.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { en, formatDesktopMessage, resolveDesktopLocale, zh } from '../src/locale.ts' + +describe('desktop locale dictionaries', () => { + it('ships the same key set in English and Chinese', () => { + expect(Object.keys(zh)).toEqual(Object.keys(en)) + expect(resolveDesktopLocale('zh-Hans-CN')).toEqual({ id: 'zh-CN', messages: zh }) + expect(resolveDesktopLocale('en-US')).toEqual({ id: 'en', messages: en }) + expect(resolveDesktopLocale('fr-FR')).toEqual({ id: 'en', messages: en }) + }) + + it('formats named values without consuming unknown placeholders', () => { + expect(formatDesktopMessage('{name}@{version} {missing}', { name: 'plugin', version: '1.2.3' })) + .toBe('plugin@1.2.3 {missing}') + }) + + it('keeps visible plugin-manager HTML copy in the locale dictionaries', () => { + const html = readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8') + const staticText = [...html.matchAll(/>([^<]*\p{L}[^<]*) match[1]?.trim()) + expect(staticText).toEqual([]) + }) +}) diff --git a/apps/desktop/tests/macos-seed-store.spec.ts b/apps/desktop/tests/macos-seed-store.spec.ts new file mode 100644 index 0000000000..2155389262 --- /dev/null +++ b/apps/desktop/tests/macos-seed-store.spec.ts @@ -0,0 +1,245 @@ +import { createHash } from 'node:crypto' +import { + appendFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { Packr } from 'msgpackr' +import { afterEach, describe, expect, it } from 'vitest' +import { + signMacOSSeedStore, + verifyMacOSSeedStore, +} from '../scripts/macos-seed-store.ts' + +const temporaryRoots: string[] = [] +const packr = new Packr({ moreTypes: true, useRecords: true }) +const SIGNING_ENVIRONMENT = { + signingIdentity: 'Example Company (TEAMID1234)', + teamId: 'TEAMID1234', +} + +function temporaryRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-signing-test-')) + temporaryRoots.push(root) + return root +} + +function casPath(store: string, body: Buffer, executable = false): { digest: string; path: string } { + const digest = createHash('sha512').update(body).digest('hex') + return { + digest, + path: join(store, 'v11', 'files', digest.slice(0, 2), `${digest.slice(2)}${executable ? '-exec' : ''}`), + } +} + +function createStoreFile(path: string, body: Buffer): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, body) +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('desktop macOS seed store signing', () => { + it('rehashes signed Mach-O content, rewrites every package reference, and prunes native orphans', async () => { + const store = temporaryRoot() + const native = Buffer.concat([Buffer.from('cffaedfe', 'hex'), Buffer.from('native-code')]) + const nativeCas = casPath(store, native) + createStoreFile(nativeCas.path, native) + const orphan = Buffer.concat([Buffer.from('cafebabe', 'hex'), Buffer.from('orphan')]) + const orphanCas = casPath(store, orphan) + createStoreFile(orphanCas.path, orphan) + const plain = Buffer.from('plain package content') + const plainCas = casPath(store, plain) + createStoreFile(plainCas.path, plain) + + const database = new DatabaseSync(join(store, 'v11', 'index.db')) + database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID') + const insert = database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)') + for (const key of ['package-a', 'package-b']) { + insert.run(key, packr.pack({ + algo: 'sha512', + files: new Map([ + ['native.node', { checkedAt: 1, digest: nativeCas.digest, mode: 0o644, size: native.length }], + ['index.js', { checkedAt: 1, digest: plainCas.digest, mode: 0o644, size: plain.length }], + ]), + sideEffects: key === 'package-b' + ? new Map([['build', { added: new Map([ + ['built/native.node', { checkedAt: 1, digest: nativeCas.digest, mode: 0o644, size: native.length }], + ]) }]]) + : undefined, + })) + } + database.close() + + const result = await signMacOSSeedStore( + store, + 'com.example.desktop', + SIGNING_ENVIRONMENT, + { + signer: async (path, identifier) => { + expect(identifier).toBe(`com.example.desktop.seed.${nativeCas.digest.slice(0, 32)}`) + appendFileSync(path, 'signed') + }, + }, + ) + + expect(result).toEqual({ signedFiles: 1, prunedOrphans: 1, updatedIndexRows: 2 }) + expect(existsSync(nativeCas.path)).toBe(false) + expect(existsSync(orphanCas.path)).toBe(false) + expect(readFileSync(plainCas.path)).toEqual(plain) + + const updated = new DatabaseSync(join(store, 'v11', 'index.db'), { readOnly: true }) + const digests = [...updated.prepare('SELECT data FROM package_index').iterate() as Iterable<{ data: Uint8Array }>] + .flatMap((row) => { + const record = packr.unpack(row.data) as { + files: Map + sideEffects?: Map }> + } + return [ + record.files.get('native.node')?.digest, + ...[...(record.sideEffects?.values() ?? [])].map(effect => effect.added.get('built/native.node')?.digest), + ].filter((digest): digest is string => digest !== undefined) + }) + updated.close() + expect(new Set(digests).size).toBe(1) + expect(digests).toHaveLength(3) + expect(digests[0]).not.toBe(nativeCas.digest) + + const verified: string[] = [] + expect(verifyMacOSSeedStore(store, SIGNING_ENVIRONMENT, (path) => { verified.push(path) })).toBe(1) + expect(verified).toHaveLength(1) + }) + + it('bounds concurrent signing while allowing independent Mach-O files to overlap', async () => { + const store = temporaryRoot() + const files = new Map() + for (let index = 0; index < 6; index += 1) { + const body = Buffer.concat([Buffer.from('feedfacf', 'hex'), Buffer.from(`native-${index}`)]) + const nativeCas = casPath(store, body) + createStoreFile(nativeCas.path, body) + files.set(`native-${index}.node`, { + checkedAt: 1, + digest: nativeCas.digest, + mode: 0o644, + size: body.length, + }) + } + const database = new DatabaseSync(join(store, 'v11', 'index.db')) + database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID') + database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)') + .run('package', packr.pack({ algo: 'sha512', files })) + database.close() + + let started = 0 + let active = 0 + let maximumActive = 0 + let releaseSigning = (): void => {} + const signingReleased = new Promise((resolve) => { releaseSigning = resolve }) + let markFirstWaveReady = (): void => {} + const firstWaveReady = new Promise((resolve) => { markFirstWaveReady = resolve }) + const signing = signMacOSSeedStore(store, 'com.example.desktop', SIGNING_ENVIRONMENT, { + concurrency: 4, + signer: async () => { + started += 1 + active += 1 + maximumActive = Math.max(maximumActive, active) + if (started === 4) markFirstWaveReady() + try { + await signingReleased + } finally { + active -= 1 + } + }, + }) + + await firstWaveReady + expect({ started, active, maximumActive }).toEqual({ started: 4, active: 4, maximumActive: 4 }) + releaseSigning() + await expect(signing).resolves.toMatchObject({ signedFiles: 6, updatedIndexRows: 1 }) + expect({ started, active, maximumActive }).toEqual({ started: 6, active: 0, maximumActive: 4 }) + }) + + it('awaits active signers and preserves the store when one signer fails', async () => { + const store = temporaryRoot() + const originals: { digest: string; path: string }[] = [] + const files = new Map() + for (let index = 0; index < 2; index += 1) { + const body = Buffer.concat([Buffer.from('feedfacf', 'hex'), Buffer.from(`native-${index}`)]) + const nativeCas = casPath(store, body) + originals.push(nativeCas) + createStoreFile(nativeCas.path, body) + files.set(`native-${index}.node`, { + checkedAt: 1, + digest: nativeCas.digest, + mode: 0o644, + size: body.length, + }) + } + const databasePath = join(store, 'v11', 'index.db') + const database = new DatabaseSync(databasePath) + database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID') + database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)') + .run('package', packr.pack({ algo: 'sha512', files })) + database.close() + const originalIndex = readFileSync(databasePath) + + let started = 0 + let settled = 0 + let releaseSigning = (): void => {} + const signingReleased = new Promise((resolve) => { releaseSigning = resolve }) + let markBothReady = (): void => {} + const bothReady = new Promise((resolve) => { markBothReady = resolve }) + const signing = signMacOSSeedStore(store, 'com.example.desktop', SIGNING_ENVIRONMENT, { + concurrency: 2, + signer: async () => { + started += 1 + const call = started + if (started === 2) markBothReady() + try { + await signingReleased + if (call === 1) throw new Error('signing failed') + } finally { + settled += 1 + } + }, + }) + + await bothReady + releaseSigning() + await expect(signing).rejects.toThrow(/signing failed/u) + expect({ started, settled }).toEqual({ started: 2, settled: 2 }) + expect(readFileSync(databasePath)).toEqual(originalIndex) + for (const original of originals) expect(existsSync(original.path)).toBe(true) + }) + + it('rejects an invalid signing worker bound before starting a signer', async () => { + const store = temporaryRoot() + mkdirSync(join(store, 'v11', 'files'), { recursive: true }) + await expect(signMacOSSeedStore(store, 'com.example.desktop', SIGNING_ENVIRONMENT, { + concurrency: 0, + signer: async () => {}, + })).rejects.toThrow(/positive integer/u) + }) + + it('propagates a signature-verification failure', () => { + const store = temporaryRoot() + const native = Buffer.concat([Buffer.from('feedfacf', 'hex'), Buffer.from('native-code')]) + const nativeCas = casPath(store, native) + createStoreFile(nativeCas.path, native) + + expect(() => { + verifyMacOSSeedStore(store, SIGNING_ENVIRONMENT, () => { + throw new Error('invalid signature') + }) + }).toThrow(/invalid signature/u) + }) +}) diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts new file mode 100644 index 0000000000..827de532e9 --- /dev/null +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -0,0 +1,171 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import type { NotarizeOptions } from '@electron/notarize' +import { + resolveDesktopAppId, + resolveMacOSNotarizationEnvironment, + resolveMacOSSigningEnvironment, +} from '../scripts/desktop-release-environment.mjs' +import { notarizeMacOSDiskImageArtifact } from '../scripts/notarize-macos-disk-images.mjs' +import { + assertMacOSSeedSignatureDetails, + assertMacOSSignatureDetails, +} from '../scripts/verify-macos-signature.mjs' + +const RELEASE_ENVIRONMENT = { + DSH_DESKTOP_APP_ID: 'com.example.desktop', + DSH_DESKTOP_TARGET_PLATFORM: 'darwin', + DSH_DESKTOP_TARGET_ARCH: 'arm64', + DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', + DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234', + APPLE_API_KEY: '/private/credentials/AuthKey_TEST123456.p8', + APPLE_API_KEY_ID: 'TEST123456', + APPLE_API_ISSUER: '11111111-2222-3333-4444-555555555555', + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', +} + +function portablePath(value: string): string { + return value.replaceAll('\\', '/') +} + +describe('desktop macOS release signature', () => { + beforeAll(() => { + for (const [name, value] of Object.entries(RELEASE_ENVIRONMENT)) vi.stubEnv(name, value) + }) + + afterAll(() => { + vi.unstubAllEnvs() + }) + + it('loads release identifiers from the environment and requires code signing', async () => { + const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') + const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') + expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts') + expect(config.extraResources).toHaveLength(2) + expect(config.extraResources[0]?.to).toBe('runtime') + expect(config.extraResources[1]?.to).toBe('seed') + expect(portablePath(config.extraResources[0]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/runtime') + expect(portablePath(config.extraResources[1]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/seed') + expect(config).toMatchObject({ + appId: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID, + mac: { + identity: RELEASE_ENVIRONMENT.DSH_DESKTOP_MACOS_SIGNING_IDENTITY, + forceCodeSigning: true, + notarize: true, + }, + dmg: { + sign: true, + writeUpdateInfo: false, + }, + publish: [{ + provider: 'generic', + url: 'https://desktop-updates.example.com/_/harness/desktop/stable/mac-arm64/', + }], + }) + expect(typeof config.artifactBuildCompleted).toBe('function') + }) + + it('validates Windows signing without requiring macOS identifiers for a Windows target', async () => { + const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') + expect(() => createElectronBuilderConfig({ + DSH_DESKTOP_APP_ID: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID, + DSH_DESKTOP_TARGET_PLATFORM: 'win32', + }, 'win32')).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u) + }) + + it('accepts the configured authority and team', () => { + const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT) + expect(() => { + assertMacOSSignatureDetails([ + `Authority=Developer ID Application: ${expected.signingIdentity}`, + `TeamIdentifier=${expected.teamId}`, + ].join('\n'), expected) + }).not.toThrow() + }) + + it('requires a secure timestamp and hardened runtime for seed code', () => { + const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT) + const details = [ + `Authority=Developer ID Application: ${expected.signingIdentity}`, + `TeamIdentifier=${expected.teamId}`, + 'Timestamp=31 Aug 2026 at 20:00:00', + 'CodeDirectory v=20500 size=773 flags=0x10000(runtime) hashes=13+7 location=embedded', + ].join('\n') + expect(() => { assertMacOSSeedSignatureDetails(details, expected) }).not.toThrow() + expect(() => { + assertMacOSSeedSignatureDetails(details.replace(/^Timestamp=.*\n/um, ''), expected) + }).toThrow(/secure timestamp/u) + expect(() => { + assertMacOSSeedSignatureDetails(details.replace('flags=0x10000(runtime)', 'flags=0x0(none)'), expected) + }).toThrow(/hardened runtime/u) + }) + + it('rejects another developer identity', () => { + const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT) + expect(() => { + assertMacOSSignatureDetails([ + 'Authority=Developer ID Application: Other Company (OTHERID123)', + 'TeamIdentifier=OTHERID123', + ].join('\n'), expected) + }).toThrow(/release identity/u) + }) + + it('rejects an unexpected team even when the authority is present', () => { + const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT) + expect(() => { + assertMacOSSignatureDetails([ + `Authority=Developer ID Application: ${expected.signingIdentity}`, + 'TeamIdentifier=OTHERID123', + ].join('\n'), expected) + }).toThrow(`TeamIdentifier=${expected.teamId}`) + }) + + it('rejects missing and malformed release identifiers', () => { + expect(() => resolveDesktopAppId({})).toThrow(/DSH_DESKTOP_APP_ID/u) + expect(() => resolveDesktopAppId({ DSH_DESKTOP_APP_ID: 'not-a-bundle-id' })).toThrow(/reverse-DNS/u) + expect(() => resolveMacOSSigningEnvironment({})).toThrow(/DSH_DESKTOP_MACOS_SIGNING_IDENTITY/u) + expect(() => resolveMacOSSigningEnvironment({ + DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Developer ID Application: Example Company (TEAMID1234)', + DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234', + })).toThrow(/must omit/u) + expect(() => resolveMacOSSigningEnvironment({ + DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', + DSH_DESKTOP_MACOS_TEAM_ID: 'short', + })).toThrow(/10 uppercase/u) + }) + + it('requires one complete notarization credential strategy', () => { + expect(resolveMacOSNotarizationEnvironment(RELEASE_ENVIRONMENT)).toEqual({ + appleApiKey: RELEASE_ENVIRONMENT.APPLE_API_KEY, + appleApiKeyId: RELEASE_ENVIRONMENT.APPLE_API_KEY_ID, + appleApiIssuer: RELEASE_ENVIRONMENT.APPLE_API_ISSUER, + }) + expect(resolveMacOSNotarizationEnvironment({ + APPLE_KEYCHAIN_PROFILE: 'dsh-notary', + })).toEqual({ keychainProfile: 'dsh-notary' }) + expect(() => resolveMacOSNotarizationEnvironment({})).toThrow(/macOS packaging requires/u) + expect(() => resolveMacOSNotarizationEnvironment({ APPLE_API_KEY: '/tmp/key.p8' })).toThrow(/APPLE_API_KEY_ID/u) + }) + + it('notarizes and qualifies a DMG before electron-builder publishes it', async () => { + const submitted: string[] = [] + const submit = vi.fn(async (options: NotarizeOptions) => { submitted.push(options.appPath) }) + const verified: string[] = [] + const verify = vi.fn((path: string) => { verified.push(path) }) + await notarizeMacOSDiskImageArtifact( + { file: '/tmp/release.dmg' }, + RELEASE_ENVIRONMENT, + resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT), + submit, + verify, + ) + await notarizeMacOSDiskImageArtifact( + { file: '/tmp/release.zip' }, + RELEASE_ENVIRONMENT, + resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT), + submit, + verify, + ) + expect(submitted).toEqual(['/tmp/release.dmg']) + expect(verified).toEqual(['/tmp/release.dmg']) + }) +}) diff --git a/apps/desktop/tests/package-target.spec.ts b/apps/desktop/tests/package-target.spec.ts new file mode 100644 index 0000000000..e822f212f5 --- /dev/null +++ b/apps/desktop/tests/package-target.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { + desktopElectronBuilderArguments, + parseDesktopPackageInvocation, + resolveDesktopPackageTarget, + withoutDesktopUploadCredentials, + withoutWindowsSigningEnvironment, +} from '../scripts/package-target.ts' + +describe('desktop package target', () => { + it('selects matching runtime and electron-builder architectures', () => { + expect(resolveDesktopPackageTarget('mac-arm64', 'darwin', 'arm64')).toMatchObject({ + platform: 'darwin', arch: 'arm64', builderPlatform: '--mac', builderArch: '--arm64', + }) + expect(resolveDesktopPackageTarget('mac-x64', 'darwin', 'x64')).toMatchObject({ + platform: 'darwin', arch: 'x64', builderPlatform: '--mac', builderArch: '--x64', + }) + expect(resolveDesktopPackageTarget('win-x64', 'win32', 'x64')).toMatchObject({ + platform: 'win32', arch: 'x64', builderPlatform: '--win', builderArch: '--x64', + }) + }) + + it('allows an Apple Silicon host to build the Intel target through Rosetta', () => { + expect(resolveDesktopPackageTarget('mac-x64', 'darwin', 'arm64').arch).toBe('x64') + }) + + it('rejects unsupported targets and hosts before building', () => { + expect(() => resolveDesktopPackageTarget('linux-x64', 'linux', 'x64')).toThrow(/unsupported target/u) + expect(() => resolveDesktopPackageTarget('win-x64', 'darwin', 'arm64')).toThrow(/Windows x64/u) + expect(() => resolveDesktopPackageTarget('mac-arm64', 'darwin', 'x64')).toThrow(/Apple Silicon/u) + expect(() => resolveDesktopPackageTarget('mac-arm64', 'linux', 'arm64')).toThrow(/macOS/u) + expect(() => resolveDesktopPackageTarget('mac-x64', 'darwin', 'ppc64')).toThrow(/Rosetta/u) + }) + + it('parses installer and unpacked-directory invocations', () => { + expect(parseDesktopPackageInvocation(['mac-arm64'], 'darwin', 'arm64').directory).toBe(false) + expect(parseDesktopPackageInvocation(['mac-arm64', '--dir'], 'darwin', 'arm64').directory).toBe(true) + expect(parseDesktopPackageInvocation([], 'darwin', 'arm64').target.name).toBe('mac-arm64') + expect(parseDesktopPackageInvocation(['--prepare-only'], 'darwin', 'arm64').prepareOnly).toBe(true) + expect(() => parseDesktopPackageInvocation(['mac-arm64', 'mac-x64'], 'darwin', 'arm64')) + .toThrow(/at most one target/u) + }) + + it('keeps electron-builder publishing disabled for the separate validated upload', () => { + const target = resolveDesktopPackageTarget('mac-arm64', 'darwin', 'arm64') + expect(desktopElectronBuilderArguments(target, false)).toEqual([ + 'exec', + 'electron-builder', + '--config', + 'electron-builder.config.mjs', + '--mac', + '--arm64', + '--publish', + 'never', + ]) + expect(desktopElectronBuilderArguments(target, true)).toContain('--dir') + }) + + it('keeps Windows signing fields out of build and seed preparation subprocesses', () => { + expect(withoutWindowsSigningEnvironment({ + DSH_DESKTOP_WINDOWS_CER_FILE: 'C:\\release\\server.cer', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret', + DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'container', + DSH_DESKTOP_WINDOWS_SIGNTOOL: 'C:\\tools\\signtool.exe', + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + })).toEqual({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production' }) + }) + + it('keeps COS credentials out of every packaging subprocess', () => { + expect(withoutDesktopUploadCredentials({ + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_COS_BUCKET: 'test-download-bucket', + DOWNLOAD_TEST_COS_SECRET_ID: 'test-id', + DOWNLOAD_TEST_COS_SECRET_KEY: 'test-key', + DOWNLOAD_PROD_COS_BUCKET: 'production-download-bucket', + DOWNLOAD_PROD_COS_SECRET_ID: 'production-id', + DOWNLOAD_PROD_COS_SECRET_KEY: 'production-key', + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + })).toEqual({ + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_COS_BUCKET: 'test-download-bucket', + DOWNLOAD_PROD_COS_BUCKET: 'production-download-bucket', + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + }) + }) +}) diff --git a/apps/desktop/tests/prepare-package-set.spec.ts b/apps/desktop/tests/prepare-package-set.spec.ts new file mode 100644 index 0000000000..ded24ceeea --- /dev/null +++ b/apps/desktop/tests/prepare-package-set.spec.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + assertDesktopHostPackageFiles, + selectDesktopPackageClosure, + type PackedDesktopPackage, +} from '../scripts/prepare-package-set.ts' + +function packed(name: string, manifest: Record = {}): PackedDesktopPackage { + return { tarball: `${name}.tgz`, manifest: { name, version: '1.0.0', ...manifest } } +} + +describe('desktop package-set selection', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('does not select a packaging target when imported as a library', async () => { + vi.stubEnv('DSH_DESKTOP_TARGET_PLATFORM', 'linux') + vi.stubEnv('DSH_DESKTOP_TARGET_ARCH', 'x64') + vi.resetModules() + await expect(import('../scripts/prepare-package-set.ts')).resolves.toHaveProperty('prepareDesktopPackageSet') + }) + + it('includes only the available internal production closure', () => { + const available = new Map([ + ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh', { + dependencies: { '@deepseek-ai/dsh-base': '^1.0.0', external: '^2.0.0' }, + optionalDependencies: { '@deepseek-ai/platform-package': '1.0.0', '@deepseek-ai/missing-platform': '1.0.0' }, + })], + ['@deepseek-ai/dsh-desktop-host', packed('@deepseek-ai/dsh-desktop-host', { + dependencies: { '@deepseek-ai/dsh': '^1.0.0' }, + })], + ['@deepseek-ai/dsh-base', packed('@deepseek-ai/dsh-base', { + peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' }, + })], + ['@deepseek-ai/cordis', packed('@deepseek-ai/cordis')], + ['@deepseek-ai/platform-package', packed('@deepseek-ai/platform-package')], + ['@deepseek-ai/unused', packed('@deepseek-ai/unused')], + ]) + expect(selectDesktopPackageClosure(available).map(entry => entry.manifest.name)).toEqual([ + '@deepseek-ai/cordis', + '@deepseek-ai/dsh', + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-desktop-host', + '@deepseek-ai/platform-package', + ]) + }) + + it('rejects a required internal package absent from the packed release inputs', () => { + const available = new Map([ + ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh', { + dependencies: { '@deepseek-ai/dsh-base': '^1.0.0' }, + })], + ['@deepseek-ai/dsh-desktop-host', packed('@deepseek-ai/dsh-desktop-host', { + dependencies: { '@deepseek-ai/dsh': '^1.0.0' }, + })], + ]) + expect(() => selectDesktopPackageClosure(available)).toThrow(/unpacked internal package/u) + expect(() => selectDesktopPackageClosure(new Map([ + ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh')], + ]))).toThrow(/omit @deepseek-ai\/dsh-desktop-host/u) + }) + + it('requires the Desktop Host entry and its packaged overlay', () => { + const files = [ + 'package/lib/index.js', + 'package/config/desktop.cordis.patch.yml', + ] + expect(() => { + assertDesktopHostPackageFiles(files) + }).not.toThrow() + expect(() => { + assertDesktopHostPackageFiles(files.slice(0, 1)) + }).toThrow(/desktop\.cordis\.patch\.yml/u) + expect(() => { + assertDesktopHostPackageFiles(files.slice(1)) + }).toThrow(/lib\/index\.js/u) + }) +}) diff --git a/apps/desktop/tests/project-manager.spec.ts b/apps/desktop/tests/project-manager.spec.ts new file mode 100644 index 0000000000..f5320b47bd --- /dev/null +++ b/apps/desktop/tests/project-manager.spec.ts @@ -0,0 +1,392 @@ +import { createHash } from 'node:crypto' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, relative, sep } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveDesktopPaths } from '../src/paths.ts' +import { + createSeedMetadata, + DesktopProjectManager, + packageNameFromSpec, + verifySeedIntegrity, + type DesktopProjectHooks, +} from '../src/project-manager.ts' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import { DESKTOP_PACKAGES_DIR, DESKTOP_PACKAGE_SET_FILE } from '../src/core-package-set.ts' +import type { DesktopRelease } from '../src/release.ts' +import { archivePnpmStore } from '../src/seed-store.ts' + +const roots: string[] = [] + +function temporaryRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-test-')) + roots.push(root) + return root +} + +function writeIntegrity(seed: string): void { + const paths: string[] = [] + const visit = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) visit(path) + else if (entry.name !== 'integrity.json') paths.push(path) + } + } + visit(seed) + const files = paths.sort().map((path) => { + const body = readFileSync(path) + return { + path: relative(seed, path).split(sep).join('/'), + bytes: statSync(path).size, + sha256: createHash('sha256').update(body).digest('hex'), + } + }) + writeFileSync(join(seed, 'integrity.json'), `${JSON.stringify({ schemaVersion: 2, files })}\n`) +} + +function archiveStore(seed: string): void { + const store = join(seed, 'store') + mkdirSync(store, { recursive: true }) + if (readdirSync(store).length === 0) writeFileSync(join(store, 'test-entry'), 'content') + archivePnpmStore(seed, store) +} + +function writeCorePackageSet(seed: string, version: string): void { + const packages = [ + { name: '@deepseek-ai/dsh', file: `deepseek-ai-dsh-${version}.tgz`, body: Buffer.from(`dsh-${version}`) }, + { + name: '@deepseek-ai/dsh-desktop-host', + file: `deepseek-ai-dsh-desktop-host-${version}.tgz`, + body: Buffer.from(`desktop-host-${version}`), + }, + ] + mkdirSync(join(seed, DESKTOP_PACKAGES_DIR), { recursive: true }) + for (const entry of packages) writeFileSync(join(seed, DESKTOP_PACKAGES_DIR, entry.file), entry.body) + writeFileSync(join(seed, DESKTOP_PACKAGE_SET_FILE), `${JSON.stringify({ + schemaVersion: 1, + packages: packages.map(({ name, file, body }) => ({ + name, + version, + file, + bytes: body.byteLength, + integrity: `sha512-${createHash('sha512').update(body).digest('base64')}`, + })), + })}\n`) +} + +function createTestSeedMetadata(seed: string, desktopRelease: DesktopRelease): void { + writeCorePackageSet(seed, desktopRelease.version) + createSeedMetadata(seed, desktopRelease) +} + +function writeFakePnpm(root: string): string { + const path = join(root, 'pnpm.mjs') + writeFileSync(path, String.raw` +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +const args = process.argv.slice(2) +const project = process.cwd() +const command = args.find(value => value === 'install' || value === 'add' || value === 'remove') +const manifestPath = join(project, 'package.json') +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) +const packageName = spec => spec.startsWith('@') + ? spec.slice(0, spec.indexOf('@', spec.indexOf('/') + 1) === -1 ? undefined : spec.indexOf('@', spec.indexOf('/') + 1)) + : spec.split('@')[0] +const packageVersion = spec => { + const index = spec.startsWith('@') ? spec.indexOf('@', spec.indexOf('/') + 1) : spec.indexOf('@') + return index === -1 ? '1.0.0' : spec.slice(index + 1) +} + +if (command === 'add') { + const spec = args[args.indexOf('add') + 1] + manifest.dependencies[packageName(spec)] = packageVersion(spec) +} +if (command === 'remove') delete manifest.dependencies[args[args.indexOf('remove') + 1]] +writeFileSync(manifestPath, JSON.stringify(manifest)) +rmSync(join(project, 'node_modules'), { recursive: true, force: true }) +for (const [name, version] of Object.entries(manifest.dependencies)) { + const packageRoot = join(project, 'node_modules', ...name.split('/')) + mkdirSync(packageRoot, { recursive: true }) + const core = name === '@deepseek-ai/dsh' || name === '@deepseek-ai/dsh-desktop-host' + const plugin = !core + const installedVersion = plugin + ? version + : JSON.parse(readFileSync(join(project, 'desktop-release.json'), 'utf8')).version + writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({ + name, version: installedVersion, + ...(plugin ? { dsh: { bundle: { patch: './bundle.yml' } } } : {}), + })) + if (plugin) writeFileSync(join(packageRoot, 'bundle.yml'), '[]\n') + else if (name === '@deepseek-ai/dsh-desktop-host') { + mkdirSync(join(packageRoot, 'lib'), { recursive: true }) + writeFileSync(join(packageRoot, 'lib', 'index.js'), '') + } +} +writeFileSync(join(project, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') +if (process.env.TEST_PNPM_LOG) writeFileSync(process.env.TEST_PNPM_LOG, JSON.stringify({ args, env: process.env })) +`) + return path +} + +function writeBlockingFakePnpm(root: string, ready: string, release: string): string { + const path = join(root, 'blocking-pnpm.mjs') + const delegate = writeFakePnpm(root) + writeFileSync(path, ` +import { existsSync, writeFileSync } from 'node:fs' +import { setTimeout as sleep } from 'node:timers/promises' +writeFileSync(${JSON.stringify(ready)}, String(process.pid)) +while (!existsSync(${JSON.stringify(release)})) await sleep(10) +await import(${JSON.stringify(pathToFileURL(delegate).href)}) +`) + return path +} + +function hooks(overrides: Partial = {}): DesktopProjectHooks { + return { + healthCheck: async () => {}, + beforeActivate: async () => {}, + afterActivate: async () => {}, + ...overrides, + } +} + +function release(version = '1.0.0'): DesktopRelease { + return { + schemaVersion: 1, + version, + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: '24.17.0', + pnpmVersion: '11.7.0', + } +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('desktop package policy', () => { + it('accepts registry package specs but rejects alternate sources and flags', () => { + expect(packageNameFromSpec('@scope/plugin@1.2.3')).toBe('@scope/plugin') + expect(packageNameFromSpec('plugin@next')).toBe('plugin') + expect(() => packageNameFromSpec('file:../plugin')).toThrow(/unsupported npm package spec/u) + expect(() => packageNameFromSpec('--registry=evil')).toThrow(/unsupported npm package spec/u) + expect(() => packageNameFromSpec('https://example.test/plugin.tgz')).toThrow(/unsupported npm package spec/u) + }) + + it('rejects any seed content changed after release inventory generation', () => { + const seed = join(temporaryRoot(), 'seed') + createTestSeedMetadata(seed, release()) + writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') + writeIntegrity(seed) + expect(() => { verifySeedIntegrity(seed) }).not.toThrow() + writeFileSync(join(seed, 'package.json'), '{}\n') + expect(() => { verifySeedIntegrity(seed) }).toThrow(/integrity verification failed/u) + }) +}) + +describe('desktop project transactions', () => { + it('installs the offline seed and reconciles a mismatched private Host', async () => { + const root = temporaryRoot() + const seed = join(root, 'seed') + const log = join(root, 'pnpm-log.json') + createTestSeedMetadata(seed, release()) + writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') + mkdirSync(join(seed, 'store'), { recursive: true }) + writeFileSync(join(seed, 'store', 'seed-entry'), 'content') + archiveStore(seed) + writeIntegrity(seed) + const paths = resolveDesktopPaths(join(root, '.dsh')) + const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) + const previousLog = process.env.TEST_PNPM_LOG + const previousRegistry = process.env.npm_config_registry + process.env.TEST_PNPM_LOG = log + process.env.npm_config_registry = 'https://user-registry.invalid' + try { + await expect(manager.applyRelease(seed, '2.0.0', hooks())).rejects.toThrow(/does not match Electron/u) + await manager.applyRelease(seed, '1.0.0', hooks()) + writeFileSync( + join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'package.json'), + '{"name":"@deepseek-ai/dsh-desktop-host","version":"0.9.0"}\n', + ) + await expect(manager.applyRelease(seed, '1.0.0', hooks())).resolves.toBe(true) + } finally { + if (previousLog === undefined) delete process.env.TEST_PNPM_LOG + else process.env.TEST_PNPM_LOG = previousLog + if (previousRegistry === undefined) delete process.env.npm_config_registry + else process.env.npm_config_registry = previousRegistry + } + expect(manager.dshVersion()).toBe('1.0.0') + expect(manager.releaseVersion()).toBe('1.0.0') + expect(paths.profile).toBe(join(root, '.dsh', 'profiles', 'desktop')) + expect(existsSync(join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh'))).toBe(true) + const installedHost = JSON.parse(readFileSync( + join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'package.json'), + 'utf8', + )) as { version: string } + expect(installedHost.version).toBe('1.0.0') + expect(existsSync(join(paths.profile, 'desktop-plugins.json'))).toBe(false) + expect(readFileSync(join(paths.pnpm.store, 'seed-entry'), 'utf8')).toBe('content') + const invocation = JSON.parse(readFileSync(log, 'utf8')) as { args: string[]; env: Record } + expect(invocation.args).toContain('--offline') + expect(invocation.args).toContain('--trust-lockfile') + expect(invocation.args).toContain(`--config.store-dir=${paths.pnpm.store}`) + expect(invocation.args).toContain('--config.enable-global-virtual-store=false') + expect(invocation.args).toContain('--config.registry=https://registry.npmjs.org/') + expect(invocation.env.NPM_CONFIG_REGISTRY).toBe('https://registry.npmjs.org/') + expect(invocation.env.NPM_CONFIG_STORE_DIR).toBe(paths.pnpm.store) + expect(invocation.env.NPM_CONFIG_USERCONFIG).toBe(join(paths.pnpm.config, 'npmrc')) + expect(invocation.env.npm_config_registry).toBeUndefined() + }) + + it('restores the active project when the replacement backend cannot start', async () => { + const root = temporaryRoot() + const seed = join(root, 'seed') + createTestSeedMetadata(seed, release()) + writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') + archiveStore(seed) + writeIntegrity(seed) + const paths = resolveDesktopPaths(join(root, '.dsh')) + const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) + await manager.applyRelease(seed, '1.0.0', hooks()) + let starts = 0 + await expect(manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks({ + afterActivate: async () => { + starts += 1 + if (starts === 1) throw new Error('backend rejected staged graph') + }, + }))).rejects.toThrow(/backend rejected staged graph/u) + expect(manager.listPlugins()).toEqual([]) + expect(manager.dshVersion()).toBe('1.0.0') + expect(starts).toBe(2) + }) + + it('restores rollback when the active move completed before its journal update', async () => { + const root = temporaryRoot() + const seed = join(root, 'seed') + createTestSeedMetadata(seed, release()) + writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') + archiveStore(seed) + writeIntegrity(seed) + const paths = resolveDesktopPaths(join(root, '.dsh')) + const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) + await manager.applyRelease(seed, '1.0.0', hooks()) + await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks()) + const stagingProfile = join(paths.staging, 'interrupted', 'profile') + mkdirSync(stagingProfile, { recursive: true }) + writeFileSync(join(stagingProfile, 'marker'), 'staging') + rmSync(paths.rollback, { recursive: true, force: true }) + mkdirSync(dirname(paths.rollback), { recursive: true }) + renameSync(paths.profile, paths.rollback) + writeFileSync(paths.pending, `${JSON.stringify({ + schemaVersion: 1, + id: 'interrupted', + stagingProfile, + step: 'prepared', + })}\n`) + + manager.recover() + + expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0' }]) + expect(existsSync(stagingProfile)).toBe(false) + expect(existsSync(paths.pending)).toBe(false) + }) + + it('records the live pnpm worker as transaction owner until it exits', async () => { + const root = temporaryRoot() + const seed = join(root, 'seed') + const ready = join(root, 'pnpm-ready') + const releaseWorker = join(root, 'pnpm-release') + createTestSeedMetadata(seed, release()) + writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') + archiveStore(seed) + writeIntegrity(seed) + const paths = resolveDesktopPaths(join(root, '.dsh')) + const runtime = { node: process.execPath, pnpm: writeBlockingFakePnpm(root, ready, releaseWorker) } + const manager = new DesktopProjectManager(paths, runtime) + const installing = manager.applyRelease(seed, '1.0.0', hooks()) + await expect.poll(() => existsSync(ready)).toBe(true) + const workerPid = Number.parseInt(readFileSync(ready, 'utf8'), 10) + expect(readFileSync(paths.lock, 'utf8')).toBe(`${String(workerPid)}\n`) + const competing = new DesktopProjectManager(paths, runtime) + await expect(competing.applyRelease(seed, '1.0.0', hooks())).rejects.toThrow(/another package transaction is active/u) + writeFileSync(releaseWorker, 'continue') + await expect(installing).resolves.toBe(true) + expect(existsSync(paths.lock)).toBe(false) + }) + + it('keeps core packages local while installing plugins from the desktop registry', async () => { + const root = temporaryRoot() + const seed = join(root, 'seed') + const log = join(root, 'pnpm-log.json') + createTestSeedMetadata(seed, release()) + writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') + archiveStore(seed) + writeIntegrity(seed) + const paths = resolveDesktopPaths(join(root, '.dsh')) + const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) + await manager.applyRelease(seed, '1.0.0', hooks()) + const previousLog = process.env.TEST_PNPM_LOG + process.env.TEST_PNPM_LOG = log + try { + await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks()) + } finally { + if (previousLog === undefined) delete process.env.TEST_PNPM_LOG + else process.env.TEST_PNPM_LOG = previousLog + } + + const manifest = JSON.parse(readFileSync(join(paths.profile, 'package.json'), 'utf8')) as { + dependencies: Record + } + const coreSpec = manifest.dependencies['@deepseek-ai/dsh'] + expect(coreSpec).toMatch(/^file:\.\/desktop-packages\//u) + expect(readFileSync(join(paths.profile, 'pnpm-workspace.yaml'), 'utf8')) + .toContain(`${JSON.stringify('@deepseek-ai/dsh')}: ${JSON.stringify(coreSpec)}`) + expect(manifest.dependencies['@scope/plugin']).toBe('2.0.0') + const invocation = JSON.parse(readFileSync(log, 'utf8')) as { args: string[]; env: Record } + expect(invocation.args).toContain('add') + expect(invocation.args).toContain('@scope/plugin@2.0.0') + expect(invocation.args).toContain('--config.registry=https://registry.npmjs.org/') + expect(invocation.env.NPM_CONFIG_REGISTRY).toBe('https://registry.npmjs.org/') + }) + + it('reconciles dsh to the packaged release without removing desktop plugins', async () => { + const root = temporaryRoot() + const paths = resolveDesktopPaths(join(root, '.dsh')) + const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) + const firstSeed = join(root, 'seed-1') + createTestSeedMetadata(firstSeed, release('1.0.0')) + writeFileSync(join(firstSeed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') + mkdirSync(join(firstSeed, 'store'), { recursive: true }) + writeFileSync(join(firstSeed, 'store', 'release-1'), 'one') + archiveStore(firstSeed) + writeIntegrity(firstSeed) + await manager.applyRelease(firstSeed, '1.0.0', hooks()) + await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks()) + + const nextSeed = join(root, 'seed-2') + createTestSeedMetadata(nextSeed, release('1.1.0')) + writeFileSync(join(nextSeed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') + mkdirSync(join(nextSeed, 'store'), { recursive: true }) + writeFileSync(join(nextSeed, 'store', 'release-2'), 'two') + archiveStore(nextSeed) + writeIntegrity(nextSeed) + + await expect(manager.applyRelease(nextSeed, '1.1.0', hooks())).resolves.toBe(true) + expect(manager.releaseVersion()).toBe('1.1.0') + expect(manager.dshVersion()).toBe('1.1.0') + expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0' }]) + const profile = JSON.parse(readFileSync(join(paths.profile, 'package.json'), 'utf8')) as { + dsh: { profile: { bundles: string[] } } + } + expect(profile.dsh.profile.bundles).toEqual([ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-web-app', + '@scope/plugin', + ]) + expect(readFileSync(join(paths.pnpm.store, 'release-1'), 'utf8')).toBe('one') + expect(readFileSync(join(paths.pnpm.store, 'release-2'), 'utf8')).toBe('two') + await expect(manager.applyRelease(nextSeed, '1.1.0', hooks())).resolves.toBe(false) + }) +}) diff --git a/apps/desktop/tests/seed-store.spec.ts b/apps/desktop/tests/seed-store.spec.ts new file mode 100644 index 0000000000..b2f5fc614a --- /dev/null +++ b/apps/desktop/tests/seed-store.spec.ts @@ -0,0 +1,167 @@ +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterEach, describe, expect, it } from 'vitest' +import { + archivePnpmStore, + extractPnpmStoreArchives, + mergePnpmStore, + removePnpmProjectRegistrations, + SEED_STORE_ARCHIVE_DIR, + SEED_STORE_ARCHIVE_MANIFEST, +} from '../src/seed-store.ts' + +const temporaryRoots: string[] = [] + +function temporaryRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-store-')) + temporaryRoots.push(root) + return root +} + +function archiveBytes(seed: string): readonly { path: string; body: Buffer }[] { + return [SEED_STORE_ARCHIVE_MANIFEST, ...readdirSync(join(seed, SEED_STORE_ARCHIVE_DIR))] + .map(path => ({ + path, + body: readFileSync(path === SEED_STORE_ARCHIVE_MANIFEST + ? join(seed, path) + : join(seed, SEED_STORE_ARCHIVE_DIR, path)), + })) +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('desktop seed store cleanup', () => { + it('removes project registrations without removing package data', () => { + const storeRoot = temporaryRoot() + mkdirSync(join(storeRoot, 'v11', 'projects', 'temporary-project'), { recursive: true }) + mkdirSync(join(storeRoot, 'v12', 'projects'), { recursive: true }) + mkdirSync(join(storeRoot, 'metadata', 'projects'), { recursive: true }) + writeFileSync(join(storeRoot, 'v11', 'package-data'), 'package') + + removePnpmProjectRegistrations(storeRoot) + + expect(existsSync(join(storeRoot, 'v11', 'projects'))).toBe(false) + expect(existsSync(join(storeRoot, 'v12', 'projects'))).toBe(false) + expect(existsSync(join(storeRoot, 'v11', 'package-data'))).toBe(true) + expect(existsSync(join(storeRoot, 'metadata', 'projects'))).toBe(true) + }) +}) + +describe('desktop seed store merge', () => { + it('preserves installed package records while the verified seed replaces matching records and files', () => { + const root = temporaryRoot() + const source = join(root, 'source') + const destination = join(root, 'destination') + for (const store of [source, destination]) { + mkdirSync(join(store, 'v11', 'files'), { recursive: true }) + const database = new DatabaseSync(join(store, 'v11', 'index.db')) + database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID') + const insert = database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)') + if (store === source) { + insert.run('seed-only', Buffer.from('seed')) + insert.run('shared', Buffer.from('new')) + } else { + insert.run('plugin-only', Buffer.from('plugin')) + insert.run('shared', Buffer.from('old')) + } + database.close() + } + writeFileSync(join(source, 'v11', 'files', 'shared'), 'new') + writeFileSync(join(destination, 'v11', 'files', 'shared'), 'old') + writeFileSync(join(destination, 'v11', 'files', 'plugin'), 'plugin') + + mergePnpmStore(source, destination) + + const database = new DatabaseSync(join(destination, 'v11', 'index.db'), { readOnly: true }) + const records = database.prepare('SELECT key, data FROM package_index ORDER BY key').all() as { + key: string + data: Uint8Array + }[] + database.close() + expect(records.map(record => [record.key, Buffer.from(record.data).toString()])).toEqual([ + ['plugin-only', 'plugin'], + ['seed-only', 'seed'], + ['shared', 'new'], + ]) + expect(readFileSync(join(destination, 'v11', 'files', 'shared'), 'utf8')).toBe('new') + expect(readFileSync(join(destination, 'v11', 'files', 'plugin'), 'utf8')).toBe('plugin') + }) +}) + +describe('desktop seed store archives', () => { + it('extracts package bytes and executable modes without retaining loose seed files', () => { + const root = temporaryRoot() + const seed = join(root, 'seed') + const store = join(seed, 'store') + const executable = join(store, 'v10', 'files', 'native-addon') + mkdirSync(join(store, 'v10', 'files'), { recursive: true }) + writeFileSync(executable, 'native') + chmodSync(executable, 0o755) + writeFileSync(join(store, 'v10', 'files', 'package-data'), 'package') + + archivePnpmStore(seed, store) + const destination = join(root, 'extracted') + extractPnpmStoreArchives(seed, destination) + + expect(existsSync(store)).toBe(false) + expect(readFileSync(join(destination, 'v10', 'files', 'package-data'), 'utf8')).toBe('package') + if (process.platform !== 'win32') { + expect(statSync(join(destination, 'v10', 'files', 'native-addon')).mode & 0o111).toBe(0o111) + } + }) + + it('produces identical shards for identical paths, bytes, and modes', () => { + const root = temporaryRoot() + const seeds = [join(root, 'first'), join(root, 'second')] + for (const [index, seed] of seeds.entries()) { + const store = join(seed, 'store') + mkdirSync(join(store, 'nested'), { recursive: true }) + const paths = index === 0 ? ['alpha', 'nested/beta'] : ['nested/beta', 'alpha'] + for (const path of paths) { + const target = join(store, path) + writeFileSync(target, path) + utimesSync(target, new Date(index * 10_000), new Date(index * 20_000)) + } + archivePnpmStore(seed, store) + } + + const first = archiveBytes(seeds[0] as string) + const second = archiveBytes(seeds[1] as string) + expect(second.map(entry => entry.path)).toEqual(first.map(entry => entry.path)) + expect(second.map(entry => entry.body)).toEqual(first.map(entry => entry.body)) + }) + + it('rejects an archive whose entry count differs from the manifest', () => { + const root = temporaryRoot() + const seed = join(root, 'seed') + const store = join(seed, 'store') + mkdirSync(store, { recursive: true }) + writeFileSync(join(store, 'package-data'), 'package') + archivePnpmStore(seed, store) + const manifestPath = join(seed, SEED_STORE_ARCHIVE_MANIFEST) + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + archives: { entries: number }[] + } + const archive = manifest.archives[0] + if (archive === undefined) throw new Error('test seed has no archive') + archive.entries += 1 + writeFileSync(manifestPath, JSON.stringify(manifest)) + + expect(() => { extractPnpmStoreArchives(seed, join(root, 'extracted')) }).toThrow(/unexpected entry count/u) + }) +}) diff --git a/apps/desktop/tests/single-instance.spec.ts b/apps/desktop/tests/single-instance.spec.ts new file mode 100644 index 0000000000..220f0d5a30 --- /dev/null +++ b/apps/desktop/tests/single-instance.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' +import { claimDesktopSingleInstance, type DesktopSingleInstanceApplication } from '../src/single-instance.ts' + +describe('desktop single-instance ownership', () => { + it('quits a second process without registering lifecycle work', () => { + const quit = vi.fn() + const on = vi.fn() + const application = { + requestSingleInstanceLock: () => false, + quit, + on, + } satisfies DesktopSingleInstanceApplication + + expect(claimDesktopSingleInstance(application, vi.fn())).toBe(false) + expect(quit).toHaveBeenCalledOnce() + expect(on).not.toHaveBeenCalled() + }) + + it('routes a later launch to the primary process', () => { + let secondInstance: (() => void) | undefined + const focus = vi.fn() + const application = { + requestSingleInstanceLock: () => true, + quit: vi.fn(), + on: vi.fn((_event: 'second-instance', listener: () => void) => { secondInstance = listener }), + } satisfies DesktopSingleInstanceApplication + + expect(claimDesktopSingleInstance(application, focus)).toBe(true) + secondInstance?.() + expect(focus).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts new file mode 100644 index 0000000000..272ef03a1d --- /dev/null +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppUpdater } from 'electron-updater' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import { parseDesktopRelease } from '../src/release.ts' +import type { DesktopUpdateState } from '../src/ipc.ts' + +vi.mock('electron', () => ({ app: { isPackaged: false } })) +vi.mock('electron-updater', () => ({ + default: { autoUpdater: { autoDownload: true, autoInstallOnAppQuit: true } }, +})) + +const { DesktopUpdateCoordinator } = await import('../src/update-coordinator.ts') + +describe('desktop release metadata', () => { + it('accepts one exact release identity for Electron and dsh', () => { + expect(parseDesktopRelease({ + schemaVersion: 1, + version: '1.2.3', + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: '24.17.0', + pnpmVersion: '11.7.0', + })).toEqual({ + schemaVersion: 1, + version: '1.2.3', + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: '24.17.0', + pnpmVersion: '11.7.0', + }) + }) + + it('rejects invalid versions and unsupported host protocols', () => { + const base = { + schemaVersion: 1, + version: '1.2.3', + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: '24.17.0', + pnpmVersion: '11.7.0', + } + expect(() => parseDesktopRelease({ ...base, version: 'latest' })).toThrow(/invalid desktop release metadata/u) + expect(() => parseDesktopRelease({ ...base, hostProtocolVersion: 999 })).toThrow(/invalid desktop release metadata/u) + }) +}) + +describe('desktop update coordinator', () => { + it('installs one Electron release and restarts after download', async () => { + const states: DesktopUpdateState[] = [] + const downloadUpdate = vi.fn(async () => []) + const quitAndInstall = vi.fn() + const beforeRestart = vi.fn(async () => {}) + const updater = { + autoDownload: true, + autoInstallOnAppQuit: true, + checkForUpdates: vi.fn(async () => ({ + isUpdateAvailable: true, + updateInfo: { version: '1.1.0' }, + })), + downloadUpdate, + quitAndInstall, + } as unknown as AppUpdater + const coordinator = new DesktopUpdateCoordinator( + (state) => { + states.push(state) + return state + }, + beforeRestart, + updater, + () => true, + ) + + await expect(coordinator.check()).resolves.toEqual({ phase: 'available', version: '1.1.0' }) + await expect(coordinator.install()).resolves.toEqual({ phase: 'ready', version: '1.1.0' }) + expect(downloadUpdate).toHaveBeenCalledOnce() + expect(beforeRestart).toHaveBeenCalledOnce() + expect(quitAndInstall).toHaveBeenCalledWith(false, true) + expect(states.map(state => state.phase)).toEqual(['checking', 'available', 'installing', 'ready']) + }) + + it('queues install behind an in-flight check instead of returning the check result', async () => { + const checked = Promise.withResolvers<{ + isUpdateAvailable: true + updateInfo: { version: string } + }>() + const downloadUpdate = vi.fn(async () => []) + const updater = { + autoDownload: true, + autoInstallOnAppQuit: true, + checkForUpdates: vi.fn(() => checked.promise), + downloadUpdate, + quitAndInstall: vi.fn(), + } as unknown as AppUpdater + const coordinator = new DesktopUpdateCoordinator(state => state, async () => {}, updater, () => true) + + const checking = coordinator.check() + const installing = coordinator.install() + expect(downloadUpdate).not.toHaveBeenCalled() + checked.resolve({ isUpdateAvailable: true, updateInfo: { version: '1.2.0' } }) + + await expect(checking).resolves.toEqual({ phase: 'available', version: '1.2.0' }) + await expect(installing).resolves.toEqual({ phase: 'ready', version: '1.2.0' }) + expect(downloadUpdate).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/tests/windows-sign.spec.ts b/apps/desktop/tests/windows-sign.spec.ts new file mode 100644 index 0000000000..9005d24f6f --- /dev/null +++ b/apps/desktop/tests/windows-sign.spec.ts @@ -0,0 +1,226 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + buildWindowsSigningEnvironment, + createRedactedWindowsSigningError, + createWindowsTokenSigner, + installWindowsNsisBootstrapSigner, + repairDanglingAuthenticodeDirectory, + scrubWindowsSigningEnvironment, +} from '../scripts/windows-sign.mjs' + +vi.mock('node:crypto', () => ({ + X509Certificate: class { + readonly ca = false + readonly keyUsage = ['1.3.6.1.5.5.7.3.3'] + + constructor(contents: Buffer) { + if (contents.toString('utf8') !== 'code-signing-certificate-fixture') { + throw new Error('invalid test certificate') + } + } + }, +})) + +const CERTIFICATE_FILE = 'C:\\release\\server.cer' +const SIGN_SCRIPT = resolve(import.meta.dirname, '../scripts/windows-sign.cmd') + +describe('Windows token signing', () => { + it('passes only the validated BAT fields to the signing command interpreter', () => { + expect(buildWindowsSigningEnvironment({ + SystemRoot: 'C:\\Windows', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'inherited-token-secret', + DEEPSEEK_API_KEY: 'api-secret', + BUILD_PASSWORD: 'build-secret', + }, { + certificateFile: CERTIFICATE_FILE, + signTool: 'C:\\tools\\signtool.exe', + path: 'C:\\release\\DeepSeek Harness.exe', + isNest: false, + tokenPin: 'token-secret!', + keyContainer: 'te-container', + })).toEqual({ + SystemRoot: 'C:\\Windows', + DSH_DESKTOP_WINDOWS_SIGNTOOL: 'C:\\tools\\signtool.exe', + DSH_DESKTOP_WINDOWS_CER_FILE: CERTIFICATE_FILE, + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret!', + DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'te-container', + DSH_DESKTOP_WINDOWS_SIGN_TARGET: 'C:\\release\\DeepSeek Harness.exe', + DSH_DESKTOP_WINDOWS_SIGN_APPEND: '', + }) + }) + + it('requests an appended signature only for an electron-builder nested task', () => { + expect(buildWindowsSigningEnvironment({}, { + certificateFile: CERTIFICATE_FILE, + signTool: 'C:\\tools\\signtool.exe', + path: 'C:\\release\\setup.exe', + isNest: true, + tokenPin: 'token-secret!', + keyContainer: 'te-container', + }).DSH_DESKTOP_WINDOWS_SIGN_APPEND).toBe('1') + }) + + it('keeps the verified SafeNet command in an ASCII CRLF CMD file', async () => { + const contents = await readFile(SIGN_SCRIPT) + const text = contents.toString('ascii') + expect(contents.every(byte => byte <= 0x7F)).toBe(true) + expect(text).toContain('\r\n') + expect(text.replaceAll('\r\n', '')).not.toContain('\n') + expect(text).toContain('setlocal DisableDelayedExpansion\r\n') + expect(text).toContain('set "DSH_DESKTOP_WINDOWS_CER_FILE="\r\n') + expect(text).toContain('set "DSH_DESKTOP_WINDOWS_TOKEN_PIN="\r\n') + expect(text).toContain('"%signTool%" sign /v /fd sha256 /f "%certificateFile%" /kc "[{{%tokenPin%}}]=%keyContainer%" /csp "eToken Base Cryptographic Provider" %appendSignature% /tr http://timestamp.digicert.com /td sha256 "%targetFile%"\r\n') + }) + + it('rejects incomplete signing identities and non-SHA-256 signing tasks', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-sign-tool-')) + const certificateFile = join(directory, 'server.cer') + const signTool = join(directory, 'signtool.exe') + await writeFile(certificateFile, 'code-signing-certificate-fixture') + await writeFile(signTool, 'fixture') + expect(() => createWindowsTokenSigner({ + certificateFile: undefined, + signTool, + tokenPin: 'token-secret!', + keyContainer: 'te-container', + })).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u) + expect(() => createWindowsTokenSigner({ + certificateFile, + signTool: undefined, + tokenPin: 'token-secret!', + keyContainer: 'te-container', + })).toThrow(/DSH_DESKTOP_WINDOWS_SIGNTOOL/u) + const signer = createWindowsTokenSigner({ + certificateFile, + signTool, + tokenPin: 'token-secret!', + keyContainer: 'te-container', + }) + try { + expect(() => createWindowsTokenSigner({ + certificateFile, + signTool, + tokenPin: 'token-secret!', + })).toThrow(/DSH_DESKTOP_WINDOWS_KEY_CONTAINER/u) + expect(() => createWindowsTokenSigner({ + certificateFile, + signTool, + tokenPin: '', + keyContainer: 'te-container', + })).toThrow(/DSH_DESKTOP_WINDOWS_TOKEN_PIN/u) + expect(() => createWindowsTokenSigner({ + certificateFile, + signTool, + tokenPin: 'token]secret', + keyContainer: 'te-container', + })).toThrow(/cannot contain/u) + await expect(signer({ + path: 'C:\\release\\setup.exe', + hash: 'sha1', + isNest: false, + })).rejects.toThrow(/requires SHA-256/u) + } + finally { + await rm(directory, { recursive: true }) + } + }) + + it('removes inherited credentials and redacts SignTool process failures', () => { + expect(scrubWindowsSigningEnvironment({ + SystemRoot: 'C:\\Windows', + DSH_DESKTOP_WINDOWS_CER_FILE: 'C:\\release\\server.cer', + DSH_DESKTOP_WINDOWS_SIGNTOOL: 'C:\\tools\\signtool.exe', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret', + DEEPSEEK_API_KEY: 'api-secret', + BUILD_PASSWORD: 'build-secret', + })).toEqual({ SystemRoot: 'C:\\Windows' }) + + const processError = Object.assign(new Error('failed'), { + code: 1, + cmd: 'signtool /kc [{{token-secret}}]=te-container', + stderr: 'provider rejected token-secret', + }) + const failure = createRedactedWindowsSigningError( + processError, + 'C:\\release\\setup.exe', + ['token-secret'], + ) + expect(failure.message).toBe('Windows release signing failed for C:\\release\\setup.exe (exit 1): provider rejected ') + expect(failure.message).not.toContain('token-secret') + expect(failure).not.toHaveProperty('cause') + expect(failure).not.toHaveProperty('cmd') + }) + + it('signs the temporary NSIS executable before enterprise policy evaluates it', async () => { + const events: string[] = [] + let receivedEnvironment: NodeJS.ProcessEnv | undefined + class FakeWineVmManager { + async exec( + file: string, + _args: string[], + options?: { env?: NodeJS.ProcessEnv }, + ): Promise { + events.push(`exec:${file}`) + receivedEnvironment = options?.env + return 'executed' + } + } + installWindowsNsisBootstrapSigner({ + sign: async (configuration) => { + events.push(`sign:${configuration.path}:${configuration.hash}:${String(configuration.isNest)}`) + }, + wineVmManager: FakeWineVmManager, + platform: 'win32', + environment: { + SystemRoot: 'C:\\Windows', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret', + }, + }) + + const result = await new FakeWineVmManager().exec('C:\\release\\setup.exe', [], { + env: { + __COMPAT_LAYER: 'RunAsInvoker', + BUILD_PASSWORD: 'build-secret', + }, + }) + + expect(result).toBe('executed') + expect(events).toEqual([ + 'sign:C:\\release\\setup.exe:sha256:false', + 'exec:C:\\release\\setup.exe', + ]) + expect(receivedEnvironment).toEqual({ + SystemRoot: 'C:\\Windows', + __COMPAT_LAYER: 'RunAsInvoker', + }) + }) + + it('clears a certificate table inherited beyond the generated uninstaller', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-sign-')) + const path = join(directory, 'uninstaller.exe') + const executable = Buffer.alloc(512) + const peOffset = 216 + const optionalHeaderOffset = peOffset + 24 + const certificateDirectoryOffset = optionalHeaderOffset + 96 + (4 * 8) + executable.write('MZ', 0, 'ascii') + executable.writeUInt32LE(peOffset, 60) + executable.write('PE\0\0', peOffset, 'ascii') + executable.writeUInt16LE(0x10B, optionalHeaderOffset) + executable.writeUInt32LE(600, certificateDirectoryOffset) + executable.writeUInt32LE(100, certificateDirectoryOffset + 4) + await writeFile(path, executable) + try { + await expect(repairDanglingAuthenticodeDirectory(path)).resolves.toBe(true) + const repaired = await readFile(path) + expect(repaired.readUInt32LE(certificateDirectoryOffset)).toBe(0) + expect(repaired.readUInt32LE(certificateDirectoryOffset + 4)).toBe(0) + await expect(repairDanglingAuthenticodeDirectory(path)).resolves.toBe(false) + } + finally { + await rm(directory, { recursive: true }) + } + }) +}) diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 0000000000..963a3f71c5 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../packages/util/home-paths" } + ] +} diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts new file mode 100644 index 0000000000..6f7fa58c8d --- /dev/null +++ b/apps/desktop/tsdown.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig([ + { + entry: ['lib/types/main.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + deps: { neverBundle: ['electron'] }, + }, + { + // Sandboxed Electron preloads run as CommonJS even though the application package is ESM. + entry: { + preload: 'lib/types/preload.js', + 'preload-app': 'lib/types/preload-app.js', + }, + outDir: 'lib', + format: ['cjs'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + deps: { neverBundle: ['electron'] }, + }, +]) diff --git a/apps/web/package.json b/apps/web/package.json index 3c9aa151da..47946e5b22 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, @@ -33,6 +33,7 @@ "@deepseek-ai/cordis-plugin-group": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web": "workspace:^", diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index b903c0c102..239b25f7ef 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -1,10 +1,8 @@ -// Keyless browser regression for the details column's default visibility and Session ownership. -// The shipped composition starts closed after selection and reload, retains an explicitly opened width through -// unselected states, and closes it only when a different Session takes ownership. -import { readFile } from 'node:fs/promises' +// Recorded-session Sidebar geometry and per-Session view state through the shipped browser composition. +import { mkdir, readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' -import type { Browser, Page } from 'playwright' +import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { @@ -16,6 +14,8 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/details-session-lifecycle', import.meta.url)) const HANDLES_EXPECTED = join(SNAPSHOT_DIR, 'handles.expected.md') +const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md') +const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0907-sidebar-rules', import.meta.url)) const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v2.jsonl', import.meta.url)) const SEED_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url)) const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' @@ -64,6 +64,64 @@ async function handleSnapshot(page: Page): Promise { ].join('\n').trimEnd() } +/** Rendered frame tracks, rounded only to remove browser subpixel allocation. */ +async function columns(page: Page): Promise { + return await appFrame(page).evaluate(element => + getComputedStyle(element).gridTemplateColumns.split(' ').map(value => Math.round(Number.parseFloat(value)))) +} + +/** Tab order and selection inside each docked pane, independent of generated ids. */ +async function paneSnapshot(page: Page) { + return await page.locator('[data-rightbar-col] [data-dockkit-pane]').evaluateAll(panes => panes.map(pane => ({ + active: pane.hasAttribute('data-dockkit-pane-active'), + tabs: [...pane.querySelectorAll('[data-dockkit-tab]')].map(tab => ({ + title: tab.querySelector('[data-dockkit-tab-title]')?.textContent?.trim(), + selected: tab.getAttribute('aria-selected') === 'true', + })), + }))) +} + +/** Product-visible geometry, pane state, and expanded Files directories at a settled checkpoint. */ +async function sidebarSnapshot(page: Page) { + const geometry = await appFrame(page).evaluate((frame) => { + const panel = frame.querySelector('[data-sidebar-right-panel]') + if (panel === null) throw new Error('Sidebar panel is not mounted') + const expanded = panel.hasAttribute('data-sidebar-right-open') + const rect = panel.getBoundingClientRect() + const style = getComputedStyle(panel) + const handle = frame.querySelector('[data-side="rightbar"]') + return { + viewport: [window.innerWidth, window.innerHeight], + columns: getComputedStyle(frame).gridTemplateColumns.split(' ').map(value => Math.round(Number.parseFloat(value))), + columnTransition: getComputedStyle(frame).transitionProperty, + expanded, + mode: panel.getAttribute('data-sidebar-right-panel'), + panelContentWidth: expanded ? Math.round(Number.parseFloat(style.width)) : 0, + panelOuterWidth: expanded ? Math.round(rect.width) : 0, + coversViewport: expanded && rect.x === 0 && rect.y === 0 + && Math.round(rect.width) === window.innerWidth && Math.round(rect.height) === window.innerHeight, + resizeHandleWidth: handle === null ? 0 : Math.round(handle.getBoundingClientRect().width), + expandedDirectories: [...panel.querySelectorAll('[data-files-entry="directory"] > button[aria-expanded="true"]')] + .map(button => button.textContent?.trim()), + } + }) + return { ...geometry, panes: await paneSnapshot(page) } +} + +/** The native pointer gesture used for the left column's width preference. */ +async function dragSidebar(page: Page, target: number): Promise { + const grip = await page.locator('[data-side="sidebar"]').boundingBox() + if (grip === null) throw new Error('Sidebar resize handle is not rendered') + await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2) + await page.mouse.down() + try { + await page.mouse.move(target, grip.y + grip.height / 2, { steps: 6 }) + } finally { + await page.mouse.up() + } + await expect.poll(() => sidebarTrack(page)).toBe(target) +} + describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => { let scaffold: WebScaffold let browser: Browser @@ -84,12 +142,18 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S }, 120_000) afterAll(async () => { - await browser?.close() - await scaffold?.close() + try { + await browser?.close() + } finally { + await scaffold?.close() + } }) - it('starts and reloads closed, then stays closed across Session ownership changes', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-details-session-lifecycle')) + it('retains each Session sidebar and applies normal, fullscreen, and capacity-close geometry', async () => { + onTestFailed(async () => { + await mkdir(SHOT_DIR, { recursive: true }) + await saveFailureShot(page, `screenshots/0907-sidebar-rules/details-session-lifecycle-${MODE}-${process.pid}`) + }) const settled = scaffold.whenTurnSettled() const input = page.locator('[data-composer-input]').first() await input.fill(PROMPT) @@ -125,7 +189,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) - const original = page.locator('[role=treeitem]').filter({ hasText: 'Reply with the single word' }).first() + const original = page.locator('[role="treeitem"][aria-selected]').filter({ hasText: 'Reply with the single word' }).first() await original.click() await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) @@ -133,20 +197,150 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S const ungrouped = page.getByText('Ungrouped', { exact: true }) const ungroupedRow = ungrouped.locator('..').locator('..') - const ungroupedSection = ungroupedRow.locator('..') - await expect.poll(async () => { - if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') { - await ungrouped.click() - await page.waitForTimeout(50) - } - return await ungroupedRow.getAttribute('aria-expanded') - }, { timeout: 5_000 }).toBe('true') - const seeded = ungroupedSection.locator('[role="treeitem"]').nth(1) + if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') await ungrouped.click() + await expect.poll(() => ungroupedRow.getAttribute('aria-expanded')).toBe('true') + // A cold Session's row may still show its cwd until its history loads. + const seeded = ungroupedRow.locator('..').locator('[role="treeitem"][aria-selected]') + await expect.poll(() => seeded.count()).toBe(1) await seeded.click() await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) + + const viewport = page.viewportSize() + if (viewport === null) throw new Error('expected a fixed viewport') + const column = page.locator('[data-rightbar-col]') + const panel = column.locator('[data-sidebar-right-panel]') + const panes = column.locator('[data-dockkit-pane]') + const normalWidth = Math.round(viewport.width * 0.45) + const normalColumns = [280, viewport.width - 280 - normalWidth, normalWidth] + const checkpoints: string[] = ['# Recorded-session Sidebar states'] + const checkpoint = async (label: string): Promise => { + checkpoints.push(`## ${label}\n\n\`\`\`json\n${JSON.stringify(await sidebarSnapshot(page), null, 2)}\n\`\`\``) + } + const select = async (row: Locator, reply: string): Promise => { + await row.click() + await expect.poll(() => row.getAttribute('aria-selected')).toBe('true') + await page.getByText(reply, { exact: true }).waitFor({ timeout: 15_000 }) + } + const open = async (): Promise => { + await page.locator('[data-sidebar-right-expand]').click() + await expect.poll(() => column.locator('[data-sidebar-right-open]').count()).toBe(1) + await expect.poll(() => columns(page)).toEqual(normalColumns) + } + const close = async (): Promise => { + await column.locator('[data-sidebar-right-toggle]').click() + await expect.poll(() => column.locator('[data-sidebar-right-open]').count()).toBe(0) + await expect.poll(() => detailsTrack(page)).toBe(0) + } + + await select(original, 'LIGHTHOUSE') + await open() + // The content-box panel adds its one rendered border pixel outside the + // CSS width assigned by the grid solver. + await expect.poll(() => sidebarSnapshot(page), { timeout: 5_000 }) + .toMatchObject({ mode: 'push', panelContentWidth: normalWidth, panelOuterWidth: normalWidth + 1, resizeHandleWidth: 8 }) + await column.locator('[data-sidebar-right-guide-entry="files"]').click() + await column.locator('[data-files-state="tree"]').waitFor({ timeout: 15_000 }) + await column.locator('[data-dockkit-add-tab]').click() + const split = column.locator('[data-dockkit-split-button]').first() + await expect.poll(() => split.isDisabled()).toBe(false) + await split.click() + await expect.poll(() => panes.count()).toBe(2) + await panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click() + await expect.poll(() => panes.first().locator('[data-files-state="tree"]').count()).toBe(1) + const retainedA = await paneSnapshot(page) + expect(retainedA.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files', 'Start'], ['Start']]) + await checkpoint('A normal: two panes') + + await column.locator('[data-sidebar-right-mode="fullscreen"]').click() + await expect.poll(() => panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport }) + expect(await columns(page)).toEqual(normalColumns) + expect(await sidebarSnapshot(page)).toMatchObject({ mode: 'fullscreen', resizeHandleWidth: 0, coversViewport: true }) + expect(await paneSnapshot(page)).toEqual(retainedA) + await checkpoint('A manual fullscreen: underlying columns retained') + + // Closing the fullscreen panel exposes Session navigation without changing + // its manual mode; reopening after the round trip must restore that mode. + await close() + expect((await sidebarSnapshot(page)).columnTransition).toBe('none') + await checkpoint('A closed with manual fullscreen retained') + await select(seeded, 'DONE') + await expect.poll(() => detailsTrack(page)).toBe(0) + await open() + expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push') + await column.locator('[data-sidebar-right-guide-entry="files"]').click() + const workspaceDirectory = column.locator('[data-files-entry="directory"] > button').filter({ hasText: /^workspace$/ }) + await workspaceDirectory.waitFor({ timeout: 15_000 }) + await workspaceDirectory.click() + await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('true') + await expect.poll(() => column.locator('[data-files-row="loading"]').count()).toBe(0) + expect(await column.locator('[data-files-row="failed"]').count()).toBe(0) + const retainedB = await paneSnapshot(page) + expect(retainedB.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files']]) + await close() + await checkpoint('B closed: independent pane and expanded workspace directory') + + await select(original, 'LIGHTHOUSE') + await expect.poll(() => detailsTrack(page)).toBe(0) + expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen') + expect(await paneSnapshot(page)).toEqual(retainedA) + await open() + await expect.poll(() => panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport }) + expect(await paneSnapshot(page)).toEqual(retainedA) + await checkpoint('A restored: manual fullscreen, tabs, and panes') + await column.locator('[data-sidebar-right-mode="push"]').click() + await expect.poll(async () => (await sidebarSnapshot(page)).panelContentWidth, { timeout: 5_000 }).toBe(normalWidth) + + await select(seeded, 'DONE') + await expect.poll(() => detailsTrack(page)).toBe(0) + expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push') + expect(await paneSnapshot(page)).toEqual(retainedB) + await open() + await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('true') + expect(await paneSnapshot(page)).toEqual(retainedB) + await checkpoint('B restored: normal mode and Files directory state') + await close() + await select(original, 'LIGHTHOUSE') + await expect.poll(() => columns(page)).toEqual(normalColumns) + expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1) + expect(await paneSnapshot(page)).toEqual(retainedA) + await checkpoint('A restored: expanded normal panel') + + try { + await page.setViewportSize({ width: 1024, height: viewport.height }) + await expect.poll(() => columns(page)).toEqual([280, 400, 344]) + await dragSidebar(page, 420) + await expect.poll(() => columns(page)).toEqual([420, 604, 0]) + expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0) + expect(await paneSnapshot(page)).toEqual(retainedA) + await checkpoint('A capacity-closed: wide left preference protected') + await page.setViewportSize(viewport) + await expect.poll(() => columns(page)).toEqual([420, viewport.width - 420, 0]) + expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0) + await checkpoint('A widened: remains closed') + + await page.locator('[data-sidebar-right-expand]').click() + await expect.poll(() => columns(page)).toEqual([420, viewport.width - 420 - normalWidth, normalWidth]) + await page.setViewportSize({ width: 767, height: viewport.height }) + await expect.poll(() => panel.boundingBox()).toEqual({ x: 0, y: 0, width: 767, height: viewport.height }) + await expect.poll(() => columns(page)).toEqual([56, 711, 0]) + expect(await sidebarSnapshot(page)).toMatchObject({ mode: 'fullscreen', resizeHandleWidth: 0, coversViewport: true }) + await checkpoint('A automatic fullscreen at 767px') + await column.locator('[data-sidebar-right-mode="push"]').click() + await expect.poll(() => column.locator('[data-sidebar-right-open]').count()).toBe(0) + await page.setViewportSize(viewport) + await expect.poll(() => columns(page)).toEqual([420, viewport.width - 420, 0]) + expect(await paneSnapshot(page)).toEqual(retainedA) + expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push') + expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0) + await checkpoint('A automatic fullscreen exited: widening does not reopen') + } finally { + await page.setViewportSize(viewport) + } + + await compareOrRefreshGolden(SIDEBAR_EXPECTED, checkpoints.join('\n\n'), MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md']) - }, 90_000) + await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md', 'sidebar.expected.md']) + }) }) diff --git a/apps/web/tests/expected/agent-preset-selection/header.expected.md b/apps/web/tests/expected/agent-preset-selection/header.expected.md index 9a9f380833..2a59c43dde 100644 --- a/apps/web/tests/expected/agent-preset-selection/header.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/header.expected.md @@ -9,3 +9,5 @@ - button "Session log": - text: Session log - img +- button "Open the sidebar": + - img diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index c247f68e74..3b43a6c687 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -4,6 +4,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -176,7 +178,6 @@ - button "Open site/app.js": app.js - button "Open src/tokens.css": tokens.css - text: + 1 file -- button "Show in folder" - button "Copy": - img - button "Good response": diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index e5e246ad19..dc24d0bebf 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -17,6 +17,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index fa6bf3f25c..c1d92a5aa7 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -17,6 +17,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/expected/goal-command-presentation/ui.expected.md b/apps/web/tests/expected/goal-command-presentation/ui.expected.md index 432a51de4d..cda91117e4 100644 --- a/apps/web/tests/expected/goal-command-presentation/ui.expected.md +++ b/apps/web/tests/expected/goal-command-presentation/ui.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index c9709a6386..b43af467c9 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -4,6 +4,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index f26dfd8214..bd51c1ecd9 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -4,6 +4,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index 41424d0ee1..2581f08dd3 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -4,6 +4,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index 21023fdc64..19314b2828 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -4,6 +4,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 3ff4bf9c21..8187c93074 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -4,6 +4,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index d55c6b0d35..b83c67251a 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index 545e0c93b4..8af7e9b7da 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index 9354a75fae..462cab160d 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -4,6 +4,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index 1456ffa683..5e8c46a9d2 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index c3399b4739..5ce25967c9 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index cf24996c06..cb98d44fac 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index 44a992b6f1..682aafd7b4 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -197,20 +197,13 @@ interface TableStop { } /** - * Close the details pane so the transcript spans the viewport. Open, it pins - * the transcript to exactly the message column and every breakout relation - * would go vacuous. - * @param target - the page whose pane to close. + * Wait for the right column to sit at its rail. Expanded, it would pin the + * transcript to exactly the message column and every breakout relation would + * go vacuous; the frame's collapse marker is the settled signal. + * @param target - the page whose frame to read. */ -async function closeDetailsPane(target: Page): Promise { - await target.getByRole('button', { name: 'Close details', exact: true }).waitFor({ timeout: 10_000 }) - await target.evaluate(() => { - document.querySelector('button[aria-label="Close details"]')?.click() - }) - // Closed details resolve to zero width but never unmount (ui-layout - // columns contract), so the settled signal is the frame's collapse marker, - // not the button's detachment. - await target.waitForSelector('[data-details-collapsed]', { timeout: 5_000 }) +async function awaitRightRail(target: Page): Promise { + await target.waitForSelector('[data-rightbar-collapsed]', { timeout: 5_000 }) } /** @@ -265,21 +258,17 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.click() await page.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 }) - // Collapse the sidebar and close the details pane for the whole sweep: - // classic-scrollbar platforms (Linux CI) lose ~15px of layout width, - // which shifts how much of a narrow viewport the panes leave the + // Collapse the sidebar and keep the right column at its rail for the whole + // sweep: classic-scrollbar platforms (Linux CI) lose ~15px of layout + // width, which shifts how much of a narrow viewport the panes leave the // transcript and lands the narrow stop's readings far from the macOS - // ones — and the details pane alone pins the transcript to exactly the - // message column, which would make every breakout relation vacuous. - // With both out of the equation the transcript follows the viewport - // identically on every platform, which is what keeps one committed - // golden true for all lanes. + // ones — and an expanded right column alone pins the transcript to + // exactly the message column, which would make every breakout relation + // vacuous. With both out of the equation the transcript follows the + // viewport identically on every platform, which is what keeps one + // committed golden true for all lanes. await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() - // JS click: after the transcript scrolled to its tail, the pane's close - // button can sit under the sticky header where a pointer click is - // intercepted; the pane itself is scaffolding, not the behavior under - // test, so actionability adds nothing here. - await closeDetailsPane(page) + await awaitRightRail(page) }, 180_000) afterAll(async () => { @@ -436,7 +425,7 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro await sessionRow.click() await hidpiPage.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 }) await hidpiPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() - await closeDetailsPane(hidpiPage) + await awaitRightRail(hidpiPage) // The pane collapses ease over the layout transition: compare only a // settled reading (two consecutive equal wide-wrapper widths). let readings: TableReading[] = [] diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 0e06e222ee..0d9b32f1df 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -12,7 +12,7 @@ import { join } from 'node:path' import type { Browser, Page, Response } from 'playwright' import { chromium } from 'playwright' import { strFromU8, unzipSync } from 'fflate' -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import { SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' import { @@ -277,13 +277,17 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { const exportButton = page.getByRole('button', { name: 'Session log' }) expect(await exportButton.isDisabled()).toBe(false) const header = exportButton.locator('xpath=ancestor::header[1]') - const [buttonBox, headerBox] = await Promise.all([ - exportButton.boundingBox(), header.boundingBox(), + // The right Sidebar's expand button holds the header's corner; the export + // control sits immediately to its left. + const sidebarButton = page.getByRole('button', { name: 'Open the sidebar' }) + const [buttonBox, sidebarBox, headerBox] = await Promise.all([ + exportButton.boundingBox(), sidebarButton.boundingBox(), header.boundingBox(), ]) - if (buttonBox === null || headerBox === null) { + if (buttonBox === null || sidebarBox === null || headerBox === null) { throw new Error('Session Header export geometry is unavailable') } - expect(headerBox.x + headerBox.width - (buttonBox.x + buttonBox.width)).toBeLessThanOrEqual(32) + expect(headerBox.x + headerBox.width - (sidebarBox.x + sidebarBox.width)).toBeLessThanOrEqual(32) + expect(sidebarBox.x - (buttonBox.x + buttonBox.width)).toBeLessThanOrEqual(32) const responsePromise = page.waitForResponse(response => response.request().method() === 'HEAD' && new URL(response.url()).pathname === '/api/session.export', { timeout: 30_000 }) @@ -382,33 +386,33 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await expect.poll(() => page.locator('tr[data-timeline-focus]').count(), { timeout: 10_000 }).toBe(0) }, 60_000) - it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details')) + it.skipIf(MODE === 'record')('bash rows leave the right column at its rail; a file link opens the Sidebar', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-rightbar')) await ensureSeedOpen(page) const bashRow = page.locator('[data-sample="bash"]').first() await expandOwningTurnProcess(page, bashRow) await bashRow.waitFor({ timeout: 15_000 }) const frame = page.locator('[style*="grid-template-columns"]').first() - expect(await frame.getAttribute('data-details-collapsed')).toBe('true') + expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true') // The row click is the card's expand toggle (unified tool-row // interaction); it must not drive layout geometry either way. await bashRow.click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') - // The card's own controls are outside the summary row and must not open - // details either — the expanded terminal card is read in place. + await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') + // The card's own controls are outside the summary row and must not expand + // the right column either — the expanded terminal card is read in place. await page.locator('[data-sample="bash"] ~ div [data-terminal] [class*="_copyButton_"]').first().click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') - // Read summaries are host-open file links; they also must not open details. + await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') + // Read summaries are file links: one click opens the file as a text-preview + // tab in the right Sidebar, which expands to show it beside the guide tab. const fileLink = page.locator('[data-variant="read"] button').first() await fileLink.waitFor({ timeout: 10_000 }) - const openPath = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath') - .mockResolvedValue({ opened: true }) - try { - await fileLink.click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') - } finally { - openPath.mockRestore() - } + await fileLink.click() + await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null) + const column = page.locator('[data-rightbar-col]') + await expect.poll(() => column.locator('[data-dockkit-tab-title]').count(), { timeout: 5_000 }).toBe(2) + // Put the column back so later cases start from the default frame. + await column.locator('[data-sidebar-right-toggle]').click() + await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') }, 60_000) it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => { diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts index 8f10654a4a..1097ed3b1e 100644 --- a/apps/web/tests/produced-files.e2e.ts +++ b/apps/web/tests/produced-files.e2e.ts @@ -1,12 +1,12 @@ // Web e2e scenario: the single-line produced-files summary a finished turn // ends with. Cold-seeds ten writes (zero model calls), then verifies the real -// assembled lane adapts from a coarse width budget and keeps a capability-gated folder handoff. -// The folder request is intercepted so one real browser click can exercise -// the full client carrier without launching a native application in CI. +// assembled lane adapts from a coarse width budget and offers no folder +// handoff: chips open in the right Sidebar's text preview, which has no +// directory form, so the row shows nothing rather than a dead button. import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-title' @@ -152,24 +152,11 @@ describe('web e2e: a finished turn ends with the files it produced', () => { expect(await chips.nth(1).innerText()).toBe('index.html') expect(await chips.nth(4).innerText()).toBe('app.ts') await expect.poll(() => row.getByText('+ 5 files', { exact: true }).isVisible()).toBe(true) - const showFolder = page.getByRole('button', { name: 'Show in folder', exact: true }) - expect(await showFolder.count()).toBe(1) + // Chips open in the right Sidebar's text preview, and a directory is not + // something that preview can show, so the row offers no folder action. + expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0) expect(await page.getByText('Produced', { exact: true }).count()).toBe(1) - const openPath = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath') - .mockResolvedValue({ opened: true }) - try { - const [response] = await Promise.all([ - page.waitForResponse(response => new URL(response.url()).pathname === '/api/session/openWorkspacePath'), - showFolder.click({ clickCount: 1 }), - ]) - expect(response.status()).toBe(200) - expect(openPath).toHaveBeenCalledTimes(1) - expect(openPath.mock.calls[0]![0]).toMatchObject({ path: `${scaffold.workspaceCwd}/.` }) - } finally { - openPath.mockRestore() - } - const tops = await row.locator(':scope > *:visible').evaluateAll(elements => elements.map(element => element.getBoundingClientRect().top)) expect(new Set(tops.map(top => Math.round(top))).size).toBe(1) diff --git a/apps/web/tests/ptc-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts index 66ff93de30..f81ba01ef6 100644 --- a/apps/web/tests/ptc-round.e2e.ts +++ b/apps/web/tests/ptc-round.e2e.ts @@ -103,7 +103,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { }, 60_000) it.skipIf(MODE === 'record')('expands the nested bash terminal inline before and after reload', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-details')) + onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-rightbar')) let liveTerminalAria: string | undefined for (const reloaded of [false, true]) { if (reloaded) { @@ -114,7 +114,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { } const nest = page.locator('[data-subcalls]').first() const frame = page.locator('[style*="grid-template-columns"]').first() - expect(await frame.getAttribute('data-details-collapsed')).toBe('true') + expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true') await expandOwningTurnProcess(page, nest) const row = nest.locator('[data-sample="bash"]').first() await expect.poll(() => row.getAttribute('data-state')).toBe('ok') @@ -126,7 +126,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { await terminal.getByText('echo CODE_ROUND_OK', { exact: true }).waitFor() await terminal.getByText('CODE_ROUND_OK', { exact: true }).waitFor() await expect.poll(() => terminal.locator('[data-state]').getAttribute('data-state')).toBe('done') - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') + await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') const aria = await terminal.ariaSnapshot() if (reloaded) expect(aria).toBe(liveTerminalAria) else liveTerminalAria = aria diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 925bb5322b..2f59edc165 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -14,7 +14,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session' @@ -38,7 +38,7 @@ const UI_EXPANDED_EXPECTED = fileURLToPath( // Command-row goldens over the same conversation after direct host commands. const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/command-row.expected.md', import.meta.url)) const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/feedback-row.expected.md', import.meta.url)) -const FILE_OPEN_FAILURE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/file-open-failure.expected.md', import.meta.url)) +const FILE_PREVIEW_EXPECTED = join(SNAPSHOT_DIR, 'file-preview.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'seeded-history-web-e2e' @@ -190,11 +190,8 @@ describe('web e2e: seeded history renders through cold resume', () => { beforeAll(async () => { scaffold = await launchWebScaffold({}) - // The workspace-aware flow runs sessions in /workspace - // (the composer's default draft name); the read-tool targets must live in - // that session cwd. Pre-creating the directory is safe because the picker - // adopts an existing directory by path. - const sessionCwd = join(scaffold.workspaceCwd, 'workspace') + // Composer recording uses a child workspace; seedSession owns the scaffold root. + const sessionCwd = MODE === 'record' ? join(scaffold.workspaceCwd, 'workspace') : scaffold.workspaceCwd await mkdir(sessionCwd, { recursive: true }) await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n') await writeFile(join(sessionCwd, 'b.txt'), 'beta\n') @@ -407,56 +404,35 @@ describe('web e2e: seeded history renders through cold resume', () => { await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false') }) - it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log stay details-inert', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-toolrow')) - // Interaction over cold-resumed history: read summaries are host-open - // file links (not expand-in-place / not details). Runs after the golden - // capture; still zero model calls. + it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log open the right Sidebar', async () => { + onTestFailed(async () => { + await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/0907-2205-sidebar', import.meta.url)), { recursive: true }) + await saveFailureShot(page, `screenshots/0907-2205-sidebar/seeded-toolrow-${process.pid}`) + }) + // Interaction over cold-resumed history: read summaries are file links + // that open a text-preview tab in the right Sidebar (not expand-in-place). + // Runs after the golden capture; still zero model calls. const fileLink = page.locator('[data-variant="read"] button').first() await expandOwningTurnProcess(page, fileLink) await fileLink.waitFor({ timeout: 10_000 }) const frame = page.locator('[style*="grid-template-columns"]').first() - expect(await frame.getAttribute('data-details-collapsed')).toBe('true') - const openPath = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath') - .mockResolvedValue({ opened: true }) - try { - await fileLink.click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') - } finally { - openPath.mockRestore() - } + expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true') + await fileLink.click() + await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null) + const column = page.locator('[data-rightbar-col]') + await expect.poll(() => column.locator('[data-dockkit-tab-title]').count(), { timeout: 5_000 }).toBe(2) // Path label survives from the recorded args (a.txt). await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) - }) - - it.skipIf(MODE === 'record')('a Host open refusal keeps the reason and retries the same path', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-file-open-failure')) - const fileLink = page.locator('[data-variant="read"] button').first() - await fileLink.waitFor({ timeout: 10_000 }) - const openPath = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath') - .mockRejectedValue(new Error('xdg-open is not available')) - try { - await fileLink.click() - const dialog = page.getByRole('dialog', { name: 'Couldn’t open file' }) - await dialog.waitFor({ timeout: 5_000 }) - const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(FILE_OPEN_FAILURE_EXPECTED, snapshot, MODE) - await expect.poll(() => dialog.innerText(), { timeout: 5_000 }) - .toContain('path open failed: xdg-open is not available') - await page.getByRole('button', { name: 'Retry' }).click() - await expect.poll(() => openPath.mock.calls.length, { timeout: 5_000 }).toBe(2) - expect(openPath.mock.calls[0]![0]).toEqual(openPath.mock.calls[1]![0]) - await page.getByRole('button', { name: 'Cancel' }).click() - await expect.poll(() => page.getByRole('dialog', { name: 'Couldn’t open file' }).count(), { - timeout: 5_000, - }).toBe(0) - } finally { - // Shared page: a leftover mask blocks later cases even when this one fails. - if (await page.getByRole('dialog', { name: 'Couldn’t open file' }).count() > 0) { - await page.keyboard.press('Escape') - } - openPath.mockRestore() - } + const path = column.locator('[data-textpreview-path]') + const absolutePath = join(scaffold.workspaceCwd, 'a.txt') + await expect.poll(() => path.textContent()).toBe(absolutePath) + expect(await path.getAttribute('title')).toBe(absolutePath) + await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n') + const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(FILE_PREVIEW_EXPECTED, preview, MODE) + // Put the column back so the later goldens see the default frame. + await column.locator('[data-sidebar-right-toggle]').click() + await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') }) it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => { @@ -563,7 +539,7 @@ describe('web e2e: seeded history renders through cold resume', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'command-row.expected.md', 'feedback-row.expected.md', 'file-open-failure.expected.md', + 'command-row.expected.md', 'feedback-row.expected.md', 'file-preview.expected.md', 'session.v2.jsonl', 'ui.expected.md', 'ui-expanded.expected.md', ]) }) diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts new file mode 100644 index 0000000000..69196119d3 --- /dev/null +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -0,0 +1,969 @@ +// Keyless assembled-browser coverage for the shipped right Sidebar: the official +// roster row, the real plugin graph, and one Chromium. No overlay is applied — +// this scenario proves the surface is in the product's own composition. +// +// The frame owns the right column as a track; the Sidebar anchors its panel to +// the column's edge and slides it in and out. Which of the two presentations +// draws the panel is a recorded, reversible choice, so this file asserts against +// the frame's track as much as against the panel itself. The way back in while +// collapsed is not in the column at all: it is one button in the conversation +// header, and it leaves when the panel opens. +// +// Ordering is the product's own: the hero comes before any session, so the +// empty right edge is asserted first and the session-bound cases follow in a +// nested block that seeds one turn. +// +// Copy is asserted in English because this page advertises English, which is +// itself the point: every string in this column now comes from the dictionary, +// so an English page renders English. The Chinese draft the product ships is +// asserted, and captured for review, on its own page at the end. +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Locator, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { + connectFreshWorkspace, newEnglishPage, saveFailureShot, ZH_BROWSER_LOCALE, +} from './support.ts' + +/** The produced file the seeded turn writes, and what the preview should show. */ +const SAMPLE_NAME = 'notes.txt' +const SAMPLE_TEXT = 'produced by the seeded turn\nsecond line\n' + +/** Where this batch's accepted product forms are archived. */ +const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0907-sidebar-rules', import.meta.url)) + +/** Archive one accepted product form; the batch receipt cites these by name. */ +async function shot(page: Page, name: string): Promise { + mkdirSync(SHOT_DIR, { recursive: true }) + await page.screenshot({ path: `${SHOT_DIR}/${name}.png`, fullPage: true }) +} + +/** Centre of a rendered element, in viewport coordinates. */ +async function centre(locator: Locator): Promise<{ x: number; y: number }> { + const box = await locator.boundingBox() + if (box === null) throw new Error('element is not rendered') + return { x: box.x + box.width / 2, y: box.y + box.height / 2 } +} + +/** + * Press a tab chip and release it over a point. + * + * The press stays left of the chip's nested close control. An optional preview + * verifies the browser recognized the drop target before the release. + */ +async function dragTo(page: Page, tab: Locator, to: { x: number; y: number }, preview?: Locator): Promise { + const box = await tab.boundingBox() + if (box === null) throw new Error('tab is not rendered') + const from = { x: box.x + 6, y: box.y + box.height / 2 } + await page.mouse.move(from.x, from.y) + await page.mouse.down() + try { + await page.mouse.move(to.x, to.y, { steps: 8 }) + await preview?.waitFor({ state: 'visible' }) + } finally { + await page.mouse.up() + } +} + +/** Press an element itself and release over a point (no tab-title indirection). */ +async function dragElement(page: Page, handle: Locator, to: { x: number; y: number }): Promise { + const from = await centre(handle) + await page.mouse.move(from.x, from.y) + await page.mouse.down() + await page.mouse.move(to.x, to.y, { steps: 8 }) + await page.mouse.up() +} + +/** A point inside `locator`, offset by fractions of its own box. */ +async function pointIn(locator: Locator, fx: number, fy: number): Promise<{ x: number; y: number }> { + const box = await locator.boundingBox() + if (box === null) throw new Error('element is not rendered') + return { x: box.x + box.width * fx, y: box.y + box.height * fy } +} + +/** Pause the panel's next real transform transition after observing its initial frame geometry. */ +async function holdPanelSlide(panel: Locator) { + return await panel.evaluateHandle((node) => { + const controller = new AbortController() + const state = { animation: null as Animation | null, columnsAtStart: '', dispose: () => { controller.abort() } } + node.addEventListener('transitionrun', (event) => { + if (event.target !== node || (event as TransitionEvent).propertyName !== 'transform') return + const frame = node.closest('[style*="grid-template-columns"]') + if (frame === null) throw new Error('panel frame is unavailable') + state.columnsAtStart = getComputedStyle(frame).gridTemplateColumns + const slide = node.getAnimations().find(animation => + 'transitionProperty' in animation && animation.transitionProperty === 'transform') + if (slide === undefined || slide.effect === null) throw new Error('panel transform transition is unavailable') + slide.pause() + slide.currentTime = Number(slide.effect.getComputedTiming().endTime) / 2 + state.animation = slide + controller.abort() + }, { signal: controller.signal }) + return state + }) +} + +/** The expand button in the conversation header, present only while collapsed. */ +function expandOf(page: Page): Locator { + return page.locator('[data-sidebar-right-expand]') +} + +/** + * Make sure the panel is open. + * + * These cases share one page and run in order, so an earlier one may have left + * the panel collapsed; a case that needs tabs says so rather than inheriting + * whatever the previous one happened to leave. The way in while collapsed is the + * header's expand button, which lives in the conversation, not the column. + */ +async function ensureExpanded(page: Page, column: Locator): Promise { + if (await column.locator('[data-sidebar-right-open]').count() > 0) return + await expandOf(page).click() + await column.locator('[data-sidebar-right-open]').waitFor({ timeout: 10_000 }) +} + +/** Reload the session's transient sidebar state before an independent gesture case. */ +async function resetSidebar(page: Page): Promise { + await page.reload({ waitUntil: 'load' }) + const column = page.locator('[data-rightbar-col]') + await expandOf(page).waitFor({ timeout: 15_000 }) + await ensureExpanded(page, column) + await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) + await width(column) + return column +} + +/** Whether the element at the centre of `locator` is the locator's own element or a descendant. */ +async function hitsItself(locator: Locator): Promise { + const point = await centre(locator) + return await locator.evaluate((node, at) => { + const hit = document.elementFromPoint(at.x, at.y) + return hit !== null && node.contains(hit) + }, point) +} + +/** + * Float a docked tab the way a user does: drag its chip clear of the docked + * surface and release over the conversation. + */ +async function floatByDrag(page: Page, tab: Locator): Promise { + const surface = page.locator('[data-dockkit-surface]').first() + const box = await surface.boundingBox() + if (box === null) throw new Error('surface is not rendered') + await dragTo(page, tab, { x: box.x - 240, y: box.y + box.height / 2 }) +} + +/** + * Drag the frame's rightbar handle until the panel is `target` px wide (the + * frame clamps to its own range). The handle sits on the panel's left edge, so + * widening is a drag to the left. + */ +async function setPanelWidth(page: Page, target: number): Promise { + const panel = page.locator('[data-sidebar-right-panel]') + const handle = page.locator('[data-side="rightbar"]').first() + for (let attempt = 0; attempt < 3; attempt += 1) { + const box = await panel.boundingBox() + if (box === null) throw new Error('panel is not rendered') + const delta = target - box.width + if (Math.abs(delta) < 2) return + const grip = await centre(handle) + await page.mouse.move(grip.x, grip.y) + await page.mouse.down() + await page.mouse.move(grip.x - delta, grip.y, { steps: 8 }) + await page.mouse.up() + await page.waitForTimeout(300) + } +} + +/** Tab titles inside one container, in strip order. */ +async function tabTitles(root: Locator): Promise { + return await root.locator('[data-dockkit-tab-title]').allInnerTexts() +} + +/** + * A rendered width, read once the frame's track transition has settled. + * + * The frame eases its grid tracks, so a single sample taken right after a + * gesture reports a frame of the animation. Column arithmetic is only exact at + * rest, so this samples until three consecutive readings agree. + */ +async function width(locator: Locator): Promise { + let last = Number.NaN + let steady = 0 + for (let attempt = 0; attempt < 80; attempt += 1) { + // Layout width, not the visible box: a zero-width track is still an answer. + const now = Math.round(await locator.evaluate(node => node.getBoundingClientRect().width)) + steady = now === last ? steady + 1 : 0 + if (steady === 2) return now + last = now + await locator.page().waitForTimeout(50) + } + throw new Error(`width never settled (last ${last}px)`) +} + +describe('web e2e: shipped right Sidebar', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold() + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 180_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('shows nothing on the right while no session keys a surface', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-hero')) + const frame = page.locator('[class*="frame"]').first() + const column = page.locator('[data-rightbar-col]') + await column.waitFor({ state: 'attached', timeout: 15_000 }) + + // With no session there is no surface: no panel in the column, no expand + // button in the header, and no track — the conversation reaches the frame's edge. + expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true') + expect(await column.locator('[data-sidebar-right-panel]').count()).toBe(0) + expect(await expandOf(page).count()).toBe(0) + expect(await width(column)).toBe(0) + await shot(page, '01-hero-no-sidebar') + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + describe('with a settled session', () => { + beforeAll(async () => { + await connectFreshWorkspace(page, scaffold.workspaceCwd) + // A settled session is what keys the surface; seed one turn through the + // real append path so the Chat surface is live before the Sidebar is driven. + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('connected workspace did not create an Agent') + // The wire parameter is `agentId`; the client passes a session id. If the + // scaffold's Agent and Session carry different ids, that mismatch is the + // silent lookup failure. + expect(String(agent.id)).toBe(String(agent.session.id)) + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Show the right sidebar.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + agent.session.append('step/start', { turn: 1, step: 1 }) + // A successful mutation is what makes the turn tail offer a produced-file + // chip — the product's own way into the Sidebar. The file is written for + // real because the preview reads it through the workspace endpoint. + // + // It goes in the SESSION's cwd, not the scaffold's: the endpoint resolves + // relative paths against `sandboxPolicy.resolve({session}).workspaceRoot`, + // which is the session header's cwd. Writing anywhere else makes the read + // fail with workspace-file/not-found, which is the endpoint being right. + writeFileSync(join(agent.session.header.cwd ?? scaffold.workspaceCwd, SAMPLE_NAME), SAMPLE_TEXT, 'utf8') + agent.session.append('tool/call', { + turn: 1, + step: 1, + callId: 'call-write-1', + name: 'write', + arguments: JSON.stringify({ file_path: SAMPLE_NAME, content: SAMPLE_TEXT }), + } as never) + agent.session.append('tool/result', { + turn: 1, + step: 1, + message: { + id: 'result-call-write-1', + role: 'user', + source: { kind: 'tool', callId: 'call-write-1' }, + content: [{ type: 'tool-result', toolCallId: 'call-write-1', content: [{ type: 'text', text: 'ok' }] }], + }, + } as never, { surfaceOp: 'append' }) + agent.session.append('assistant/message', { + stream: [], + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'Ready.' }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + agent.session.append('step/end', { turn: 1, step: 1 }) + agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(agent.session) + await page.getByText('Ready.').waitFor({ timeout: 10_000 }) + }, 120_000) + + it('CONTROL: the old fileReferences namespace answers over the same wire', async () => { + const composer = page.locator('textarea, [contenteditable="true"]').first() + await composer.click() + await composer.pressSequentially('@') + // Any candidate list means the agent-scoped lookup and the gateway route + // both work in this scaffold; nothing rendered means the wire is the fault. + const answered = await page.locator('[data-input-trigger], [role="listbox"], [data-reference-list]') + .first().waitFor({ timeout: 10_000 }).then(() => true, () => false) + await page.keyboard.press('Escape') + // Leave the composer as it was found: these cases share one page, and a + // stray '@' rides into every later assertion and screenshot. `fill('')` + // does NOT clear this editor — it reported success while the character + // stayed — so the reset is a real keystroke, and it is asserted rather + // than assumed. + await composer.click() + await page.keyboard.press('ControlOrMeta+a') + await page.keyboard.press('Backspace') + await expect.poll(async () => (await composer.innerText()).trim()).toBe('') + expect(answered).toBe(true) + }) + + it('starts collapsed behind a header button and squeezes the conversation when opened', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right')) + const frame = page.locator('[class*="frame"]').first() + const column = page.locator('[data-rightbar-col]') + const conversation = page.locator('[class*="centerCol"]').first() + const expand = expandOf(page) + + // Collapsed default: no track, the panel sits off the frame's edge, and + // the only way in is the header button — on the same row as the other + // header utilities, at its far right. + await expand.waitFor({ timeout: 15_000 }) + expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true') + expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0) + const utilities = page.locator('[class*="headerUtilities"]') + const expandBox = await expand.boundingBox() + const rowBox = await utilities.boundingBox() + if (expandBox === null || rowBox === null) throw new Error('header utilities are not rendered') + expect(Math.round(expandBox.y + expandBox.height / 2)).toBe(Math.round(rowBox.y + rowBox.height / 2)) + // Its own corner seat, past the utilities' right edge — not a utility. + expect(expandBox.x).toBeGreaterThan(rowBox.x + rowBox.width) + const conversationBoxBefore = await conversation.boundingBox() + if (conversationBoxBefore === null) throw new Error('conversation is not rendered') + // How far the utilities' right edge sits from the conversation's own. + const gapBefore = (conversationBoxBefore.x + conversationBoxBefore.width) - (rowBox.x + rowBox.width) + const centerBefore = await width(conversation) + await shot(page, '02a-collapsed-header-button') + + // Opening squeezes by default: the column takes a track of the panel's + // width, the conversation gives up exactly that much room, and the header + // button leaves with the panel's arrival. + await expand.click() + await expect.poll(async () => await frame.getAttribute('data-rightbar-collapsed')).toBe(null) + await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(1) + const panelWidth = await width(column) + expect(panelWidth).toBeGreaterThan(0) + expect(await width(conversation)).toBe(centerBefore - panelWidth) + await expect.poll(async () => await expand.count()).toBe(0) + // The corner keeps its footprint, so the utilities' right edge stays where + // it was relative to the conversation's own right edge. + expect(await page.locator('[data-sidebar-right-expand-placeholder]').count()).toBe(1) + const utilitiesAfter = await utilities.boundingBox() + const conversationAfter = await conversation.boundingBox() + if (utilitiesAfter === null || conversationAfter === null) throw new Error('header is not rendered') + const gapAfter = (conversationAfter.x + conversationAfter.width) - (utilitiesAfter.x + utilitiesAfter.width) + expect(Math.round(gapAfter)).toBe(Math.round(gapBefore)) + + // The panel is in the column, not over it, and carries the seeded tab — + // whose body arrives through the guide type's keyed registration, not from + // any dispatch inside the seat. Its two controls sit at the end of the + // top-right pane's strip: the panel has no header row of its own. + expect(await column.locator('[data-sidebar-right-panel="push"]').count()).toBe(1) + const chrome = column.locator('[data-dockkit-strip-chrome]') + expect(await chrome.count()).toBe(1) + expect(await chrome.locator('[data-sidebar-right-mode]').count()).toBe(1) + expect(await chrome.locator('[data-sidebar-right-toggle]').count()).toBe(1) + + // One centre line across the strip: chip text, split, and the two panel + // controls all sit at the same height. The add control joins the check + // below, once the strip draws it. + const centreY = async (selector: string): Promise => { + const box = await column.locator(selector).first().boundingBox() + if (box === null) throw new Error(`${selector} is not rendered`) + return Math.round(box.y + box.height / 2) + } + const textLine = await centreY('[data-dockkit-tab-title]') + for (const selector of ['[data-dockkit-split-button]', '[data-sidebar-right-mode]', '[data-sidebar-right-toggle]']) { + expect(await centreY(selector), selector).toBe(textLine) + } + + // The guide is unique per pane, so while this pane holds one its strip + // offers no add control. Closing it brings the control back, and the + // control opens the guide again in that pane. + const addTab = column.locator('[data-dockkit-add-tab]') + expect(await addTab.count()).toBe(0) + await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() + await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME]) + await column.locator('[data-dockkit-tab-close]').first().click() + await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME]) + await expect.poll(async () => await addTab.count()).toBe(1) + expect(await centreY('[data-dockkit-add-tab]')).toBe(textLine) + await addTab.click() + await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME, 'Start']) + await expect.poll(async () => await column.locator('[data-sidebar-right-guide]').count()).toBe(1) + await expect.poll(async () => await addTab.count()).toBe(0) + // Back to the seeded shape the cases below start from. + await column.locator('[data-dockkit-tab-close]').first().click() + await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) + await shot(page, '02-squeezed-panel') + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('covers the viewport in fullscreen without changing the underlying columns', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-mode')) + const frame = page.locator('[class*="frame"]').first() + const column = page.locator('[data-rightbar-col]') + const panel = column.locator('[data-sidebar-right-panel]') + const conversation = page.locator('[class*="centerCol"]').first() + const squeezed = await width(conversation) + const before = await panel.boundingBox() + const trackWidth = await width(column) + + await column.locator('[data-sidebar-right-mode="fullscreen"]').click() + await expect.poll(async () => await panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen') + expect(await frame.getAttribute('data-rightbar-collapsed')).toBe(null) + expect(await width(conversation)).toBe(squeezed) + expect(await width(column)).toBe(trackWidth) + const viewport = page.viewportSize() + if (viewport === null) throw new Error('expected a fixed viewport') + await expect.poll(async () => await panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport }) + expect(await expandOf(page).count()).toBe(0) + expect(await frame.locator('[data-side="rightbar"]').count()).toBe(0) + await shot(page, '03-fullscreen-panel') + + await column.locator('[data-sidebar-right-mode="push"]').click() + await expect.poll(async () => await panel.getAttribute('data-sidebar-right-panel')).toBe('push') + expect(await width(conversation)).toBe(squeezed) + expect(await panel.boundingBox()).toEqual(before) + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('keeps the conversation still during fullscreen entry and installs the hidden track without animation', async () => { + onTestFailed(() => saveFailureShot(page, 'screenshots/0907-sidebar-rules/sidebar-right-fullscreen-entry')) + mkdirSync(SHOT_DIR, { recursive: true }) + const column = await resetSidebar(page) + const frame = page.locator('[class*="frame"]').first() + const panel = column.locator('[data-sidebar-right-panel]') + const viewport = page.viewportSize() + if (viewport === null) throw new Error('expected a fixed viewport') + const geometry = () => frame.evaluate(node => ({ + columns: getComputedStyle(node).gridTemplateColumns, + transition: getComputedStyle(node).transitionProperty, + handles: [...node.querySelectorAll('[data-side="sidebar"], [data-side="rightbar"]')] + .map(handle => getComputedStyle(handle).transitionProperty), + animatingGrid: node.getAnimations().some(animation => + 'transitionProperty' in animation && animation.transitionProperty === 'grid-template-columns' + && animation.playState !== 'finished' && animation.playState !== 'idle'), + })) + await frame.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) }) + const normalColumns = (await geometry()).columns + await column.locator('[data-sidebar-right-mode="fullscreen"]').click() + await expect.poll(() => panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport }) + await column.locator('[data-sidebar-right-toggle]').click() + await Promise.all([ + panel.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) }), + frame.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) }), + ]) + const closedColumns = (await geometry()).columns + expect(closedColumns).not.toBe(normalColumns) + await page.emulateMedia({ reducedMotion: 'no-preference' }) + + // Pause the real CSS transition at its midpoint so host scheduling cannot + // skip the partly covered frame whose underlying width is under test. + const held = await holdPanelSlide(panel) + try { + await expandOf(page).click() + await expect.poll(() => held.evaluate(state => state.animation?.playState)).toBe('paused') + const entering = await panel.boundingBox() + if (entering === null) throw new Error('entering panel is not rendered') + expect(entering.x).toBeGreaterThan(0) + expect(entering.x).toBeLessThan(viewport.width) + expect((await geometry()).columns).toBe(closedColumns) + expect((await geometry()).animatingGrid).toBe(false) + expect(await frame.getAttribute('data-rightbar-fullscreen')).toBeNull() + + await held.evaluate((state) => { (state.animation as Animation).finish() }) + await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true') + expect(await panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport }) + expect(await geometry()).toEqual({ columns: normalColumns, transition: 'none', handles: ['none'], animatingGrid: false }) + + const exit = await holdPanelSlide(panel) + try { + await column.locator('[data-sidebar-right-toggle]').click() + await expect.poll(() => exit.evaluate(state => state.animation?.playState)).toBe('paused') + expect(await exit.evaluate(state => state.columnsAtStart)).toBe(closedColumns) + const leaving = await panel.boundingBox() + if (leaving === null) throw new Error('leaving panel is not rendered') + expect(leaving.x).toBeGreaterThan(0) + expect(leaving.x).toBeLessThan(viewport.width) + expect(await geometry()).toEqual({ columns: closedColumns, transition: 'none', handles: ['none'], animatingGrid: false }) + await exit.evaluate((state) => { (state.animation as Animation).finish() }) + expect((await geometry()).columns).toBe(closedColumns) + } finally { + await exit.evaluate((state) => { + state.dispose() + if (state.animation?.playState === 'paused') state.animation.finish() + }) + await exit.dispose() + } + + await expandOf(page).click() + await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true') + await column.locator('[data-sidebar-right-mode="push"]').click() + expect((await geometry()).columns).toBe(normalColumns) + expect((await geometry()).animatingGrid).toBe(false) + + await page.emulateMedia({ reducedMotion: 'reduce' }) + await column.locator('[data-sidebar-right-mode="fullscreen"]').click() + await column.locator('[data-sidebar-right-toggle]').click() + await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBeNull() + await expandOf(page).click() + await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true') + expect(await panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport }) + expect((await geometry()).columns).toBe(normalColumns) + expect((await geometry()).animatingGrid).toBe(false) + } finally { + await held.evaluate((state) => { + state.dispose() + if (state.animation?.playState === 'paused') state.animation.finish() + }) + await held.dispose() + try { + if (await panel.getAttribute('data-sidebar-right-panel') === 'fullscreen' + && await panel.getAttribute('data-sidebar-right-open') !== null) { + await column.locator('[data-sidebar-right-mode="push"]').click() + } + } finally { + await page.emulateMedia({ reducedMotion: null }) + } + } + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('keeps a capacity-closed panel closed after widening and uses fullscreen on a narrow viewport', async () => { + const viewport = page.viewportSize() + if (viewport === null) throw new Error('expected a fixed viewport') + const frame = page.locator('[class*="frame"]').first() + const column = page.locator('[data-rightbar-col]') + const sidebar = page.locator('[class*="sidebarCol"]').first() + const panel = column.locator('[data-sidebar-right-panel]') + try { + await page.setViewportSize({ width: 1024, height: viewport.height }) + const leftGrip = frame.locator('[data-side="sidebar"]') + const grip = await centre(leftGrip) + await dragElement(page, leftGrip, { x: 420, y: grip.y }) + await expect.poll(async () => await width(sidebar)).toBe(420) + await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0) + await page.setViewportSize(viewport) + await expect.poll(async () => await width(sidebar)).toBe(420) + expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0) + await expandOf(page).click() + await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(1) + + await page.setViewportSize({ width: 767, height: viewport.height }) + await expect.poll(async () => await panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen') + await expect.poll(async () => await width(panel)).toBe(767) + await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true') + expect(await frame.locator('[data-side="rightbar"]').count()).toBe(0) + await column.locator('[data-sidebar-right-mode="push"]').click() + await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0) + await page.setViewportSize(viewport) + await expect.poll(async () => await width(sidebar)).toBe(420) + expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0) + } finally { + await page.setViewportSize(viewport) + const grip = await centre(frame.locator('[data-side="sidebar"]')) + await dragElement(page, frame.locator('[data-side="sidebar"]'), { x: 280, y: grip.y }) + await expect.poll(async () => await width(sidebar)).toBe(280) + await ensureExpanded(page, column) + } + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('CONTROL: the host endpoint answers when called directly, bypassing the wire', async () => { + const files = (scaffold.ctx as unknown as { + get(name: string): { + read(agent: unknown, path: string, range: object, signal: AbortSignal): Promise<{ text: string; eof: boolean }> + } | undefined + }).get('workspaceFiles') + if (files === undefined) throw new Error('host endpoint is not provided') + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('no Agent to read for') + + // Raced against a timer so a hang reports a verdict instead of stalling + // the suite: this case exists to tell host logic apart from the wire. + // A page is the file's lines joined by `\n`, without the final terminator. + const verdict = await Promise.race([ + files.read(agent, SAMPLE_NAME, {}, new AbortController().signal) + .then(value => ({ kind: 'settled' as const, text: value.text, eof: value.eof })) + .catch((error: unknown) => ({ kind: 'threw' as const, text: String(error), eof: false })), + new Promise<{ kind: 'hung'; text: string; eof: boolean }>((resolve) => { + setTimeout(() => { resolve({ kind: 'hung', text: 'no settlement in 10s', eof: false }) }, 10_000) + }), + ]) + expect(verdict).toEqual({ kind: 'settled', text: SAMPLE_TEXT.replace(/\n$/u, ''), eof: true }) + }, 30_000) + + it('opens content once, splits, and floats it outside the column', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-content')) + const column = page.locator('[data-rightbar-col]') + const panes = column.locator('[data-dockkit-pane]') + const floats = page.locator('[data-sidebar-right-float-host] [data-dockkit-float]') + + // Observation before action: does the read ever leave the browser? The + // assertion states the healthy answer so a failure prints the real one. + // + // CAVEAT: `sent` is trustworthy — a request frame carries the endpoint + // name. `received` is NOT: a response frame carries only its rpc id, so a + // zero here means "my filter saw nothing", not "the host never answered". + // Correlate by rpc id before drawing any conclusion from it. + const wire = { sent: 0, received: 0 } + const watch = (payload: string): void => { + if (!payload.includes('workspaceFiles')) return + wire.sent += 1 + } + page.on('websocket', (ws) => { + ws.on('framesent', (frame) => { watch(String(frame.payload)) }) + ws.on('framereceived', (frame) => { + if (String(frame.payload).includes('workspaceFiles')) wire.received += 1 + }) + }) + page.on('request', (request) => { + if (request.url().includes('workspaceFiles')) wire.sent += 1 + }) + + // The product's own entry point: the turn tail's produced-file chip. It + // reaches the Sidebar through openFile → ctx.sidebarRight.openResource, and the + // text type claims the address. + const chip = page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }) + await chip.click() + await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME]) + + // Opening the same content again focuses rather than duplicating. + await panes.first().locator('[data-dockkit-tab]').first().click() + await chip.click() + await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME]) + + // The body arrives through the text type's keyed registration, and its + // content came over the wire from the real file. + // A real Remote round-trip settles well after the default poll window. + await column.locator('[data-textpreview-state="text"]') + .waitFor({ timeout: 15_000 }) + .catch(() => { throw new Error(`preview never settled; wire=${JSON.stringify(wire)}`) }) + expect(await column.locator('pre').first().innerText()).toContain('produced by the seeded turn') + // The whole batch-E chain in one frame: a produced-file chip in the + // conversation, the tab it opened, and the file's real content read over + // the workspace endpoint. + await shot(page, '06-produced-chip-to-preview') + + // The directory scenario's V1 behaviour, asserted in the shipped product: + // there is no folder affordance at all. `openFile('.')` would name a + // directory, which a text preview correctly refuses, and the native opener + // it used to reach is gone — so the row offers nothing rather than a + // button that always fails. + expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0) + + // Split, then dock-drag: the kit's gestures drive the store's actions. + await panes.first().locator('[data-dockkit-split-button]').click() + await expect.poll(async () => await panes.count()).toBe(2) + await dragTo( + page, + column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }).first(), + await pointIn(panes.nth(1), 0.5, 0.94), + ) + await expect.poll(async () => await panes.count()).toBe(2) + + await panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) + .locator('[data-dockkit-tab-close]').click() + await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME]) + + // The guide is unique per pane: panes seeded with one offer no + // add control; the pane holding only the file is the one that does. + const filePane = panes.filter({ has: page.locator('[data-dockkit-tab-title]', { hasText: SAMPLE_NAME }) }) + await expect.poll(async () => await filePane.locator('[data-dockkit-add-tab]').count()).toBe(1) + expect(await column.locator('[data-dockkit-add-tab]').count()).toBe(1) + + // Floating leaves the column entirely, and survives collapsing it. The + // pane the tab was alone in goes with it: an emptied pane never stays. + const tab = column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }).first() + await floatByDrag(page, tab) + await expect.poll(async () => await floats.count()).toBe(1) + expect(await column.locator('[data-dockkit-float]').count()).toBe(0) + await expect.poll(async () => await panes.count()).toBe(1) + await shot(page, '04-split-and-float') + + await column.locator('[data-sidebar-right-toggle]').click() + await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0) + expect(await floats.count()).toBe(1) + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + // §9.2 (an explicit second copy of the same content) has no control on the + // panel by product decision, and copy has no service method yet: + // `duplicateTab` is a store/kit intent only, which service.client.spec.ts covers. + + it('keeps each session\'s surface to itself, and restores it on return', async () => { + const fx = await newEnglishPage(browser) + const fxTripwire = watchConsole(fx) + onTestFailed(() => saveFailureShot(fx, 'web-e2e-sidebar-right-sessions')) + try { + await fx.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const settled = fx.getByRole('treeitem', { name: /Show the right sidebar\./u }).first() + await settled.click() + await expect.poll(async () => await settled.getAttribute('aria-selected')).toBe('true') + const frame = fx.locator('[class*="frame"]').first() + const column = fx.locator('[data-rightbar-col]') + await ensureExpanded(fx, column) + await width(column) + await fx.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() + await column.locator('[data-textpreview-state="text"]').waitFor({ timeout: 15_000 }) + const wrap = column.locator('[data-textpreview-tool="wrap"]') + expect(await wrap.getAttribute('aria-pressed')).toBe('true') + await wrap.click() + await expect.poll(async () => await wrap.getAttribute('aria-pressed')).toBe('false') + await column.locator('[data-dockkit-split-button]').first().click() + const panes = column.locator('[data-dockkit-pane]') + await expect.poll(async () => await panes.count()).toBe(2) + const records = async (): Promise<{ panes: string[]; tabs: string[]; titles: string[] }> => ({ + panes: await panes.evaluateAll(nodes => nodes.map(node => node.getAttribute('data-dockkit-pane')!)), + tabs: await column.locator('[data-dockkit-tab]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-dockkit-tab')!)), + titles: await tabTitles(column), + }) + const before = await records() + + // The real New Session action selects a distinct blank Session; its + // collapsed surface must not inherit the settled Session's tabs. + await fx.getByRole('button', { name: 'New session', exact: true }).last().click() + await expect.poll(async () => await settled.getAttribute('aria-selected')).toBe('false') + await expect.poll(async () => await frame.getAttribute('data-rightbar-collapsed')).toBe('true') + expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0) + expect(await column.locator('[data-textpreview-state="text"]').count()).toBe(0) + + await settled.click() + await expect.poll(async () => await settled.getAttribute('aria-selected')).toBe('true') + await expect.poll(records, { timeout: 15_000 }).toEqual(before) + expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1) + expect(await wrap.getAttribute('aria-pressed')).toBe('false') + expect(await column.locator('pre').first().innerText()).toContain('produced by the seeded turn') + expect(fxTripwire.pageErrors).toEqual([]) + expect(fxTripwire.warnings).toEqual([]) + } finally { + await fx.close() + } + }, 120_000) + + it('§9.3/§9.4 runs the whole pointer chain in a real browser', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-gestures')) + const column = await resetSidebar(page) + const panes = column.locator('[data-dockkit-pane]') + const floats = page.locator('[data-sidebar-right-float-host] [data-dockkit-float]') + + // Chromium cancels pointer capture if a render replaces the pressed + // element; jsdom cannot establish that the whole gesture survives. + + // 1. Reorder inside one strip: drop the last tab left of its neighbours. + // The first pane needs two tabs for this — and for the move below to + // leave it standing, since a pane emptied by a move is dropped. + const first = panes.first() + const strip = first.locator('[data-dockkit-strip]') + await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() + await expect.poll(async () => await tabTitles(first)).toEqual(['Start', SAMPLE_NAME]) + const order = await tabTitles(first) + // The insertion index is measured against chip midpoints, not strip width. + await dragTo(page, first.locator('[data-dockkit-tab]').last(), + await pointIn(first.locator('[data-dockkit-tab]').first(), 0.25, 0.5), + strip.locator('[data-dockkit-caret="0"]')) + await expect.poll(async () => await tabTitles(first)).toEqual([...order].reverse()) + + // 2. Cross-pane move into a second pane: the tab leaves one pane's strip + // for another's. + if (await panes.count() < 2) { + await first.locator('[data-dockkit-split-button]').click() + await expect.poll(async () => await panes.count()).toBe(2) + } + const moving = first.locator('[data-dockkit-tab]').first() + const title = await moving.locator('[data-dockkit-tab-title]').innerText() + await dragTo(page, moving, await pointIn(panes.nth(1), 0.5, 0.5)) + await expect.poll(async () => await tabTitles(panes.nth(1))).toContain(title) + + const splitButtons = column.locator('[data-dockkit-split-button]') + await expect.poll(async () => await splitButtons.count()).toBe(0) + expect(await panes.count()).toBe(2) + await setPanelWidth(page, 560) + await expect.poll(async () => await splitButtons.count()).toBe(0) + + const outer = column.locator('[data-dockkit-divider]').first() + const before = await width(panes.last()) + const grip = await centre(outer) + await dragElement(page, outer, { x: grip.x - 100, y: grip.y }) + await expect.poll(async () => await width(panes.last())).toBeGreaterThan(before) + await dragElement(page, outer, { x: 0, y: grip.y }) + const ratio = async (): Promise => { + const left = await width(panes.first()) + const right = await width(panes.last()) + return left / (left + right) + } + await expect.poll(ratio).toBeCloseTo(0.2, 2) + const surfaceBox = await column.locator('[data-dockkit-surface]').boundingBox() + if (surfaceBox === null) throw new Error('surface is not rendered') + await dragElement(page, outer, { x: surfaceBox.x + surfaceBox.width / 2, y: grip.y }) + await expect.poll(ratio).toBeCloseTo(0.5, 2) + expect(await panes.count()).toBe(2) + expect(await splitButtons.count()).toBe(0) + + // 5. Two floats coexist, and one of them moves. Both leave the widest + // pane; a pane emptied by the first float is merged away, so the + // second one comes from whichever pane is widest by then. + const floatOne = panes.last().locator('[data-dockkit-tab]').first() + await floatByDrag(page, floatOne) + await expect.poll(async () => await floats.count()).toBe(1) + const box = await floats.first().boundingBox() + if (box === null) throw new Error('float is not rendered') + await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 }) + await expect.poll(async () => (await floats.first().boundingBox())?.x ?? box.x).not.toBe(box.x) + + const second = panes.last().locator('[data-dockkit-tab]').first() + await floatByDrag(page, second) + await expect.poll(async () => await floats.count()).toBe(2) + + // 6. Dock one back: the docked tree takes it, the other float stays. Dock + // the TOPMOST float — floats render bottom-to-top, so the newest one + // covers the older one's controls and would intercept the click. + await floats.last().locator('[data-dockkit-float-dock]').click() + await expect.poll(async () => await floats.count()).toBe(1) + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) + + it('drops a pane whose last tab closes, and reseeds the guide when none is left', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-settle')) + const column = await resetSidebar(page) + const panes = column.locator('[data-dockkit-pane]') + await panes.first().locator('[data-dockkit-split-button]').click() + await expect.poll(async () => await panes.count()).toBe(2) + const closeAllIn = async (pane: Locator): Promise => { + const tabs = await pane.locator('[data-dockkit-tab]').count() + for (let i = 0; i < tabs; i += 1) { + await pane.locator('[data-dockkit-tab-close]').first().click() + } + } + + // Closing a pane's last tab drops the pane: there is no separate + // "close pane" gesture, and none is needed. + let count = await panes.count() + expect(count).toBeGreaterThan(1) + while (count > 1) { + await closeAllIn(panes.nth(count - 1)) + await expect.poll(async () => await panes.count()).toBe(count - 1) + count -= 1 + } + + // The last pane cannot be dropped, so closing everything in it reseeds + // the guide: the surface always has one tab to look at. + await closeAllIn(panes.first()) + await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) + expect(await column.locator('[data-sidebar-right-guide]').count()).toBe(1) + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) + + it('§9.7 returns to the default surface after a reload', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-reload')) + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const frame = page.locator('[class*="frame"]').first() + const column = page.locator('[data-rightbar-col]') + await column.waitFor({ state: 'attached', timeout: 15_000 }) + // The surface is view state, not durable session data: a reload zeroes it + // back to the collapsed default. Expected behaviour, not a defect. + await expect.poll(async () => await frame.getAttribute('data-rightbar-collapsed')).toBe('true') + await expect.poll(async () => await expandOf(page).count()).toBe(1) + expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0) + }) + + it('opens a context menu on right-click that the strip cannot clip', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-menu')) + const column = page.locator('[data-rightbar-col]') + + await ensureExpanded(page, column) + await expect.poll(async () => await column.locator('[data-dockkit-tab]').count()).toBeGreaterThan(0) + // No "more" control on the chip: the chip carries its close, and the menu + // is the secondary press. + expect(await column.locator('[data-dockkit-tab-more]').count()).toBe(0) + await column.locator('[data-dockkit-tab]').first().click({ button: 'right' }) + const menu = page.locator('[data-dockkit-tab-menu]') + await expect.poll(async () => await menu.count()).toBe(1) + + // The kit's one item; the extension seat is declared and rendered, and + // with no registrant it contributes nothing, which is what "declared, not + // speculative" looks like from the outside. + await expect.poll(async () => await menu.getByRole('menuitem').allInnerTexts()).toEqual(['Close']) + // The menu hangs below the strip that clips its overflow. Its pixels are + // its own: a hit test at its centre lands on it, not on whatever the + // strip would have shown through a clipped box. + expect(await hitsItself(menu)).toBe(true) + const box = await menu.boundingBox() + const viewport = page.viewportSize() + if (box === null || viewport === null) throw new Error('menu or viewport is not measurable') + expect(box.x).toBeGreaterThanOrEqual(0) + expect(box.x + box.width).toBeLessThanOrEqual(viewport.width) + await page.keyboard.press('Escape') + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + // The product ships Chinese; the cases above advertise English so their role + // locators stay stable. This is the other half of the same seam, and the + // screenshot it takes is what the copy draft gets reviewed from. It lives in + // this block because a settled session is its precondition too — a case that + // depends on a sibling block's setup passes only in the right order. + it('renders the shipped Chinese copy on a Chinese page', async () => { + const zhPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const zhTripwire = watchConsole(zhPage) + onTestFailed(() => saveFailureShot(zhPage, 'web-e2e-sidebar-right-zh')) + try { + await zhPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await zhPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + // A fresh page opens the workspace on a blank session's hero, which has + // no session header and so no expand button. The settled session is the + // second row of the tree; pick it the way a user would. + await zhPage.getByRole('treeitem', { name: /Show the right sidebar\./u }).first().click() + const column = zhPage.locator('[data-rightbar-col]') + await expandOf(zhPage).waitFor({ timeout: 20_000 }) + await expandOf(zhPage).click() + + const guide = column.locator('[data-sidebar-right-guide]') + await expect.poll(async () => await guide.count()).toBe(1) + // Wait for the track, not just the panel: the copy is only legible once + // the column has the width, and a screenshot taken mid-transition reads + // as a layout defect that is not there. + expect(await width(column)).toBeGreaterThan(300) + await expect.poll(async () => await tabTitles(column)).toEqual(['开始']) + await expect.poll(async () => await guide.locator('p').first().innerText()) + .toBe('侧栏用来放你想一直看着的东西。') + await shot(zhPage, '05-guide-copy-zh') + + expect(zhTripwire.pageErrors).toEqual([]) + expect(zhTripwire.warnings).toEqual([]) + } finally { + await zhPage.close() + } + }, 120_000) + }) +}) diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index fec0614de5..81c1e347a7 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 17c8670403..cdfc17fae0 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -87,6 +87,7 @@ "tests/feedback-command.e2e.ts", "tests/feedback-release.e2e.ts", "tests/agent-team-panel.e2e.ts", + "tests/sidebar-right.e2e.ts", "tests/startup-auto-selection.e2e.ts", "tests/produced-files.e2e.ts", "tests/produced-file-mentions.e2e.ts", diff --git a/benchmarks/agent-continuation/agent-continuation.worker.ts b/benchmarks/agent-continuation/agent-continuation.worker.ts index 7e676e9971..91faa5f347 100644 --- a/benchmarks/agent-continuation/agent-continuation.worker.ts +++ b/benchmarks/agent-continuation/agent-continuation.worker.ts @@ -10,7 +10,6 @@ import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' import { PARENT_ID, response, resultText, syntheticHistory, TIME_ZERO, WORKLOAD } from './workload.ts' @@ -82,7 +81,6 @@ async function measure(root: string, scenario: string): Promise { const ctx = new Context() - await ctx.plugin(SessionProjectionRegistry) const agentScenario = scenario === 'agent-resume' if (agentScenario) await mountAgentLoopTestDependencies(ctx) - else await ctx.plugin(SessionStore) + else { + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SessionStore) + } await installProjectionSet(ctx, agentScenario) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' }) let history: SessionHistoryController | undefined diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 12a7a1d617..fd58c7ae00 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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/architecture.md -architecture.md: fcb9c1e59b60dc059ab66b64ac26acd3c9157c96 -architecture.zh.md: d7a0a3833ebf9397837967065249d7fe1650d707 +architecture.md: a77dfd06c61cbb12d7008133a7e8d515a3449a02 +architecture.zh.md: 83fa958609500cc85af9c0d1f7e339104f6c18c7 diff --git a/docs/architecture.md b/docs/architecture.md index fcb9c1e59b..a77dfd06c6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,6 +46,12 @@ Vendored CLIs, build-only and test-only executables, direct in-process plugin mo The Python SDK follows the same application architecture. Its runtime wheel packages the normal `dsh` CLI as `deepseek-harness-sdk-runtime--`, and the client launches `dsh --profile sdk` with an explicit Harness home by default. The minimal example selects the shipped `sdk-minimal` profile. Python exposes profile selection and ordered patch files rather than a complete Cordis tree; persistent external plugins are installed through `dsh plugin`. The removed private direct-config carrier has no compatibility bin or fallback parser. +## Desktop application + +The [Electron desktop application](../apps/desktop/README.md) owns the reserved `$DSH_HOME/profiles/desktop` npm project. Each signed Electron release binds one exact dsh version and carries a first-party offline seed; startup installs that version into the writable profile with the bundled pnpm, while retaining exact desktop-plugin versions from the previous profile. CLI profiles share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules` with Desktop. + +Electron starts the private Desktop Host package under its bundled upstream Node.js process; that package loads the installed dsh backend and matching client graph from the reserved profile. Unary RPC, Remote streams, and version-matched client assets cross versioned framed byte pipes with Node IPC reserved for lifecycle control, then reach the renderer through the secure `dsh-app://` protocol; the desktop composition opens no Web server or loopback port. Only shell-owned UI can run plugin transactions through the bundled pnpm and its private `$DSH_HOME/desktop/pnpm/store`. + ## Core packages Here are some core packages that contribute to the Cordis tree. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index d7a0a3833e..83fa958609 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -46,6 +46,12 @@ Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂 Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI 打包为 `deepseek-harness-sdk-runtime--`,客户端默认以显式 Harness home 启动 `dsh --profile sdk`。极简示例选择随附的 `sdk-minimal` profile。Python 暴露 profile 选择与有序 patch 文件,而不是完整 Cordis 树;持久外部插件通过 `dsh plugin` 安装。已删除的私有直读配置载体没有兼容 bin 或回退 parser。 +## 桌面应用 + +[Electron 桌面应用](../apps/desktop/README.zh.md)持有保留的 `$DSH_HOME/profiles/desktop` npm 项目。每个签名 Electron 发行版绑定一个确切 dsh 版本并携带第一方离线 seed;启动时通过内置 pnpm 把该版本安装进可写 profile,同时保留旧 profile 中桌面插件的确切版本。CLI profile 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、lockfile 或 `node_modules`。 + +Electron 通过内置的上游 Node.js 进程启动私有 Desktop Host 包;该包从保留 profile 加载已安装的 dsh 后端与匹配的客户端图。一元 RPC、Remote stream 与版本匹配的客户端资源经带版本的分帧字节管道传输,Node IPC 只保留生命周期控制,再通过安全的 `dsh-app://` 协议到达渲染进程;因此桌面组合不会开放 Web server 或 loopback 端口。只有壳自有 UI 能通过内置 pnpm 及其私有 `$DSH_HOME/desktop/pnpm/store` 执行插件事务。 + ## 核心包 以下是向 Cordis 树贡献内容的部分核心包。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 93823b7b78..c370fbef7f 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -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/capability-seams.md -capability-seams.md: fd27d61959fbc53589c1345cfb9cc9bc0acbb161 -capability-seams.zh.md: 6fb09b90df6656c6b57f69afd0c76ef39baf5510 +capability-seams.md: a47ec285e248c4d969e49bc941d5c93faef7b65e +capability-seams.zh.md: 42e7105611f21f74ee0d8088e06e5c7007af9376 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index fd27d61959..a47ec285e2 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -44,6 +44,8 @@ flowchart LR pkg_api_settings_controller["api-settings-controller"] svc_credentialsController["ctx.credentialsController
Host credential-surface Remote controller"] svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"] + pkg_api_workspace_files["api-workspace-files"] + svc_workspaceFiles["ctx.workspaceFiles
Host workspace file Remote service"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] svc_directoryPickerController["ctx.directoryPickerController
Host directory-picking Remote controller"] @@ -232,6 +234,7 @@ flowchart LR pkg_api_settings_controller --> svc_settingsController pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController + pkg_api_workspace_files --> svc_workspaceFiles pkg_attachment --> svc_attachments pkg_attachment_local --> svc_attachments pkg_authorization --> svc_authorization @@ -480,6 +483,7 @@ flowchart LR | `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Lists the Session composition's user-invocable skills without activating a cold Agent. | | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. | +| `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session's workspace root, confined by lstat, containment, and a stat re-check. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 6fb09b90df..42e7105611 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -46,6 +46,8 @@ flowchart LR pkg_api_settings_controller["api-settings-controller"] svc_credentialsController["ctx.credentialsController
Host credential-surface Remote controller"] svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"] + pkg_api_workspace_files["api-workspace-files"] + svc_workspaceFiles["ctx.workspaceFiles
Host workspace file Remote service"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] svc_directoryPickerController["ctx.directoryPickerController
Host directory-picking Remote controller"] @@ -234,6 +236,7 @@ flowchart LR pkg_api_settings_controller --> svc_settingsController pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController + pkg_api_workspace_files --> svc_workspaceFiles pkg_attachment --> svc_attachments pkg_attachment_local --> svc_attachments pkg_authorization --> svc_authorization @@ -482,6 +485,7 @@ flowchart LR | `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 在不激活冷 Agent 的前提下列出 Session 组合中允许用户调用的 skill。 | | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 | +| `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | 为会话工作区根内的文件提供 stat、分页文本、字节窗口、目录列举与变更流,经 lstat、包含关系与 stat 重检限定。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7aafa5de3d..d52edac87b 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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/config-catalog.md -config-catalog.md: 90ec60c30c5ef2ef5fcf75e18ab3d2b51f9cac60 -config-catalog.zh.md: e5c3134561393afe3267808ee04a4cf46399a0f9 +config-catalog.md: 12e308b54e0a3cded9ef06f2963a3b6fb3c993b8 +config-catalog.zh.md: 42ea196d28ea8e628e31a235ef71760ece18f6ab diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 90ec60c30c..12e308b54e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -229,6 +229,32 @@ export interface Config { Source: [`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts) + + +## `@deepseek-ai/dsh-api-workspace-files` + +Requires: `fs` · `sandboxPolicy` · `typert` + +```ts config-catalog +/** Deployment caps on one page or one listing. */ +export interface Config { + /** + * Inclusive byte cap on one page's text and on one byte window. + * + * A page above this fails; it is not shortened, because a silently cut page + * reads as the whole page. A byte window asking for more is refused the same + * way. The file itself has no size cap: a caller pages through it. + */ + readonly maxBytes: number + /** Default and largest page size in lines; a request asking for more is refused. */ + readonly maxLines: number + /** Cap on returned directory entries; the rest is dropped and reported cut. */ + readonly maxEntries: number +} +``` + +Source: [`packages/api/workspace-files/src/index.ts:57`](../packages/api/workspace-files/src/index.ts) + ## `@deepseek-ai/dsh-attachment-local` @@ -315,7 +341,7 @@ Source: [`packages/shell/bash-sandbox/src/index.ts:36`](../packages/shell/bash-s ## `@deepseek-ai/dsh-client-connection` -Requires: `webServer` · `credentials` +Requires: `credentials` ```ts config-catalog /** Browser authentication, request limits, and connection recovery configuration. */ @@ -744,7 +770,7 @@ export interface Config { } ``` -Source: [`packages/fs/fs-local/src/index.ts:41`](../packages/fs/fs-local/src/index.ts) +Source: [`packages/fs/fs-local/src/index.ts:42`](../packages/fs/fs-local/src/index.ts) @@ -3397,7 +3423,8 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-file-upload` — requires `agents` · `attachments` · `commands` · `connection` ([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) -- `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) +- `@deepseek-ai/dsh-client-modules` — requires `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) +- `@deepseek-ai/dsh-client-resources` ([`packages/client/resources/src/index.ts`](../packages/client/resources/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-approval` ([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment` ([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) @@ -3428,6 +3455,9 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory` ([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-sidebar-right` ([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-sidebar-textpreview` ([`packages/client/ui-sidebar-textpreview/src/index.ts`](../packages/client/ui-sidebar-textpreview/src/index.ts)) - `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) - `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) @@ -3503,6 +3533,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-client-store` ([`packages/client/store/src/index.ts`](../packages/client/store/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime` ([`packages/test-support/client-runtime/src/index.ts`](../packages/test-support/client-runtime/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-dockkit` ([`packages/client/ui-dockkit/src/index.ts`](../packages/client/ui-dockkit/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e5c3134561..42ea196d28 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -231,6 +231,32 @@ export interface Config { 来源:[`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts) + + +## `@deepseek-ai/dsh-api-workspace-files` + +Requires: `fs` · `sandboxPolicy` · `typert` + +```ts config-catalog +/** Deployment caps on one page or one listing. */ +export interface Config { + /** + * Inclusive byte cap on one page's text and on one byte window. + * + * A page above this fails; it is not shortened, because a silently cut page + * reads as the whole page. A byte window asking for more is refused the same + * way. The file itself has no size cap: a caller pages through it. + */ + readonly maxBytes: number + /** Default and largest page size in lines; a request asking for more is refused. */ + readonly maxLines: number + /** Cap on returned directory entries; the rest is dropped and reported cut. */ + readonly maxEntries: number +} +``` + +来源:[`packages/api/workspace-files/src/index.ts:57`](../packages/api/workspace-files/src/index.ts) + ## `@deepseek-ai/dsh-attachment-local` @@ -317,7 +343,7 @@ export type Config = LocalConfig ## `@deepseek-ai/dsh-client-connection` -需要: `webServer` · `credentials` +需要:`credentials` ```ts config-catalog /** Browser authentication, request limits, and connection recovery configuration. */ @@ -3399,7 +3425,8 @@ export interface Config { - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-file-upload` — 需要 `agents` · `attachments` · `commands` · `connection`([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) -- `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) +- `@deepseek-ai/dsh-client-modules` — 需要 `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) +- `@deepseek-ai/dsh-client-resources`([`packages/client/resources/src/index.ts`](../packages/client/resources/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-approval`([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment`([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) @@ -3430,6 +3457,9 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory`([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-sidebar-right`([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-sidebar-textpreview`([`packages/client/ui-sidebar-textpreview/src/index.ts`](../packages/client/ui-sidebar-textpreview/src/index.ts)) - `@deepseek-ai/dsh-client-ui-skill`([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) - `@deepseek-ai/dsh-client-ui-subagent`([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme`([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) @@ -3504,6 +3534,7 @@ export interface Config { - `@deepseek-ai/dsh-brand`([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-client-store`([`packages/client/store/src/index.ts`](../packages/client/store/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime`([`packages/test-support/client-runtime/src/index.ts`](../packages/test-support/client-runtime/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-dockkit`([`packages/client/ui-dockkit/src/index.ts`](../packages/client/ui-dockkit/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives`([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slots`([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web`([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index f0995f4194..e2773ec3e0 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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/event-producer-consumer.md -event-producer-consumer.md: c352fe57795ee3c0c8f8a4adef0b06ff109dec76 -event-producer-consumer.zh.md: 5a091d09ca9ba8ea9ee3274096b1cca181768cfe +event-producer-consumer.md: 88413a45350827dafb904638cae51c1c950e8e32 +event-producer-consumer.zh.md: 8b0f845d4517c295d5fd86eab97f7ad3d1a3f234 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c352fe5779..88413a4535 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,19 +9,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:315`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:213`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:345`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:242`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:250`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:289`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:369`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:296`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:387`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | @@ -41,7 +41,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:57`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 5a091d09ca..8b0f845d45 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -11,19 +11,19 @@ | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:315`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:213`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:345`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:242`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:250`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:289`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:369`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:296`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:387`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | @@ -43,7 +43,7 @@ | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:57`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | @@ -56,10 +56,10 @@ | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:172`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:163`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:168`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:159`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 79ef8b4b09..7789f3c308 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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/module-graph.md -module-graph.md: 0951780b0bf53b35f75ef13c50a3875138d756cc -module-graph.zh.md: 2a073370bd4de913f3a1293fcbebb9b9e25954f4 +module-graph.md: ecb72f02bc7a27a75318e8abf56c20f9915831ea +module-graph.zh.md: 5c94c866d10e81948dd69089210cd4b08ded7531 diff --git a/docs/module-graph.md b/docs/module-graph.md index 0951780b0b..ecb72f02bc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -114,6 +114,7 @@ flowchart TD pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] pkg_api_workspace_controller["api-workspace-controller"] + pkg_api_workspace_files["api-workspace-files"] end subgraph group_attachment["packages/attachment"] pkg_attachment["attachment"] @@ -137,6 +138,7 @@ flowchart TD pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] + pkg_client_resources["client-resources"] pkg_client_store["client-store"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] @@ -148,6 +150,7 @@ flowchart TD pkg_client_ui_deliverables["client-ui-deliverables"] pkg_client_ui_directory_picker_browse["client-ui-directory-picker-browse"] pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"] + pkg_client_ui_dockkit["client-ui-dockkit"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_input_trigger["client-ui-input-trigger"] pkg_client_ui_jobs["client-ui-jobs"] @@ -168,6 +171,9 @@ flowchart TD pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"] pkg_client_ui_settings_plugins["client-ui-settings-plugins"] pkg_client_ui_sidebar["client-ui-sidebar"] + pkg_client_ui_sidebar_files["client-ui-sidebar-files"] + pkg_client_ui_sidebar_right["client-ui-sidebar-right"] + pkg_client_ui_sidebar_textpreview["client-ui-sidebar-textpreview"] pkg_client_ui_skill["client-ui-skill"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_subagent["client-ui-subagent"] @@ -457,6 +463,7 @@ flowchart TD pkg_agent --> pkg_session_projection pkg_agent --> pkg_system_prompt pkg_agent --> pkg_typert_protocol + pkg_agent --> pkg_util_values pkg_fs --> pkg_brand pkg_fs --> pkg_invariants pkg_fs --> pkg_llm @@ -868,11 +875,6 @@ flowchart TD pkg_tool_terminal --> pkg_system_prompt pkg_tool_terminal --> pkg_terminal pkg_tool_terminal --> pkg_tools - pkg_agent_loop_testkit --> pkg_agent - pkg_agent_loop_testkit --> pkg_llm - pkg_agent_loop_testkit --> pkg_session - pkg_agent_loop_testkit --> pkg_system_prompt - pkg_agent_loop_testkit --> pkg_tools pkg_llm_replay --> pkg_compaction pkg_llm_replay --> pkg_deepseek_llm_api_extensions pkg_llm_replay --> pkg_llm @@ -946,6 +948,13 @@ flowchart TD pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tools pkg_tool_pwsh --> pkg_user_approval + pkg_agent_loop_testkit --> pkg_agent + pkg_agent_loop_testkit --> pkg_agent_loop + pkg_agent_loop_testkit --> pkg_llm + pkg_agent_loop_testkit --> pkg_session + pkg_agent_loop_testkit --> pkg_session_projection + pkg_agent_loop_testkit --> pkg_system_prompt + pkg_agent_loop_testkit --> pkg_tools pkg_webhook --> pkg_agent pkg_webhook --> pkg_agent_default_model pkg_webhook --> pkg_agent_presets @@ -1176,6 +1185,7 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | +| [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | | [`base`](../packages/bundle/base) | `bundle` | — | @@ -1185,6 +1195,7 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | — | | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | +| [`client-resources`](../packages/client/resources) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | @@ -1196,6 +1207,7 @@ flowchart TD | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | — | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | — | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — | +| [`client-ui-dockkit`](../packages/client/ui-dockkit) | `client` | — | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | — | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — | | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | — | @@ -1216,6 +1228,9 @@ flowchart TD | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | +| [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — | +| [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — | +| [`client-ui-sidebar-textpreview`](../packages/client/ui-sidebar-textpreview) | `client` | — | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | — | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — | @@ -1290,7 +1305,7 @@ flowchart TD | [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) | -| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | +| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1377,7 +1392,6 @@ flowchart TD | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | -| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | @@ -1390,6 +1404,7 @@ flowchart TD | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 2a073370bd..5c94c866d1 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -116,6 +116,7 @@ flowchart TD pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] pkg_api_workspace_controller["api-workspace-controller"] + pkg_api_workspace_files["api-workspace-files"] end subgraph group_attachment["packages/attachment"] pkg_attachment["attachment"] @@ -139,6 +140,7 @@ flowchart TD pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] + pkg_client_resources["client-resources"] pkg_client_store["client-store"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] @@ -150,6 +152,7 @@ flowchart TD pkg_client_ui_deliverables["client-ui-deliverables"] pkg_client_ui_directory_picker_browse["client-ui-directory-picker-browse"] pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"] + pkg_client_ui_dockkit["client-ui-dockkit"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_input_trigger["client-ui-input-trigger"] pkg_client_ui_jobs["client-ui-jobs"] @@ -170,6 +173,9 @@ flowchart TD pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"] pkg_client_ui_settings_plugins["client-ui-settings-plugins"] pkg_client_ui_sidebar["client-ui-sidebar"] + pkg_client_ui_sidebar_files["client-ui-sidebar-files"] + pkg_client_ui_sidebar_right["client-ui-sidebar-right"] + pkg_client_ui_sidebar_textpreview["client-ui-sidebar-textpreview"] pkg_client_ui_skill["client-ui-skill"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_subagent["client-ui-subagent"] @@ -459,6 +465,7 @@ flowchart TD pkg_agent --> pkg_session_projection pkg_agent --> pkg_system_prompt pkg_agent --> pkg_typert_protocol + pkg_agent --> pkg_util_values pkg_fs --> pkg_brand pkg_fs --> pkg_invariants pkg_fs --> pkg_llm @@ -870,11 +877,6 @@ flowchart TD pkg_tool_terminal --> pkg_system_prompt pkg_tool_terminal --> pkg_terminal pkg_tool_terminal --> pkg_tools - pkg_agent_loop_testkit --> pkg_agent - pkg_agent_loop_testkit --> pkg_llm - pkg_agent_loop_testkit --> pkg_session - pkg_agent_loop_testkit --> pkg_system_prompt - pkg_agent_loop_testkit --> pkg_tools pkg_llm_replay --> pkg_compaction pkg_llm_replay --> pkg_deepseek_llm_api_extensions pkg_llm_replay --> pkg_llm @@ -948,6 +950,13 @@ flowchart TD pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tools pkg_tool_pwsh --> pkg_user_approval + pkg_agent_loop_testkit --> pkg_agent + pkg_agent_loop_testkit --> pkg_agent_loop + pkg_agent_loop_testkit --> pkg_llm + pkg_agent_loop_testkit --> pkg_session + pkg_agent_loop_testkit --> pkg_session_projection + pkg_agent_loop_testkit --> pkg_system_prompt + pkg_agent_loop_testkit --> pkg_tools pkg_webhook --> pkg_agent pkg_webhook --> pkg_agent_default_model pkg_webhook --> pkg_agent_presets @@ -1178,6 +1187,7 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | +| [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | | [`base`](../packages/bundle/base) | `bundle` | — | @@ -1187,6 +1197,7 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | — | | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | +| [`client-resources`](../packages/client/resources) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | @@ -1198,6 +1209,7 @@ flowchart TD | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | — | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | — | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — | +| [`client-ui-dockkit`](../packages/client/ui-dockkit) | `client` | — | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | — | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — | | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | — | @@ -1218,6 +1230,9 @@ flowchart TD | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | +| [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — | +| [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — | +| [`client-ui-sidebar-textpreview`](../packages/client/ui-sidebar-textpreview) | `client` | — | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | — | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — | @@ -1292,7 +1307,7 @@ flowchart TD | [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) | -| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | +| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1379,7 +1394,6 @@ flowchart TD | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | -| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | @@ -1392,6 +1406,7 @@ flowchart TD | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 2671f9fa0c..6775871bcd 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -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/persistence-catalog.md -persistence-catalog.md: c47cd8a09d4c7a5179bb36d9c62611564b95a5cc -persistence-catalog.zh.md: c67d315c91565cee5855ee5b0667672151b83323 +persistence-catalog.md: 8d6f66dc949b6d41530eb387534689dc66f1e99f +persistence-catalog.zh.md: 06d07ceab984ad20ee6637227d54fafbbb58bdd4 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index c47cd8a09d..8d6f66dc94 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -101,8 +101,8 @@ Sources: [`packages/core/session/src/types.ts:385`](../packages/core/session/src ```ts persistence-catalog /** * One normalized mutation of an agent's durable pending-message lists. - * Live dispatch precedes projection mutation, so synchronous observers may - * read the pre-splice inbox to recover the removed messages. + * The session-projection registry applies the committed event before + * `Session.append()` returns; Inbox live notifications follow that commit. */ 'agent/inbox/spliced': { target: InboxTarget @@ -113,7 +113,7 @@ Sources: [`packages/core/session/src/types.ts:385`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:58`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:87`](../packages/core/agent/src/types.ts) ### `agent-preset/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c67d315c91..06d07ceab9 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -103,8 +103,8 @@ export type SessionEvent = { ```ts persistence-catalog /** * One normalized mutation of an agent's durable pending-message lists. - * Live dispatch precedes projection mutation, so synchronous observers may - * read the pre-splice inbox to recover the removed messages. + * The session-projection registry applies the committed event before + * `Session.append()` returns; Inbox live notifications follow that commit. */ 'agent/inbox/spliced': { target: InboxTarget @@ -115,7 +115,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/agent/src/types.ts:58`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:87`](../packages/core/agent/src/types.ts) ### `agent-preset/*` diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 9176b4e312..a39cbcf162 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -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/README.md -README.md: 7ead36412136b00eb284897bb1114ead7d4d96d4 -README.zh.md: bc7500c00d5bb68cb7d4bf607e3d3a58bd2c55cc +README.md: 9afbdda338ed797279b719eaf577be45a84349e2 +README.zh.md: ee3c89830a76bc1cddb83e0a50507f3e4439800d diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index 7ead364121..9afbdda338 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -54,6 +54,8 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [web-client.md](web-client.md) | the browser architecture: boot, Remote communication, paired Client models, UI adapters, Conversation assembly, Slots, and reconnect semantics | | [client-modules.md](client-modules.md) | the web plugin table: `dsh.client` declarations, `WebBootGraph` wire composition, the bundle route and index tap | | [slots.md](slots.md) | typed Web UI composition: declaration ownership, cardinality and scope, framework and feature injection, props derivation, and the shipped hierarchy | +| [client-resources.md](client-resources.md) | the client resource model: `dsh-resource:///…` addresses, protocol providers and `ResourceProtocolMap`, the `useResource` global hook and its states, pins and release | +| [sidebar-right.md](sidebar-right.md) | the right Sidebar: resource and navigation addresses, tab-type registration and routing, the `ctx.sidebarRight` navigation service, the pane-tab slots and owner props, the resource model, and the Workspace Files service | | [conversation.md](conversation.md) | target-neutral Session-event assembly: Context identity, Location data, replay paths, view builders, and target-owned render nodes | | [session-projection.md](session-projection.md) | the projection seam: `SessionProjectionMap`, the pure `ProjectionDefinition` unit, `ProjectionSnapshot`'s consistent cut, the change feed | | [session-telemetry.md](session-telemetry.md) | the outbound session-reporting capability seam: `SessionTelemetryRecord`/`SessionTelemetrySeverity`, the `SessionTelemetrySink` contract, and the `session-telemetry/record` redact waterfall | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index bc7500c00d..ee3c89830a 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -54,6 +54,8 @@ | [web-client.md](web-client.zh.md) | 浏览器架构:启动、Remote 通信、配对的 Client model、UI adapter、Conversation 组装、Slots 与重连语义 | | [client-modules.md](client-modules.zh.md) | Web 插件表:`dsh.client` 声明、`WebBootGraph` 线上组合、bundle 路由与 index 转换 | | [slots.md](slots.zh.md) | 类型化 Web UI 组合:声明所有权、cardinality 与 scope、框架与功能注入、props 推导及当前层级 | +| [client-resources.md](client-resources.zh.md) | 客户端资源模型:`dsh-resource:///…` 地址、协议提供方与 `ResourceProtocolMap`、`useResource` 全局 hook 及其状态、钉住与释放 | +| [sidebar-right.md](sidebar-right.zh.md) | 右侧 Sidebar:资源地址与导航地址、tab 类型注册与路由、`ctx.sidebarRight` 导航服务、pane-tab slot 与 owner props、资源模型及 Workspace Files 服务 | | [conversation.md](conversation.zh.md) | target-neutral Session event 组装:Context identity、Location data、replay 路径、view builder 与 target 自有 render node | | [session-projection.md](session-projection.zh.md) | 投影 seam:`SessionProjectionMap`、纯函数 `ProjectionDefinition` 单元、`ProjectionSnapshot` 的一致切面、变更馈送 | | [session-telemetry.md](session-telemetry.zh.md) | 对外会话上报能力 seam:`SessionTelemetryRecord`/`SessionTelemetrySeverity`、`SessionTelemetrySink` 约定和 `session-telemetry/record` 脱敏 waterfall | diff --git a/docs/subsystems/client-modules.i18n.yaml b/docs/subsystems/client-modules.i18n.yaml index db0e53ba0e..33e35b5377 100644 --- a/docs/subsystems/client-modules.i18n.yaml +++ b/docs/subsystems/client-modules.i18n.yaml @@ -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/client-modules.md -client-modules.md: f58cb6592a009292ffc4f87a207fe4e103fd2365 -client-modules.zh.md: 18c72dfba6f851776100a9b6200e7222d580db7c +client-modules.md: 12925fa8ddb7c7193af76cf1bed5894f5d7d75e0 +client-modules.zh.md: 513391af8369a5ba4d9a439a14f7fb5eb1f45d27 diff --git a/docs/subsystems/client-modules.md b/docs/subsystems/client-modules.md index f58cb6592a..12925fa8dd 100644 --- a/docs/subsystems/client-modules.md +++ b/docs/subsystems/client-modules.md @@ -130,6 +130,15 @@ graph(): WebBootGraph */ clientPath(id: string): string | undefined +/** + * Serve an advertised revisioned bundle or source map without a Web server. + * Unknown URLs return 404, unsupported methods return 405, and `HEAD` + * returns the same immutable headers without a body. + * @param request - shell-carrier request for a `/plugins` resource. + * @returns the exact response also exposed by the optional Web route. + */ +fetchBundle(request: Request): Response + /** * Filesystem baseline captured before an entry's current bytes were read. * HMR compares it with the live files when installing a watch, so a write diff --git a/docs/subsystems/client-modules.zh.md b/docs/subsystems/client-modules.zh.md index 18c72dfba6..513391af83 100644 --- a/docs/subsystems/client-modules.zh.md +++ b/docs/subsystems/client-modules.zh.md @@ -130,6 +130,15 @@ graph(): WebBootGraph */ clientPath(id: string): string | undefined +/** + * Serve an advertised revisioned bundle or source map without a Web server. + * Unknown URLs return 404, unsupported methods return 405, and `HEAD` + * returns the same immutable headers without a body. + * @param request - shell-carrier request for a `/plugins` resource. + * @returns the exact response also exposed by the optional Web route. + */ +fetchBundle(request: Request): Response + /** * Filesystem baseline captured before an entry's current bytes were read. * HMR compares it with the live files when installing a watch, so a write diff --git a/docs/subsystems/client-resources.i18n.yaml b/docs/subsystems/client-resources.i18n.yaml new file mode 100644 index 0000000000..e1267b8d56 --- /dev/null +++ b/docs/subsystems/client-resources.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/subsystems/client-resources.md +client-resources.md: a1643e61779e5c19450c7e1688b0ce7fcfbee786 +client-resources.zh.md: 418c9aef91a58a85ff8102617074a38813c5f94b diff --git a/docs/subsystems/client-resources.md b/docs/subsystems/client-resources.md new file mode 100644 index 0000000000..a1643e6177 --- /dev/null +++ b/docs/subsystems/client-resources.md @@ -0,0 +1,94 @@ +# Client Resources + +English | [中文](client-resources.zh.md) + +The client resource model turns an address into live data for any Web Client component. [`dsh-client-resources`](../../packages/client/resources/README.md) provides the `ctx.resources` service and the `useResource` global standard hook; a package that owns a kind of content registers one **provider** for its **protocol**, and a component reads the content's current state by **address** without importing the owner's runtime. The right Sidebar's tabs are the model's first consumer ([Right Sidebar](sidebar-right.md)); the decision record is the [client resource model Agent Note](../../.agents/notes/implemented/architecture/2026-09-05-client-resource-model.md). + +This page is the developer reference: how to write an address, how to register a provider, how to read a resource, what the states and failures mean, and how the model holds and releases a resource. + +## Addresses + +A resource address is a `dsh-resource:///…` URL. The host names the protocol and must be a key of `ResourceProtocolMap`; the path is the protocol's own, and its owner percent-encodes each segment. A protocol that needs a scope puts it in the path: the `file` protocol's addresses read `dsh-resource://file/session//` or `dsh-resource://file/absolute/`, built with `fileAddressFor(sessionId, cwd, path)` and read back with `parseFileAddress(address)` from [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.md). The model itself reads only the scheme and the host: `protocolOf(address)` returns the lower-cased host of a `dsh-resource://` URL and `undefined` for anything else. Addresses under any other scheme — the Sidebar's `sidebar://guide` — name no resource and read as `none`. + +| Address | Protocol key | Reads as | +|---|---|---| +| `dsh-resource://file/session/s1/notes/a.md` | `file` | the metadata of `notes/a.md` under session `s1`'s workspace root, when the `file` provider is registered | +| `dsh-resource://file/absolute/home/me/notes.md` | `file` | the metadata of that absolute path, read through the current session and confined to its workspace | +| `DSH-RESOURCE://File/session/s1/a` | `file` | a distinct record: addresses compare as strings, and `openResource` accepts only the canonical lower-case spelling that `fileAddressFor` emits | +| `sidebar://guide` | — | `none`: a navigation address | +| `/home/me/notes.md` | — | `none`: not a URL | + +## Registering a provider + +The owner of a protocol declares its value type on `ResourceProtocolMap` and registers one provider inside its own `ctx.effect`, so the protocol lives exactly as long as the plugin ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). `open(address, { signal })` returns a stream of `RemoteResult` frames — the current state first, then one frame per change — and must stop when `signal` aborts. A failure is an `ok: false` frame carrying a `RemoteFailure`; a throw inside the stream is a programming error and is not caught. `reload(address)` is optional and asks the open stream for a fresh frame. + +```ts ignore-check +import type { Context } from '@deepseek-ai/cordis' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-client-resources/client' + +interface NoteView { readonly title: string; readonly updatedAt: string } + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface ResourceProtocolMap { note: NoteView } +} + +export const inject = ['resources', 'remote'] + +export function apply(ctx: Context): void { + ctx.effect(() => ctx.resources.register<'note'>({ + protocol: 'note', + async *open(address, { signal }): AsyncIterable> { + const id = new URL(address).pathname.slice(1) + yield await ctx.remote.notes.read(id, signal) + for await (const change of ctx.remote.notes.follow(id, signal)) yield change + }, + reload(address) { ctx.remote.notes.requestReread(new URL(address).pathname.slice(1)) }, + }), 'my-notes: note resource provider') +} +``` + +A protocol has exactly one provider; a second registration throws. Registering while addresses of the protocol are already held opens their streams at once; disposing the provider ends those streams and the addresses read `none` until a provider returns. + +## Reading a resource + +Every slot component receives `useResource` in its props, whatever its scope ([Slots](slots.md)). `useResource

(address)` names the protocol as the type argument and returns the address's current snapshot; subscribing is what holds the resource open, and a component that mounts while another holder keeps the resource alive reads the latest value at once without reopening the stream ([read a resource](../../packages/client/resources/README.md#read-a-resource)). + +| `status` | Meaning | `value` | `failure` | +|---|---|---|---| +| `none` | No provider is registered for the address's protocol, or the address is not a resource address | `undefined` | `undefined` | +| `loading` | The provider's stream is open and has not yielded yet | `undefined` | `undefined` | +| `live` | The latest frame succeeded | the latest `ok` value | `undefined` | +| `failed` | The latest frame reported a failure | the last `ok` value, kept | the frame's `RemoteFailure` | + +`reload()` asks the provider for a fresh frame and is a no-op when the protocol has no provider or the provider has no `reload`; the function is reference-stable per address, so a body may hold it. + +```tsx ignore-check +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-api-workspace-files/client' + +type Props = PropsRuntime<'sidebar.right.pane.tab'> + +export function FileHeader({ tab, useResource, t }: Props) { + const meta = useResource<'file'>(tab.contentId) + if (meta.status === 'failed') return

{t('failed', { code: meta.failure.code })}

+ return ( +
+ {tab.title} + {meta.value?.changed && } +
+ ) +} +``` + +A consumer presents `failed` itself: the model keeps the last value beside the failure so a body can show stale content with a notice rather than a blank, and the next `ok` frame clears the failure. Nothing in the model produces user-visible text. + +## Holding and releasing + +A resource is alive while it has a holder: a subscribed `useResource`, or a pin. `ctx.resources.pin(address, signal)` keeps a resource open without subscribing until `signal` aborts, and an already-aborted signal pins nothing; the right Sidebar pins every open tab record's address for the record's life, so switching tabs unmounts a body without closing its stream. The first holder opens the provider's stream; the last release aborts it, discards the value, and returns the snapshot to `loading` (provider present) or `none` (absent). A frame the provider yields after that release is dropped, and the iterator is returned. `ctx.resources.source(address)` is the bare observable behind the hook, reference-stable per address, for callers outside React; reading its snapshot does not hold the resource ([lifecycle](../../packages/client/resources/README.md#lifecycle)). + +Streams carry metadata, not content. The `file` provider's value is `{ absolutePath, version, bytes?, changed }`: `absolutePath`, `version`, and `bytes` from the Host's `stat`, `changed` raised when the Host reports an agent write and cleared by `reload`. A consumer reads the file's text itself, by page, through the Workspace Files Remote namespace ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)). + +## Limits + +Records live for the page lifetime: an address's record stays after its last holder leaves, holding no stream and no value, so memory grows with the number of distinct addresses ever read. A provider that ignores `signal` keeps running until its next frame. The failure type is the Remote face's `RemoteFailure`, so a provider whose source is not a Remote call mints one. A misspelled protocol or a malformed address reads as `none` with no other diagnostic. diff --git a/docs/subsystems/client-resources.zh.md b/docs/subsystems/client-resources.zh.md new file mode 100644 index 0000000000..418c9aef91 --- /dev/null +++ b/docs/subsystems/client-resources.zh.md @@ -0,0 +1,94 @@ +# 客户端资源 + +[English](client-resources.md) | 中文 + +客户端资源模型把一个地址变成任何 Web Client 组件都能读的活数据。[`dsh-client-resources`](../../packages/client/resources/README.zh.md) 提供 `ctx.resources` 服务与 `useResource` 全局标准 hook;拥有某类内容的包为它的**协议**注册一个**提供方**,组件按**地址**读取该内容的当前状态,而无需引用拥有者的运行时。右侧 Sidebar 的 tab 是这个模型的第一个消费方([右侧 Sidebar](sidebar-right.zh.md));决策记录见 [客户端资源模型 Agent Note](../../.agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md)。 + +本页是面向开发者的参考:地址怎么写、提供方怎么注册、资源怎么读、状态与失败各是什么意思、模型怎样持有与释放一份资源。 + +## 地址 + +资源地址是 `dsh-resource:///…` 形式的 URL。host 命名协议,必须是 `ResourceProtocolMap` 的键;路径归协议自己,由其拥有者逐段做百分号编码。需要作用域的协议把作用域放进路径:`file` 协议的地址形如 `dsh-resource://file/session//<相对该会话工作区根的路径>` 或 `dsh-resource://file/absolute/<去掉前导 / 的绝对路径>`,用 [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.zh.md) 的 `fileAddressFor(sessionId, cwd, path)` 构造、`parseFileAddress(address)` 读回。模型本身只读 scheme 与 host:`protocolOf(address)` 对 `dsh-resource://` URL 返回小写 host,对其它任何字串返回 `undefined`。其它 scheme 下的地址——Sidebar 的 `sidebar://guide`——不指向资源,读作 `none`。 + +| 地址 | 协议键 | 读作 | +|---|---|---| +| `dsh-resource://file/session/s1/notes/a.md` | `file` | 会话 `s1` 工作区根下 `notes/a.md` 的元数据(`file` 提供方已注册时) | +| `dsh-resource://file/absolute/home/me/notes.md` | `file` | 该绝对路径的元数据,经当前会话读取、受其工作区限制 | +| `DSH-RESOURCE://File/session/s1/a` | `file` | 另一份记录:地址按字符串比较,`openResource` 只接受 `fileAddressFor` 生成的规范小写拼写 | +| `sidebar://guide` | — | `none`:导航地址 | +| `/home/me/notes.md` | — | `none`:不是 URL | + +## 注册提供方 + +协议拥有者在 `ResourceProtocolMap` 上声明其值类型,并在自己的 `ctx.effect` 里注册一个提供方,使协议与插件同寿([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。`open(address, { signal })` 返回一条 `RemoteResult` 帧流——首帧是当前状态,之后每次变化一帧——并且必须在 `signal` 中止时停下。失败是携带 `RemoteFailure` 的 `ok: false` 帧;流里抛出是编程错误,不会被捕获。`reload(address)` 可选,请已打开的流给一个新帧。 + +```ts ignore-check +import type { Context } from '@deepseek-ai/cordis' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-client-resources/client' + +interface NoteView { readonly title: string; readonly updatedAt: string } + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface ResourceProtocolMap { note: NoteView } +} + +export const inject = ['resources', 'remote'] + +export function apply(ctx: Context): void { + ctx.effect(() => ctx.resources.register<'note'>({ + protocol: 'note', + async *open(address, { signal }): AsyncIterable> { + const id = new URL(address).pathname.slice(1) + yield await ctx.remote.notes.read(id, signal) + for await (const change of ctx.remote.notes.follow(id, signal)) yield change + }, + reload(address) { ctx.remote.notes.requestReread(new URL(address).pathname.slice(1)) }, + }), 'my-notes: note resource provider') +} +``` + +一个协议恰有一个提供方;第二次注册抛错。注册时若该协议的地址已被持有,则立刻打开它们的流;提供方 dispose 时结束这些流,地址读作 `none` 直到提供方回来。 + +## 读取资源 + +每个 slot 组件不论作用域都在 props 上收到 `useResource`([Slots](slots.zh.md))。`useResource

(address)` 以类型参数命名协议,返回该地址的当前快照;订阅就是持有资源的方式,另一个持有者让资源存活时,新挂载的组件立刻读到最新值而不重开流([读取资源](../../packages/client/resources/README.zh.md#read-a-resource))。 + +| `status` | 含义 | `value` | `failure` | +|---|---|---|---| +| `none` | 地址的协议没有注册提供方,或地址不是资源地址 | `undefined` | `undefined` | +| `loading` | 提供方的流已打开、尚未产出 | `undefined` | `undefined` | +| `live` | 最新一帧成功 | 最新的 `ok` 值 | `undefined` | +| `failed` | 最新一帧报告了失败 | 保留的上一个 `ok` 值 | 该帧的 `RemoteFailure` | + +`reload()` 请提供方给一个新帧,协议没有提供方或提供方没有 `reload` 时是空操作;该函数按地址引用稳定,正文可以长期持有。 + +```tsx ignore-check +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-api-workspace-files/client' + +type Props = PropsRuntime<'sidebar.right.pane.tab'> + +export function FileHeader({ tab, useResource, t }: Props) { + const meta = useResource<'file'>(tab.contentId) + if (meta.status === 'failed') return

{t('failed', { code: meta.failure.code })}

+ return ( +
+ {tab.title} + {meta.value?.changed && } +
+ ) +} +``` + +`failed` 由消费方自己呈现:模型把最后一个值留在失败旁,正文可以带提示显示旧内容而不是一片空白,下一个 `ok` 帧会清除失败。模型本身不产生任何用户可见文案。 + +## 持有与释放 + +资源有持有者就存活:一个订阅中的 `useResource`,或一次钉住。`ctx.resources.pin(address, signal)` 在不订阅的情况下让资源保持打开直到 `signal` 中止,已中止的信号什么也不钉;右侧 Sidebar 在每条打开的 tab 记录存续期内钉住其地址,因此切 tab 卸载正文不关流。第一个持有者打开提供方的流;最后一个释放时中止它、丢弃值,并把快照回到 `loading`(有提供方)或 `none`(没有)。提供方在这次释放之后产出的帧被丢弃,迭代器被归还。`ctx.resources.source(address)` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用;只读它的快照不算持有([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。 + +流只推元数据不推内容。`file` 提供方的值是 `{ absolutePath, version, bytes?, changed }`:`absolutePath`、`version` 与 `bytes` 来自 Host 的 `stat`,`changed` 在 Host 报告 agent 写入时置起、由 `reload` 清除。消费方自己经 Workspace Files Remote 命名空间按页读文件文本([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md))。 + +## 限制 + +记录在页面存续期内保留:地址的记录在最后一个持有者离开后仍留着,不持有流也不持有值,因此内存随读过的不同地址数增长。忽略 `signal` 的提供方会一直跑到它的下一帧。失败类型是 Remote 面的 `RemoteFailure`,来源不是 Remote 调用的提供方得自己铸一个。拼错的协议或畸形的地址读作 `none`,没有别的诊断。 diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 811c2e5d21..25c3042d2f 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -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/core.md -core.md: 29e332d068857254e3cd27892adda36a9229a0d7 -core.zh.md: c148b8f6a48d7f01b12f59b5fc14c655a1fb8daa +core.md: 4af3a22478f324655dea1b132f0c8a8528f4b883 +core.zh.md: 7359e5aaa75e9f750d44f1386acab388665e1e1e diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 29e332d068..4af3a22478 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -65,7 +65,7 @@ interface Agent { readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session - /** The agent-owned projection of durable pending work. */ + /** Agent-owned access to durable pending work. */ readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus @@ -210,12 +210,69 @@ Dispatch requires `provider` and `model` after `agent/request`. An explicit `rea The inbox is the delivery vocabulary — two ordered pending-message lists the agent owns as a durable projection: +```ts type-equiv +/** Agent-owned access to pending work; concrete storage belongs to the driver. */ +interface Inbox { + /** Prompts awaiting individual turns. */ + readonly nextTurn: readonly UserMessage[] + /** Input awaiting the next step boundary. */ + readonly nextStep: readonly UserMessage[] + + /** Durably cancel all pending input, clearing next-step before next-turn. */ + clear(): void + + /** + * Append one message to a pending list. + * @param target - pending list to extend. + * @param message - message to append. + */ + append(target: InboxTarget, message: UserMessage): void + + /** + * Prepend one message to a pending list. + * @param target - pending list to extend. + * @param message - message to prepend. + */ + prepend(target: InboxTarget, message: UserMessage): void + + /** + * Replace one pending message in place. + * @param messageId - identity of the pending message to replace. + * @param newMessage - replacement message. + * @returns whether the message was still pending. + */ + replace(messageId: MessageId, newMessage: UserMessage): boolean + + /** + * Remove one pending message. + * @param messageId - identity of the pending message to remove. + * @returns whether the message was still pending. + */ + remove(messageId: MessageId): boolean + + /** + * Apply standard splice semantics and durably record the normalized result. + * @param target - pending list to mutate. + * @param start - splice position. + * @param deleteCount - maximum number of messages to remove. + * @param inserted - messages to insert at the resolved position. + * @returns messages removed by the splice. + */ + splice( + target: InboxTarget, + start: number, + deleteCount: number, + inserted: UserMessage[], + ): UserMessage[] +} +``` + ```ts type-equiv /** One of the two ordered pending-message lists owned by an agent. */ type InboxTarget = 'next-turn' | 'next-step' ``` -Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. `Inbox.append`, `prepend`, `replace`, `remove`, `clear`, `splice`, and `claim` record normalized durable `agent/inbox/spliced` mutations and reject duplicate pending ids. `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists; replacement may change identity and emits the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are cancellations. `claim(target)` removes the proposed step batch — all `next-step` input plus, at a turn boundary, one `next-turn` message — through pure deletion splices without emitting discarded notifications, and the loop separately emits per-message claimed notifications. Whole-queue consumers such as UI projections reconstruct `nextTurn` and `nextStep` from the durable splices, while consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications. +Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. The structural `Inbox` methods record normalized durable `agent/inbox/spliced` mutations and reject duplicate pending ids. `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists; replacement may change identity and emits the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are cancellations. At a step boundary, dsh-agent-loop's package-internal `ReactLoopInbox` removes the proposed batch — all `next-step` input plus, at a turn boundary, one `next-turn` message — through pure deletion splices without discarded notifications, then emits per-message claimed notifications. Loop-only pending detection and claiming are not part of `Agent.inbox`. Each `ReactLoopInbox` constructor contributes the standard `inbox` projection from its agent scope; the registry shares that definition across agents by reference count, and its cell is the sole live state while the same fold serves cold consumers. The fold rejects unsafe or out-of-range splice coordinates and duplicate identities across both lists, identifying malformed durable history by event seq. Consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications. Cancellation: diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index c148b8f6a4..7359e5aaa7 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -69,7 +69,7 @@ interface Agent { readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session - /** The agent-owned projection of durable pending work. */ + /** Agent-owned access to durable pending work. */ readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus @@ -214,12 +214,69 @@ interface AgentOptions { inbox 即投递词汇——agent 以持久投影形式拥有的两条有序待处理消息列表: +```ts type-equiv +/** Agent-owned access to pending work; concrete storage belongs to the driver. */ +interface Inbox { + /** Prompts awaiting individual turns. */ + readonly nextTurn: readonly UserMessage[] + /** Input awaiting the next step boundary. */ + readonly nextStep: readonly UserMessage[] + + /** Durably cancel all pending input, clearing next-step before next-turn. */ + clear(): void + + /** + * Append one message to a pending list. + * @param target - pending list to extend. + * @param message - message to append. + */ + append(target: InboxTarget, message: UserMessage): void + + /** + * Prepend one message to a pending list. + * @param target - pending list to extend. + * @param message - message to prepend. + */ + prepend(target: InboxTarget, message: UserMessage): void + + /** + * Replace one pending message in place. + * @param messageId - identity of the pending message to replace. + * @param newMessage - replacement message. + * @returns whether the message was still pending. + */ + replace(messageId: MessageId, newMessage: UserMessage): boolean + + /** + * Remove one pending message. + * @param messageId - identity of the pending message to remove. + * @returns whether the message was still pending. + */ + remove(messageId: MessageId): boolean + + /** + * Apply standard splice semantics and durably record the normalized result. + * @param target - pending list to mutate. + * @param start - splice position. + * @param deleteCount - maximum number of messages to remove. + * @param inserted - messages to insert at the resolved position. + * @returns messages removed by the splice. + */ + splice( + target: InboxTarget, + start: number, + deleteCount: number, + inserted: UserMessage[], + ): UserMessage[] +} +``` + ```ts type-equiv /** One of the two ordered pending-message lists owned by an agent. */ type InboxTarget = 'next-turn' | 'next-step' ``` -每个待处理入队项就是其 `UserMessage`;`MessageId` 是唯一标识。`Inbox.append`、`prepend`、`replace`、`remove`、`clear`、`splice` 与 `claim` 会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。`replace(messageId, newMessage)` 与 `remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息;替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都表示取消。`claim(target)` 通过纯删除 splice 移除拟进入步骤的批次——全部 `next-step` 输入,外加轮次边界上的一条 `next-turn` 消息——且不发出 discarded 通知;循环另行逐条发出 claimed 通知。UI 投影等整体队列消费方通过持久 splice 重建 `nextTurn` 与 `nextStep`,而跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。 +每个待处理入队项就是其 `UserMessage`;`MessageId` 是唯一标识。结构化 `Inbox` 方法会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。`replace(messageId, newMessage)` 与 `remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息;替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都表示取消。在步骤边界,dsh-agent-loop 包内部的 `ReactLoopInbox` 会通过纯删除 splice 移除拟进入步骤的批次——全部 `next-step` 输入,外加轮次边界上的一条 `next-turn` 消息——且不发出 discarded 通知,随后逐条发出 claimed 通知。仅供循环使用的待处理检测与领取操作不属于 `Agent.inbox`。每个 `ReactLoopInbox` 构造函数都从其 agent 作用域贡献标准 `inbox` 投影;注册表通过引用计数在多个 agent 之间共享该定义,其 cell 是唯一 live 状态,同一份折叠也服务于冷消费方。该 fold 会拒绝不安全或越界的 splice 坐标,以及跨两份列表重复的标识,并通过事件 seq 指出格式错误的持久历史。跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。 取消: diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index 7b592e60fa..e73eb155fd 100644 --- a/docs/subsystems/filesystem.i18n.yaml +++ b/docs/subsystems/filesystem.i18n.yaml @@ -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/filesystem.md -filesystem.md: 06fb92f453f8a09f87aee817ab56ef147be9a91f -filesystem.zh.md: ea5b6f7a0d6f45b0d7960298ea10417c0b6c2dd3 +filesystem.md: c93d5e250860c19ab41f133848f5038b78f1bd57 +filesystem.zh.md: e9011d35b9a52fd42439a23e78fe4bff7ae41c11 diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index 06fb92f453..c93d5e2508 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -396,6 +396,21 @@ abstract streamText(target: FsTarget, signal?: AbortSignal): Promise +/** + * Read one byte window of the regular file as raw bytes with no decoding or + * binary rejection: the bytes at `[offset, offset + length)`, shorter when + * the file ends inside the window and empty when `offset` lies at or past + * its end. The window is the bound here, not the file: a backend transfers + * at most `length` bytes of content beyond the prefix it skips to reach + * `offset` and never buffers the whole file, so the caller's cap on `length` + * is the guard against unbounded buffering. + * @param target - the resolved target to read. + * @param range - `offset`, the 0-based first byte, and `length`, the largest byte count; both non-negative integers. + * @param signal - aborts the read. + * @returns the window's bytes, at most `length` long. + */ +abstract readByteRange(target: FsTarget, range: { offset: number; length: number }, signal?: AbortSignal): Promise + /** * List direct children of a directory in stable name order. Returns resolved * child targets plus cheap metadata only; never reads file contents. diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index ea5b6f7a0d..e9011d35b9 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -396,6 +396,21 @@ abstract streamText(target: FsTarget, signal?: AbortSignal): Promise +/** + * Read one byte window of the regular file as raw bytes with no decoding or + * binary rejection: the bytes at `[offset, offset + length)`, shorter when + * the file ends inside the window and empty when `offset` lies at or past + * its end. The window is the bound here, not the file: a backend transfers + * at most `length` bytes of content beyond the prefix it skips to reach + * `offset` and never buffers the whole file, so the caller's cap on `length` + * is the guard against unbounded buffering. + * @param target - the resolved target to read. + * @param range - `offset`, the 0-based first byte, and `length`, the largest byte count; both non-negative integers. + * @param signal - aborts the read. + * @returns the window's bytes, at most `length` long. + */ +abstract readByteRange(target: FsTarget, range: { offset: number; length: number }, signal?: AbortSignal): Promise + /** * List direct children of a directory in stable name order. Returns resolved * child targets plus cheap metadata only; never reads file contents. diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml new file mode 100644 index 0000000000..1b16e2a532 --- /dev/null +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md +sidebar-right.md: db4103a282287d8ea163d66b19efec27e0a0630f +sidebar-right.zh.md: 1cb0b1e67e99acd7cb6aa12272cd57a5fdd207e0 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md new file mode 100644 index 0000000000..db4103a282 --- /dev/null +++ b/docs/subsystems/sidebar-right.md @@ -0,0 +1,137 @@ +# Right Sidebar + +English | [中文](sidebar-right.zh.md) + +The right Sidebar is the Web Client's per-Session docking surface: a column of panes and tabs beside the conversation in which addressed content — a workspace file, a directory tree, the product's own pages — opens, splits, floats, and closes. [`dsh-client-ui-sidebar-right`](../../packages/client/ui-sidebar-right/README.md) owns the surface, the tab-type registry, and the navigation service; [`dsh-client-ui-dockkit`](../../packages/client/ui-dockkit/README.md) is its internal layout engine; [`dsh-client-resources`](../../packages/client/resources/README.md) turns addresses into live values for any component; [`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) provides both the Host workspace service and the Client `file` resource provider. + +This page is the reference for the subsystem's contracts: addresses, tab-type registration, the navigation service, the extension slots and their owner props, the resource model, the Workspace Files service, the shipped types, and what is deliberately not built. How the layout engine, the frame, and the surface fit together is in the [Agent Note](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md); slot mechanics are in the [Slots reference](slots.md). + +## Position and ownership + +One docking surface exists per Session, held in a session-scoped slot store and drawn by the `rightbar` seat; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and the last pane reseeds the guide tab. + +A tab type is two registrations that share one `kind`: a static definition in `ctx.sidebarRightTabs` saying which addresses the type opens, and a keyed slot registration supplying its body. The framework injects `useTabInfo()` for live Sidebar, pane and tab information; each type keeps its own state in its slot store. Packages import each other's declarations only as types. + +| Package | Role | +|---|---| +| [`client/ui-sidebar-right`](../../packages/client/ui-sidebar-right/README.md) | The panel and rail seats, the layout store, `ctx.sidebarRightTabs`, `ctx.sidebarRight`, the Tab domain, the guide type | +| [`client/ui-dockkit`](../../packages/client/ui-dockkit/README.md) | Pure layout engine and React surface; an internal dependency of `ui-sidebar-right`, not a stable interface | +| [`client/resources`](../../packages/client/resources/README.md) | `ctx.resources`, `useResource`, the protocol → value roster `ResourceProtocolMap` | +| [`api/workspace-files`](../../packages/api/workspace-files/README.md) | Host `ctx.workspaceFiles`, the `workspaceFiles` Remote namespace, and the Client `file` resource provider | +| [`util/workspace-path`](../../packages/util/workspace-path/README.md) | The file address grammar: `fileAddressFor`, `parseFileAddress` | +| [`client/ui-sidebar-textpreview`](../../packages/client/ui-sidebar-textpreview/README.md), [`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.md) | The shipped `text` and `files` types | + +## Addresses + +Every tab is opened by an address string, and the address is the tab's content identity. Two families exist. + +A **resource address** is a `dsh-resource:///…` URL. The host names the resource protocol — the key of `ResourceProtocolMap` — and everything after it is the protocol's own path; one scheme serves every protocol, so adding a protocol adds a host, never a scheme. The `file` protocol's path opens with its scope: `session/` followed by the path relative to that session's workspace root (`dsh-resource://file/session/abc/src/notes.txt`), or `absolute` followed by the absolute path with its leading `/` dropped (`dsh-resource://file/absolute/home/ys/notes.txt`, `dsh-resource://file/absolute/C:/x/y.txt` on Windows). Every id and path segment is component-encoded, with `:` kept literal for drive letters. `fileAddressFor(sessionId, cwd, path)` builds one — a relative path or an absolute path inside the workspace becomes `session`-relative, any other absolute path becomes `absolute` — and `parseFileAddress(address)` reads it back or returns `undefined` ([grammar](../../packages/util/workspace-path/README.md)). + +A **page address** is what the Sidebar records for a tab opened by kind rather than by resource: `sidebar://`, written by the Sidebar itself when `openTab(kind)` runs. Callers never build one — the guide and the file tree are opened as `openTab('guide')` and `openTab('files')` — and no other navigation address exists ([not built](#not-built)). + +Tab identity is the pair `(kind, address)`: the registry's claim uses the address verbatim as the record's `contentId`, so opening the same address through the same type finds the existing tab, and the same address through two types is two tabs. + +## Tab-type registration + +`ctx.sidebarRightTabs.register(definition)` registers one implementation of a type for the caller's lifetime and returns the disposer; the caller holds it inside its own `ctx.effect`, so an implementation lives exactly as long as the plugin that contributed it, and a second registration of the same `id` throws ([extension seats](../../packages/client/ui-sidebar-right/README.md#extension-seats)). The definition is static: no runtime hook, nothing per tab or per session. + +| Field | Meaning | +|---|---| +| `id` | The implementation's identity, unique across every registration; a package name is the natural value (`@deepseek-ai/dsh-client-ui-sidebar-files`). It is the key the body and title register under. | +| `kind` | The type's discriminator: what its tabs are, and what `openTab` names. Not unique — an extension may take over a builtin's kind. The shipped kinds are `guide`, `text`, `files`. | +| `patterns` | Optional resource-address globs the type recognizes; a page type opened by kind omits them. A pattern containing `:` matches the whole address (`dsh-resource://file/**`); one without matches the URL's path at any depth (`*.md`), and an address that is not a URL matches no such pattern. Matching is case-insensitive and does not hide dotfiles; the syntax is picomatch's POSIX dialect. | +| `priority` | One of three literal bands: `extension` (the default and the highest: a type from outside the product outranks every shipped viewer), `builtin` (types shipped with the product), `fallback` (plain-content viewers anything more specific should beat). | +| `canOpen(address)` | Optional synchronous veto of a glob match; it runs on every routing decision. | +| `title(address)` | The chip's text, captured into the layout record when the tab opens and never rewritten. | +| `guide` | Optional entry boxes for the guide page: `{ order, title(), description(), icon? }`. Picking a box opens the contributing type as a page; omit to stay off the page. | + +Routing is a ranked claim. `candidates(address)` ranks the types whose patterns match and whose `canOpen` does not veto: by band, then by the length of the longest matched pattern, then by registration order. `claim(address, kind?)` picks the first candidate, or the named `kind` outright — its globs are skipped, its `canOpen` still applies — and returns `{ kind, contentId: address, title }`. An address no type claims throws: it is a wiring mistake, not a user error. + +One `kind` may carry one `builtin` and one `extension` registration at the same time. The extension is the one in force for claims, `get(kind)`, `openTab(kind)`, and the guide page, and the seat finds a tab's body and title under the definition in force's `id`, so no slot priority is involved; when the extension unregisters, the builtin resumes. Every other collision on a kind, and every duplicate `id`, throws. + +```ts ignore-check +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' + +export const inject = ['sidebarRightTabs', 'slots'] + +export function apply(ctx: Context): void { + ctx.effect(() => ctx.sidebarRightTabs.register({ + id: '@acme/dsh-client-ui-image', + kind: 'image', + patterns: ['*.png', '*.jpg', '*.gif', '*.svg'], + canOpen: address => address.startsWith('dsh-resource://file/'), + title: address => address.slice(address.lastIndexOf('/') + 1), + }), 'image type') + ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register( + { name: 'sidebar.right.pane.tab', key: '@acme/dsh-client-ui-image' }, + ImageBody, + )), 'image body') +} +``` + +## Navigation: `ctx.sidebarRight` + +Two opens are the navigation controller, and every way into the column calls one of them: `openResource(address, options?)` for a `dsh-resource://` address — the conversation's file links, a tool row's line reference, a file tree's rows — and `openTab(kind, options?)` for a page — the strip's add control, a guide entry box. Both run four steps as one history entry — claim (the registry ranks the resource's types, or the named `kind`'s implementation in force answers); focus a tab already showing the same `(kind, address)`; otherwise seat a new tab; expand the column — and then record the navigation in the Tab domain ([service](../../packages/client/ui-sidebar-right/README.md#ctxsidebarright)). Content the user cannot see is not opened, so a collapsed column expands in the same step. `openResource` throws for an address outside `dsh-resource://` or one no type claims; `openTab` throws for a kind nothing registered: both are wiring mistakes, not user errors. + +| Option | Meaning | +|---|---| +| `paneId` | Land a new tab in this pane; default is the active docked pane (the first docked pane while a floating pane is active). | +| `replaceTab` | Take this tab's pane and strip slot, closing it in the same step; a floating tab lends no place, so the new tab lands as if unplaced. | +| `revealIfOpened` | Default `true`: a tab already showing the same `(kind, address)` is focused and handed `params`. `false` opens another tab regardless. | +| `kind` (`openResource` only) | Name the opening type instead of ranking claims; its implementation in force opens the address, and its `canOpen` still applies. | +| `params` | Navigation parameters for the body, delivered as `navigation.params`. `openResource` types them by resource type through the merge-extensible `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); `openTab` types them by kind through `SidebarRightTabParamsMap`, `undefined` for a kind that declares none; a body reads `SidebarRightNavigationParams`, the union of both. Values are JSON-shaped by convention and not validated at run time. | + +Placement is the caller's option, never a type's property. The conversation calls `openResource(fileAddressFor(sessionId, cwd, path))` and, from a `read` tool row, adds `{ params: { line } }` from the call's 1-based `offset`; a guide entry box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`; a file-tree row calls `tab.actions.openResource(address)`; the strip's add control calls `openTab('guide', { paneId, revealIfOpened: false })`. + +`close(tabId)` closes a tab; `active()` returns the active pane's active tab; `isExpanded()` and `toggleExpanded()` read and flip the column, the flip recorded in the sequence. Reads answer for the no-Session case with `undefined` or `false`; writes need a mounted Session surface and throw without one rather than write into a surface nobody draws. + +`focus(tabId)` makes a tab its pane's active tab; `split(paneId?)` splits the active docked pane, or the named one, and returns the new pane's id — or `undefined`, recording nothing, when the pane budget or the column's width forbids a split; `float(tabId, rect?)` lifts a tab into a floating pane; `dock(paneId)` returns a floating pane to the docked area. All four run the store's existing actions and record one history entry each; a target that does not exist or is already in the requested state is a no-op, and like `open` they throw without a mounted Session surface. `TabId`, `PaneId`, `TabRecord`, and `FloatRect` are re-exported from the package's `/client` entry so a caller needs no dockkit import. + +## Slots and owner props + +The subsystem declares four slots; a tab type registers into the first, optionally the second, and any package into the others ([hierarchy](slots.md)). + +| Slot | Cardinality | Purpose | +|---|---|---| +| `sidebar.right.pane.tab` | keyed by the definition's `id`, Session scope | One tab's body. The seat dispatches a tab to the `id` of its kind's implementation in force, so the registrant receives every tab of its kind, docked or floating. A kind whose implementation registered no body renders the owner's "nothing can view this" notice. | +| `sidebar.right.pane.tab.title` | keyed by the definition's `id`, Session scope | The chip's title, with the same owner share as the body. Optional: without an entry the chip shows the `title(address)` text captured at open time; a type with a live title reads its own store here. | +| `sidebar.right.tab.guide` | chain, Session scope | Replaces the guide tab's contents without replacing the tab; the first non-declining entry takes the body, otherwise the shipped guide renders. | +| `sidebar.right.tab.menu.item` | list, Session scope | Content-level actions appended after the kit's own layout actions. An item that acts must call the owner's `dismiss()`. | + +A body, title and guide replacement receive the framework-injected `useTabInfo()`. It returns `{ sidebar, panel, tab }`: `sidebar` holds `expanded` and `fullscreen`, `panel.id` names the containing pane, and `tab` contains its record fields plus `visible`, `navigation`, `signal`, and `actions`. Docked bodies are visible only while expanded and active; docked titles need only expansion; floats stay visible. `signal` aborts when the record disappears or the plugin unloads, not on hiding or Session switching. `tab.actions` provides `openResource`, `openTab`, and `close`, bound to the tab's own Session. Open placement defaults to its current pane; `revealIfOpened` defaults to `true`, and `replaceTab: true` replaces this record in the same history entry. Menu entries retain plain `tab` and `dismiss` owner parameters. + +`navigation.revision` increments on every navigation to the tab whether or not `params` changed, so a body can act on "navigated again" alone; it is `1` for a tab opened by address and `0` for a record nobody opened by address — a seeded guide, or a tab restored by undo. The Tab domain holds one occurrence per open record: a record that appears is pinned in the resource model, so switching tabs unmounts a body without dropping its content; a record that vanishes is aborted and dropped; a record restored by undo is a new occurrence ([Tab domain](../../packages/client/ui-sidebar-right/README.md#the-tab-domain)). + +## Resource model + +The model is documented in [Client Resources](client-resources.md); this section states what the Sidebar relies on. A resource is one address, and a resource address is a `dsh-resource:///…` URL whose lower-cased host is the protocol key. The protocol's owning client package registers one provider with `ctx.resources.register(provider)` for its own lifetime; a second provider for the same protocol throws ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). A provider is `{ protocol, open(address, { signal }), reload?(address) }`: `open` yields `RemoteResult` frames — the current state first, one frame per later change — and stops when `signal` aborts; a failure is an `{ ok: false, error }` frame, never a throw, and a throw inside the stream is a programming error the model does not catch. + +`useResource

(address)` is a global standard prop on every slot component, whatever its scope. It returns `{ status, value, failure, reload }`: `none` when the address's protocol has no provider or the address is not a resource address (`sidebar://guide` names no resource), `loading` until the first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value. `reload()` asks the provider for a fresh frame and is a no-op without one ([read a resource](../../packages/client/resources/README.md#read-a-resource)). + +A resource stays open while it has a holder — a subscribed `useResource` or a `ctx.resources.pin(address, signal)`; the first holder opens the provider's stream, later holders share it and read the latest value at once, and the last release aborts the stream and discards the value. Streams carry metadata, not content: the `file` value is `{ absolutePath, version, bytes?, changed }`, and a consumer reads file text itself, by page, through the Workspace Files service ([lifecycle](../../packages/client/resources/README.md#lifecycle)). + +## Workspace Files + +The Host `ctx.workspaceFiles` service and the generated `workspaceFiles` Remote namespace answer for files inside the addressed Session's workspace root: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { offset?, length? })` returns one raw byte window (`offset` 0-based, `length` capped by the configured byte limit) as base64 `{ …stat, offset, data, eof }` with no text decoding; `list(path)` returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set; `changes()` yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). Every call passes the same four gates — the path is inside the workspace root, symlinks are refused, page, window, and entry caps hold, `read`'s text is UTF-8 — and fails with a `workspace-file/*` error code otherwise ([failures](../../packages/api/workspace-files/README.md)). + +[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) registers the `file` provider and declares `ResourceProtocolMap.file`. It sends a Session address's relative path unchanged to the Host and binds change filtering to the first successful `stat.absolutePath`. It waits for the Host's `ready` frame before stat, retaining changes delivered during that read. An absolute address uses the current Session; only its absence produces Client `workspace-file/unknown-workspace`. No Client `cwd` is required. + +## Shipped types + +- **`guide`** — `builtin`, opened as `openTab('guide')`. A centred title, one line, and one entry box per `guide` entry the registered types contributed, in `order`; picking a box opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, every new pane is seeded with one, and the strip's add control appears only while its pane has none ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)). +- **`text`** — `fallback`, `dsh-resource://file/**`. Reads metadata through `useResource<'file'>` and the file's lines by page through `read`; honours `params.line` on every navigation; keeps pages, scroll, and wrap in its own store ([README](../../packages/client/ui-sidebar-textpreview/README.md)). +- **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list`, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)). + + +## Not built + +- Persistence: layout state is memory-only; a reload starts every session collapsed, and no session's tabs are visible from another. +- A read-only layout snapshot or subscription on `ctx.sidebarRight`: the service exposes operations only, and dockkit's `LayoutState`/`LayoutOp` are internal. +- A capability-discovery array (`features`) on the service. +- An `option` priority band: nothing lists a type without letting it claim. +- Retitling a record: `title(address)` is captured once; a live chip comes from the title slot, not from the record. +- Naming an implementation when opening: `openResource` names a kind at most, and the kind's implementation in force answers. +- An address lookup on the service (`find`): a caller opens with `revealIfOpened` and lets the surface de-duplicate. +- Navigation addresses beyond the Sidebar's own `sidebar://` bookkeeping; their grammar waits for the navigation controller as a whole. +- A user-facing undo, a content navigation stack, tab icons, and closing restrictions ([deferred](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md#deferred)). diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md new file mode 100644 index 0000000000..1cb0b1e67e --- /dev/null +++ b/docs/subsystems/sidebar-right.zh.md @@ -0,0 +1,137 @@ +# 右侧 Sidebar + +[English](sidebar-right.md) | 中文 + +右侧 Sidebar 是 Web Client 里每个会话一份的停靠面:会话区旁的一列 pane 与 tab,按地址寻址的内容——工作区文件、目录树、产品自带页面——在这里打开、分栏、浮出、关闭。[`dsh-client-ui-sidebar-right`](../../packages/client/ui-sidebar-right/README.zh.md) 拥有这个面、tab 类型注册表与导航服务;[`dsh-client-ui-dockkit`](../../packages/client/ui-dockkit/README.zh.md) 是它内部的布局引擎;[`dsh-client-resources`](../../packages/client/resources/README.zh.md) 把地址变成任何组件都能读的活数据;[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 同时提供 Host 工作区文件服务与 Client `file` 资源提供者。 + +本页是该子系统契约的参考:地址、tab 类型注册、导航服务、扩展 slot 与其 owner props、资源模型、Workspace Files 服务、内置类型,以及明确不做的事。布局引擎、frame 与停靠面如何拼在一起见 [Agent Note](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md);slot 机制见 [Slots 参考](slots.zh.md)。 + +## 定位与归属 + +每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar` 席位绘制;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,最后一个 pane 会重新种入引导 tab。 + +一个 tab 类型是共用一个 `kind` 的两次注册:在 `ctx.sidebarRightTabs` 里的静态定义说明该类型打开哪些地址,一次 keyed slot 注册提供它的正文。框架注入 `useTabInfo()` 以读取 Sidebar、窗格和标签的实时信息;各类型把自身状态放在 slot store 里。各包之间只以类型形式引用彼此的声明。 + +| 包 | 职责 | +|---|---| +| [`client/ui-sidebar-right`](../../packages/client/ui-sidebar-right/README.zh.md) | 面板与栏席位、布局 store、`ctx.sidebarRightTabs`、`ctx.sidebarRight`、Tab 域、引导类型 | +| [`client/ui-dockkit`](../../packages/client/ui-dockkit/README.zh.md) | 纯布局引擎与 React 面;`ui-sidebar-right` 的内部依赖,不是稳定接口 | +| [`client/resources`](../../packages/client/resources/README.zh.md) | `ctx.resources`、`useResource`、协议 → 值类型的花名册 `ResourceProtocolMap` | +| [`api/workspace-files`](../../packages/api/workspace-files/README.zh.md) | Host `ctx.workspaceFiles`、`workspaceFiles` Remote 命名空间与 Client `file` 资源提供者 | +| [`util/workspace-path`](../../packages/util/workspace-path/README.zh.md) | 文件地址语法:`fileAddressFor`、`parseFileAddress` | +| [`client/ui-sidebar-textpreview`](../../packages/client/ui-sidebar-textpreview/README.zh.md)、[`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.zh.md) | 内置的 `text` 与 `files` 类型 | + +## 地址 + +每个 tab 都由一个地址字串打开,地址就是 tab 的内容身份。地址分两族。 + +**资源地址**是 `dsh-resource:///…` 形式的 URL。host 命名资源协议——即 `ResourceProtocolMap` 的键——其后是该协议自己的路径;所有协议共用一个 scheme,新增协议只新增 host、不新增 scheme。`file` 协议的路径以其作用域开头:`session/` 后接相对该会话工作区根的路径(`dsh-resource://file/session/abc/src/notes.txt`),或 `absolute` 后接去掉前导 `/` 的绝对路径(`dsh-resource://file/absolute/home/ys/notes.txt`,Windows 上为 `dsh-resource://file/absolute/C:/x/y.txt`)。id 与每一段路径都做组件编码,盘符的 `:` 保留原样。`fileAddressFor(sessionId, cwd, path)` 构造地址——相对路径或工作区内的绝对路径成为 `session` 相对地址,其他绝对路径成为 `absolute` 地址——`parseFileAddress(address)` 读回各部分或返回 `undefined`([语法](../../packages/util/workspace-path/README.zh.md))。 + +**页面地址**是 Sidebar 为按 kind(而非按资源)打开的 tab 记下的地址:`sidebar://`,由 Sidebar 自己在 `openTab(kind)` 运行时写入。调用方从不拼它——引导页与文件树以 `openTab('guide')`、`openTab('files')` 打开——此外不存在任何导航地址([不做](#not-built))。 + +tab 身份是 `(kind, address)` 二元组:注册表的认领把地址原文用作记录的 `contentId`,因此同一地址经同一类型再次打开会找到已有 tab,同一地址经两个类型打开则是两个 tab。 + +## Tab 类型注册 + +`ctx.sidebarRightTabs.register(definition)` 在调用方的生命周期内注册一个类型的一份实现并返回注销器;调用方把它放在自己的 `ctx.effect` 里,因此实现与贡献它的插件同寿,同一 `id` 的第二次注册抛错([扩展席位](../../packages/client/ui-sidebar-right/README.zh.md#extension-seats))。定义是静态的:没有运行时 hook,没有按 tab 或按会话的东西。 + +| 字段 | 含义 | +|---|---| +| `id` | 该实现的身份,在所有注册中唯一;包名是自然取值(`@deepseek-ai/dsh-client-ui-sidebar-files`)。正文与标题坑位按它注册。 | +| `kind` | 类型的判别名:它的 tab 是什么,也是 `openTab` 点名的对象。不唯一——extension 可以接管 builtin 的 kind。内置 kind 为 `guide`、`text`、`files`。 | +| `patterns` | 可选的资源地址 glob;按 kind 打开的页面类型省略。含 `:` 的模式匹配整个地址(`dsh-resource://file/**`);不含的匹配 URL 的路径部分且任意深度都中(`*.md`),不是 URL 的地址不会命中此类模式。匹配不分大小写、不隐藏 dotfile;语法为 picomatch 的 POSIX 方言。 | +| `priority` | 三档字面量之一:`extension`(缺省且最高:产品之外的类型压过所有内置查看器)、`builtin`(随产品发布的类型)、`fallback`(任何更具体的类型都应压过的纯内容查看器)。 | +| `canOpen(address)` | 可选的同步否决,对 glob 命中生效;每次路由决策都会调用。 | +| `title(address)` | chip 文本,在 tab 打开时捕获进布局记录,之后不再改写。 | +| `guide` | 可选的引导页入口框:`{ order, title(), description(), icon? }`。点一框即把贡献它的类型作为页面打开;省略即不上引导页。 | + +路由是一次排序认领。`candidates(address)` 对模式命中且未被 `canOpen` 否决的类型排序:先按档,再按最长命中模式的长度,最后按注册顺序。`claim(address, kind?)` 取第一个候选,或直接用点名的 `kind`——跳过它的 glob,但 `canOpen` 仍生效——返回 `{ kind, contentId: address, title }`。没有任何类型认领的地址会抛错:这是接线错误,不是用户错误。 + +同一个 `kind` 可同时携带一个 `builtin` 与一个 `extension` 注册。extension 在认领、`get(kind)`、`openTab(kind)` 与引导页上生效,席位按生效定义的 `id` 找 tab 的正文与标题,不涉及任何 slot 优先级;extension 注销后 builtin 恢复。kind 上的其它任何撞名以及任何重复的 `id` 都抛错。 + +```ts ignore-check +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' + +export const inject = ['sidebarRightTabs', 'slots'] + +export function apply(ctx: Context): void { + ctx.effect(() => ctx.sidebarRightTabs.register({ + id: '@acme/dsh-client-ui-image', + kind: 'image', + patterns: ['*.png', '*.jpg', '*.gif', '*.svg'], + canOpen: address => address.startsWith('dsh-resource://file/'), + title: address => address.slice(address.lastIndexOf('/') + 1), + }), 'image type') + ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register( + { name: 'sidebar.right.pane.tab', key: '@acme/dsh-client-ui-image' }, + ImageBody, + )), 'image body') +} +``` + +## 导航:`ctx.sidebarRight` + +两种打开构成导航控制器,进入这一列的每条路都调用其一:`openResource(address, options?)` 打开 `dsh-resource://` 地址——会话区的文件链接、工具行的行号引用、文件树的行;`openTab(kind, options?)` 打开页面——tab 条的新增控件、引导页入口框。两者都以一条历史记录走完四步——认领(注册表为资源排候选,或点名 `kind` 的生效实现应答);聚焦已显示同一 `(kind, address)` 的 tab;否则落一个新 tab;展开这一列——然后把导航记入 Tab 域([服务](../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright))。用户看不见的内容不算打开,所以折叠的列会在同一步展开。`openResource` 对 `dsh-resource://` 之外的地址或无人认领的地址抛错;`openTab` 对无人注册的 kind 抛错:二者都是接线错误,不是用户错误。 + +| 选项 | 含义 | +|---|---| +| `paneId` | 新 tab 落到这个 pane;缺省为活动的停靠 pane(活动的是浮窗时取第一个停靠 pane)。 | +| `replaceTab` | 占用这个 tab 的 pane 与条上位置,并在同一步关闭它;浮窗里的 tab 让不出位置,新 tab 按未指定位置落位。 | +| `revealIfOpened` | 缺省 `true`:已显示同一 `(kind, address)` 的 tab 被聚焦并收到 `params`。`false` 则无论如何再开一个。 | +| `kind`(仅 `openResource`) | 点名打开类型而不排候选;该 kind 的生效实现打开地址,它的 `canOpen` 仍生效。 | +| `params` | 给正文的导航参数,作为 `navigation.params` 送达。`openResource` 按资源类型经声明合并表 `SidebarRightResourceParamsMap` 定型(文本预览声明 `{ line?: number }`);`openTab` 按 kind 经 `SidebarRightTabParamsMap` 定型,未声明的 kind 为 `undefined`;正文读到的是二者联合 `SidebarRightNavigationParams`。值按约定为 JSON 形状,运行时不校验。 | + +落位是调用方的选项,从不是类型的属性。会话区调 `openResource(fileAddressFor(sessionId, cwd, path))`,`read` 工具行另加 `{ params: { line } }`(来自调用的 1 起 `offset`);引导页入口框调 `tab.actions.openTab(entry.kind, { replaceTab: true })`;文件树的行调 `tab.actions.openResource(address)`;tab 条的新增控件调 `openTab('guide', { paneId, revealIfOpened: false })`。 + +`close(tabId)` 关闭一个 tab;`active()` 返回活动 pane 的活动 tab;`isExpanded()` 与 `toggleExpanded()` 读取与翻转这一列,翻转记入序列。无会话时读操作返回 `undefined` 或 `false`;写操作需要已挂载的会话面,没有时抛错而不是写进没人绘制的面。 + +`focus(tabId)` 让一个 tab 成为其 pane 的活动 tab;`split(paneId?)` 分割活动的停靠 pane 或点名的 pane,返回新 pane 的 id——pane 数预算或列宽不允许时返回 `undefined` 且不记账;`float(tabId, rect?)` 把一个 tab 浮出为浮窗 pane;`dock(paneId)` 把浮窗 pane 收回停靠区。四者都走 store 既有动作、各记一条历史;目标不存在或已处于目标状态时是空操作,与 `open` 一样在没有已挂载会话面时抛错。`TabId`、`PaneId`、`TabRecord`、`FloatRect` 自本包 `/client` 入口再导出,调用方无需引 dockkit。 + +## Slot 与 owner props + +本子系统声明四个 slot;tab 类型注册进第一个,可选地注册第二个,任何包都可注册进其余两个([层级](slots.zh.md))。 + +| Slot | Cardinality | 用途 | +|---|---|---| +| `sidebar.right.pane.tab` | 按定义的 `id` keyed,会话作用域 | 一个 tab 的正文。席位把 tab 分发到其 kind 生效实现的 `id`,因此注册者收到该 kind 的每个 tab,停靠或浮窗。实现没有注册正文的 kind 渲染 owner 的「无法查看此内容」提示。 | +| `sidebar.right.pane.tab.title` | 按定义的 `id` keyed,会话作用域 | chip 的标题,owner share 与正文相同。可选:没有条目时 chip 显示打开时捕获的 `title(address)` 文本;有活标题的类型在此读自己的 store。 | +| `sidebar.right.tab.guide` | chain,会话作用域 | 替换引导 tab 的内容而不替换 tab;第一个不拒绝的条目接管正文,否则渲染自带引导。 | +| `sidebar.right.tab.menu.item` | list,会话作用域 | 追加在 kit 自身布局动作之后的内容级动作。执行了动作的条目必须调用 owner 的 `dismiss()`。 | + +正文、标题与引导页替换项接收框架注入的 `useTabInfo()`。它返回 `{ sidebar, panel, tab }`:`sidebar` 包含 `expanded` 与 `fullscreen`,`panel.id` 标识所属窗格,`tab` 包含记录字段以及 `visible`、`navigation`、`signal` 和 `actions`。停靠正文仅在展开且活跃时可见;停靠标题只要求展开;浮窗保持可见。`signal` 在记录消失或插件卸载时中止,不因隐藏或切换 Session 而中止。`tab.actions` 提供绑定到标签所属 Session 的 `openResource`、`openTab` 与 `close`。打开位置缺省为当前所属窗格;`revealIfOpened` 缺省为 `true`,`replaceTab: true` 在同一历史项中替换本记录。菜单项保留普通的 `tab` 与 `dismiss` owner 参数。 + +`navigation.revision` 在每次导航到该 tab 时递增,`params` 不变也递增,正文可仅凭「又被导航了」行动;按地址打开的 tab 为 `1`,没有人按地址打开的记录——种入的引导、撤销恢复的 tab——为 `0`。Tab 域为每条打开的记录保有一个 occurrence:记录出现即在资源模型里钉住,因此切换 tab 卸载正文也不丢内容;记录消失即中止并丢弃;撤销恢复的记录是新的 occurrence([Tab 域](../../packages/client/ui-sidebar-right/README.zh.md#the-tab-domain))。 + +## 资源模型 + +模型本身见[客户端资源](client-resources.zh.md);本节只写 Sidebar 依赖的部分。一份资源是一个地址,资源地址是 `dsh-resource:///…` 形式的 URL,小写 host 即协议键。协议所属的客户端包用 `ctx.resources.register(provider)` 在自身生命周期内注册唯一的提供方;同一协议的第二个提供方抛错([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。提供方是 `{ protocol, open(address, { signal }), reload?(address) }`:`open` 产出 `RemoteResult` 帧——首帧是当前状态,之后每次变化一帧——并在 `signal` 中止时停下;失败是 `{ ok: false, error }` 帧而不是抛错,流里抛出的东西是编程错误,模型不捕获。 + +`useResource

(address)` 是每个 slot 组件都有的全局标准 prop,不论作用域。它返回 `{ status, value, failure, reload }`:地址协议没有提供方或地址不是资源地址(`sidebar://guide` 不指向资源)时为 `none`,首帧之前为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。`reload()` 请提供方给一个新帧,没有提供方时是空操作([读取资源](../../packages/client/resources/README.zh.md#read-a-resource))。 + +资源有持有者就保持打开——订阅中的 `useResource` 或一次 `ctx.resources.pin(address, signal)`;第一个持有者打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并丢弃值。流只推元数据不推内容:`file` 的值是 `{ absolutePath, version, bytes?, changed }`,消费方自己经 Workspace Files 服务按页读文件文本([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。 + +## Workspace Files + +Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间负责所寻址会话工作区根之内的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { offset?, length? })` 返回一个原始字节窗口(`offset` 0 起,`length` 受配置字节上限限制),形如 base64 的 `{ …stat, offset, data, eof }`、不做文本解码;`list(path)` 返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`;`changes()` 在订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。每次调用都过同样四关——路径在工作区根内、拒绝符号链接、页、窗口与条目上限、`read` 的 UTF-8 文本——否则以 `workspace-file/*` 错误码失败([失败](../../packages/api/workspace-files/README.zh.md))。 + +[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 注册 `file` 提供方并声明 `ResourceProtocolMap.file`。它把 Session 地址的相对路径原样发送给 Host,按首次成功的 `stat.absolutePath` 绑定变更过滤。它在 stat 前等待 Host 的 `ready` 帧,并保留读取期间到达的变更。绝对地址使用当前 Session;只有缺少当前 Session 时才产生 Client `workspace-file/unknown-workspace`。Client 不需要 `cwd`。 + +## 内置类型 + +- **`guide`**——`builtin`,以 `openTab('guide')` 打开。居中标题、一行说明,以及已注册类型贡献的每个 `guide` 入口一框、按 `order` 排列;点一框即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,每个新 pane 都种入一个,tab 条的新增控件只在本 pane 没有引导时出现([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。 +- **`text`**——`fallback`,`dsh-resource://file/**`。经 `useResource<'file'>` 读元数据、经 `read` 按页读文件行;每次导航都响应 `params.line`;页、滚动与换行放在自己的 store 里([README](../../packages/client/ui-sidebar-textpreview/README.zh.md))。 +- **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。 + + +## 不做 + +- 持久化:布局状态只在内存里;刷新后每个会话从折叠开始,任何会话的 tab 都不会出现在另一个会话里。 +- `ctx.sidebarRight` 上的只读布局快照或订阅:服务只暴露操作,dockkit 的 `LayoutState`/`LayoutOp` 是内部的。 +- 服务上的能力探测数组(`features`)。 +- `option` 优先级档:没有「只列出、不许认领」的类型。 +- 改写记录的标题:`title(address)` 只捕获一次;活的 chip 来自标题 slot,而不是记录。 +- 打开时点名某个实现:`openResource` 最多点名一个 kind,由该 kind 的生效实现应答。 +- 服务上的地址查找(`find`):调用方用 `revealIfOpened` 打开,由停靠面去重。 +- Sidebar 自身 `sidebar://` 记账之外的导航地址;其语法等导航控制器整体做时再定。 +- 面向用户的撤销、内容导航栈、tab 图标与关闭限制([暂缓](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md#deferred))。 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 1c96cdce9a..49925a9aa5 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -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/slots.md -slots.md: a37374ff11f9d460a2566a7a30f8318e69cbfa0b -slots.zh.md: 09329b1f299f4912490f888efa3146ec0ce11e28 +slots.md: 136607b035c4a40c8ab96d6bf933e6b5c8a925bf +slots.zh.md: b3a54048f5df20ad3ef271aea868ce15d5e18bce diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index a37374ff11..136607b035 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -142,7 +142,8 @@ root │ ├─ conversation.session.header │ │ ├─ conversation.session.header.lineage │ │ ├─ conversation.session.header.actions -│ │ └─ conversation.session.header.utilities +│ │ ├─ conversation.session.header.utilities +│ │ └─ conversation.session.header.corner │ ├─ conversation.composer │ │ └─ conversation.approval.detail │ ├─ conversation.composer.bar @@ -158,8 +159,11 @@ root │ ├─ conversation.hero.workspace │ │ └─ conversation.hero.workspace.directoryFlow │ └─ conversation.hero.agentPreset -├─ details -│ └─ conversation.details.tool +├─ rightbar +│ ├─ sidebar.right.pane.tab +│ │ └─ sidebar.right.tab.guide +│ ├─ sidebar.right.pane.tab.title +│ └─ sidebar.right.tab.menu.item └─ shell.overlay ``` diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index 09329b1f29..b3a54048f5 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -142,7 +142,8 @@ root │ ├─ conversation.session.header │ │ ├─ conversation.session.header.lineage │ │ ├─ conversation.session.header.actions -│ │ └─ conversation.session.header.utilities +│ │ ├─ conversation.session.header.utilities +│ │ └─ conversation.session.header.corner │ ├─ conversation.composer │ │ └─ conversation.approval.detail │ ├─ conversation.composer.bar @@ -158,8 +159,11 @@ root │ ├─ conversation.hero.workspace │ │ └─ conversation.hero.workspace.directoryFlow │ └─ conversation.hero.agentPreset -├─ details -│ └─ conversation.details.tool +├─ rightbar +│ ├─ sidebar.right.pane.tab +│ │ └─ sidebar.right.tab.guide +│ ├─ sidebar.right.pane.tab.title +│ └─ sidebar.right.tab.menu.item └─ shell.overlay ``` diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index b901856ad2..77fbb01e39 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -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: a66d9d94b02ec98d4197862f8e86e6ec027fda6c -workspace.zh.md: 46883ccd3c9cd3b3cbd8c91afe4c6d7e85449cbf +workspace.md: 3e54ef3d0a2339d50fbc8a4132b91d7f82701321 +workspace.zh.md: a9b8375f388538a3588a84f2d71e47b89ddc679a diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index a66d9d94b0..3e54ef3d0a 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -242,6 +242,68 @@ Host service backing the generated `ctx.remote.workspace` namespace. Source: [`packages/api/workspace-controller/src/index.ts`](../../packages/api/workspace-controller/src/index.ts) + + +### `ctx.workspaceFiles` — `WorkspaceFiles` + +Host Remote service over the composed filesystem, confined to one workspace. + +```ts cordis-catalog +/** + * Read one page of lines from a UTF-8 text file inside the Agent's workspace. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param path - workspace path, absolute or relative to the workspace root. + * @param range - the line window; omitted fields take the page defaults. + * @param signal - caller cancellation. + * @returns the page, the file's version at the stat before it, and whether it reaches the last line. + */ +@Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise + +/** + * Read one byte window of a regular file inside the Agent's workspace: raw + * bytes, no text decoding and no binary rejection. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param path - workspace path, absolute or relative to the workspace root. + * @param range - the byte window; omitted fields take the window defaults. + * @param signal - caller cancellation. + * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. + */ +@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise + +/** + * Report one regular file's identity, version, and size without its content. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param path - workspace path, absolute or relative to the workspace root. + * @param signal - caller cancellation. + * @returns the file's absolute path, current version, and byte size. + */ +@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise + +/** + * List the direct children of one directory inside the Agent's workspace. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param path - workspace path, absolute or relative to the workspace root. + * @param signal - caller cancellation. + * @returns the directory's children in the backend's stable name order, bounded by the entry cap. + */ +@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise + +/** + * Stream every `fs/observed` observation of a file inside the Agent's + * workspace. Only Agent filesystem operations report here; the OS is not + * watched. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param signal - generation cancellation. + * @returns `ready` once the Host observation queue is active and the workspace + * root is resolved, then queued and live observations in emission order. + */ +@Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable +``` + +Types: [Agent](core.md) + +Source: [`packages/api/workspace-files/src/index.ts`](../../packages/api/workspace-files/src/index.ts) + ### `ctx.workspaceRegistry` — `WorkspaceRegistry` diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 46883ccd3c..a9b8375f38 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -242,6 +242,68 @@ Host service backing the generated `ctx.remote.workspace` namespace. Source: [`packages/api/workspace-controller/src/index.ts`](../../packages/api/workspace-controller/src/index.ts) + + +### `ctx.workspaceFiles` — `WorkspaceFiles` + +Host Remote service over the composed filesystem, confined to one workspace. + +```ts cordis-catalog +/** + * Read one page of lines from a UTF-8 text file inside the Agent's workspace. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param path - workspace path, absolute or relative to the workspace root. + * @param range - the line window; omitted fields take the page defaults. + * @param signal - caller cancellation. + * @returns the page, the file's version at the stat before it, and whether it reaches the last line. + */ +@Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise + +/** + * Read one byte window of a regular file inside the Agent's workspace: raw + * bytes, no text decoding and no binary rejection. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param path - workspace path, absolute or relative to the workspace root. + * @param range - the byte window; omitted fields take the window defaults. + * @param signal - caller cancellation. + * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. + */ +@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise + +/** + * Report one regular file's identity, version, and size without its content. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param path - workspace path, absolute or relative to the workspace root. + * @param signal - caller cancellation. + * @returns the file's absolute path, current version, and byte size. + */ +@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise + +/** + * List the direct children of one directory inside the Agent's workspace. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param path - workspace path, absolute or relative to the workspace root. + * @param signal - caller cancellation. + * @returns the directory's children in the backend's stable name order, bounded by the entry cap. + */ +@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise + +/** + * Stream every `fs/observed` observation of a file inside the Agent's + * workspace. Only Agent filesystem operations report here; the OS is not + * watched. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param signal - generation cancellation. + * @returns `ready` once the Host observation queue is active and the workspace + * root is resolved, then queued and live observations in emission order. + */ +@Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable +``` + +Types: [Agent](core.zh.md) + +Source: [`packages/api/workspace-files/src/index.ts`](../../packages/api/workspace-files/src/index.ts) + ### `ctx.workspaceRegistry` — `WorkspaceRegistry` diff --git a/package.json b/package.json index 349125f00b..422cce1ce6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "license": "MIT", "private": true, "type": "module", @@ -20,10 +20,25 @@ "build": "tsx scripts/build.ts", "build:bench": "npm run build:lib && tsdown --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", - "build:lib": "npm run build:lib:host && npm run build:lib:client", + "build:lib": "pnpm run build:lib:host && pnpm run build:lib:client", "build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", "build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-web-frontend run build", + "build:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run build", + "dev:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run dev", + "start:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run start", + "prepare:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run prepare:package", + "package:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run package", + "package:desktop:dir": "pnpm --filter @deepseek-ai/dsh-desktop run package:dir", + "package:desktop:mac:arm64": "pnpm --filter @deepseek-ai/dsh-desktop run package:mac:arm64", + "package:desktop:mac:arm64:dir": "pnpm --filter @deepseek-ai/dsh-desktop run package:mac:arm64:dir", + "package:desktop:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run package:mac:x64", + "package:desktop:mac:x64:dir": "pnpm --filter @deepseek-ai/dsh-desktop run package:mac:x64:dir", + "package:desktop:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run package:win:x64", + "package:desktop:win:x64:dir": "pnpm --filter @deepseek-ai/dsh-desktop run package:win:x64:dir", + "upload:mac:arm64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:mac:arm64", + "upload:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:mac:x64", + "upload:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:win:x64", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", "typecheck": "npm run build:lib:host && npm run typecheck:contracts-ready", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 7edd811ab4..8b7c70841b 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/acp/acp/tests/harness.ts b/packages/acp/acp/tests/harness.ts index aa37bf1bc8..51d89dab25 100644 --- a/packages/acp/acp/tests/harness.ts +++ b/packages/acp/acp/tests/harness.ts @@ -23,7 +23,6 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, St import { type GenerateOptions, LlmAdapter, ReasoningEffortId, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import TokenMeter from '@deepseek-ai/dsh-token-meter' import * as AcpPlugin from '../src/index.ts' @@ -232,10 +231,6 @@ export async function makeBridgeHarness(options: { const ownsPersistenceRoot = options.persistenceRoot === undefined const persistenceRoot = options.persistenceRoot ?? await mkdtemp(join(tmpdir(), 'dsh-acp-test-')) await mountAgentLoopTestDependencies(ctx, { systemPrompt: { personaPrefix: options.persona ?? '' } }) - // The agent loop and the composed approval/permission services declare - // sessionProjections a required injection: mount the registry (and with it - // the loop's turnBoundary unit) before the loop activates. - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(JsonlSessionPersistence, { root: persistenceRoot, compression: 'none' }) await ctx.plugin(TokenMeter) if (options.attachments !== false) await ctx.plugin(MemoryAttachmentStore) diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index c47c67f406..ce98751e3d 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/README.i18n.yaml @@ -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/README.md -README.md: b4d8ddd84baa411a2675c95dda1701937e06fe6d -README.zh.md: 5dff7a59219a539c75d7ab85d293cd5b389018ec +README.md: 20455e6b5622ffd6e826b1d4b427838f96f6610f +README.zh.md: 0294b1823e6a09cae4591b4c6081a546d8d60494 diff --git a/packages/api/README.md b/packages/api/README.md index b4d8ddd84b..20455e6b56 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -31,6 +31,7 @@ The packages below provide the Remote layer; the package READMEs own the exhaust | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` | +| [`workspace-files/`](workspace-files/README.md) | Owns bounded workspace file access — `stat`, paged `read`, `list`, and the agent-write `changes` feed — and the Client `file` resource provider over it. | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads. diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 5dff7a5921..0294b1823e 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -31,6 +31,7 @@ kind: "package-group" | [`session-controller/`](session-controller/README.zh.md) | 拥有 Session 命令、历史 stream、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` | +| [`workspace-files/`](workspace-files/README.zh.md) | 拥有有界的工作区文件访问——`stat`、分页 `read`、`list` 与 agent 写入的 `changes` 流——以及其上的 Client `file` 资源提供者。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各 controller 包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 42ac1c2155..d7d9d4a0a8 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 660b9775f5..3cfba6343d 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, @@ -82,6 +82,7 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-files": "workspace:^", "zod": "^4.4.3" } } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index cc917458dc..bc93c25cca 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -14,6 +14,7 @@ import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' +import workspaceFilesRemote from '@deepseek-ai/dsh-api-workspace-files/remote' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' @@ -33,6 +34,8 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/remote' export type * from '@deepseek-ai/dsh-api-session-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' export type * from '@deepseek-ai/dsh-api-workspace-controller/types' +export type {} from '@deepseek-ai/dsh-api-workspace-files/remote' +export type * from '@deepseek-ai/dsh-api-workspace-files/types' export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. @@ -148,7 +151,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, - subagentsRemote, sessionRemote, workspaceRemote, + subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 2be76de65a..589d5e92bd 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -25,7 +25,6 @@ }, { "path": "../../credentials/credentials" - }, { "path": "../../context/file-reference" @@ -69,6 +68,9 @@ { "path": "../session-controller/tsconfig.client.json" }, + { + "path": "../workspace-files/tsconfig.client.json" + }, { "path": "../settings-controller" }, diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index c91c11baa2..d88af3c120 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -56,6 +56,9 @@ { "path": "../session-controller/tsconfig.host.json" }, + { + "path": "../workspace-files/tsconfig.host.json" + }, { "path": "../workspace-controller/tsconfig.host.json" }, diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 6ae203a68b..981e5f12ca 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/session-controller/README.md -README.md: a2639ebd5fb648ed193d7c3575a649891ebf76b9 -README.zh.md: 7bed20d3e47855b045bb838b354328f1769273e2 +README.md: 6b56c4976b5ca66fbdbdb274bdf5bf8f38d52b1d +README.zh.md: a9c53d37371e3280c5bc0ae21a93a511036a810d diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index a2639ebd5f..6b56c4976b 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -27,7 +27,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. -The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. +The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. For each inbox change, the Host publishes the projection frame first and derives the queue replacement from that same validated post-fold value, so listener registration order cannot produce a stale queue frame.Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 7bed20d3e4..a9c53d3737 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 -Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`,即轮次跳转加载器,按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 +Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 inbox 变更时,Host 会先发布 projection frame,再从同一份已校验的折叠后值派生 queue replacement,因此监听器注册顺序不会产生陈旧的 queue frame。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 58c41dcc07..65b757e0e1 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, @@ -118,6 +118,8 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index 1a03011644..9bdd5a05bf 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -1,11 +1,11 @@ /** Live Session queue, jobs, and projection state with reconnect baselines. */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, InboxState } from '@deepseek-ai/dsh-agent' import { Deque } from '@deepseek-ai/dsh-deque' import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' import type { - Session, SessionEvent, SessionEventMap, SessionId, UserMessage, + Session, SessionId, UserMessage, } from '@deepseek-ai/dsh-session' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { @@ -23,7 +23,6 @@ export class SessionControlController { /** @param ctx - Host context carrying live Agent, projection, and jobs services. */ constructor(private readonly ctx: Context) { - ctx.on('session/event', (session, event) => { this.onSessionEvent(session, event) }) ctx.sessionProjections.onChanged((session, key, value, seq) => { this.broadcast({ type: 'projection', @@ -32,6 +31,14 @@ export class SessionControlController { value: value as JsonValue, seq, }) + if (key !== 'inbox') return + const agent = this.ctx.agents.get(session.id) + if (agent?.session !== session) return + this.broadcast({ + type: 'queue', + sessionId: session.id, + items: queueItemsFromInbox(value as InboxState), + }) }) ctx.inject(['jobs'], (jobsCtx) => { jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) }) @@ -95,17 +102,6 @@ export class SessionControlController { return blocks } - private onSessionEvent(session: Session, event: SessionEvent): void { - if (event.type !== 'agent/inbox/spliced') return - const agent = this.ctx.agents.get(session.id) - if (agent?.session !== session) return - this.broadcast({ - type: 'queue', - sessionId: session.id, - items: queueItems(agent, event.data), - }) - } - private onJobsChanged(owner: Agent | undefined): void { if (owner !== undefined) { this.broadcast({ type: 'jobs', sessionId: owner.id, jobs: this.jobsFor(owner) }) @@ -171,24 +167,22 @@ class ControlQueue { } } -function queueItems( - agent: Agent, - splice?: SessionEventMap['agent/inbox/spliced'], -): SessionQueuedItem[] { - const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => { - const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep - return splice?.target === target - ? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted) - : messages - } +function queueItems(agent: Agent): SessionQueuedItem[] { + return queueItemsFromInbox({ + 'next-turn': agent.inbox.nextTurn, + 'next-step': agent.inbox.nextStep, + }) +} + +function queueItemsFromInbox(inbox: InboxState): SessionQueuedItem[] { return [ - ...project('next-turn').map(message => ({ + ...inbox['next-turn'].map(message => ({ id: message.id, placement: 'queued' as const, ...promptRpcId(message), message: { id: message.id, content: message.content as unknown as JsonValue[] }, })), - ...project('next-step').map(message => ({ + ...inbox['next-step'].map(message => ({ id: message.id, placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const, ...promptRpcId(message), diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 30665e979c..e96a3b128b 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -1,6 +1,6 @@ import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, Inbox, ModelSelectionRef } from '@deepseek-ai/dsh-agent' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' @@ -13,6 +13,7 @@ import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/ import { describe, expect, it, vi } from 'vitest' import { ApiSessionAgentController } from '../src/agent.ts' import { SessionCommandController } from '../src/commands.ts' +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' async function commandHarness( @@ -68,7 +69,7 @@ async function commandHarness( provider: 1, } as never) } - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const inbox = createInboxStub() const steer = vi.fn((message: UserMessage) => { inbox.append('next-step', message) }) const cancel = vi.fn() const agent = { diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 4e7565c7ff..a6073c5f2a 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { @@ -38,7 +39,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ const session = ctx.sessions.create(SESSION, { meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }) }, }) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const inbox = createInboxStub() const followup = vi.fn() const agent = { id: session.id, diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts index cf6378f5e8..4c88ba21df 100644 --- a/packages/api/session-controller/tests/control-jobs.host.spec.ts +++ b/packages/api/session-controller/tests/control-jobs.host.spec.ts @@ -1,5 +1,5 @@ import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import type { JobOutcome } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' @@ -9,6 +9,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { describe, expect, it } from 'vitest' import { SessionControlController } from '../src/control.ts' import type { SessionControlFrame } from '../src/types.ts' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' type BaselineFrame = Extract type JobFrame = Extract @@ -36,20 +37,28 @@ async function harness(withJobs: boolean): Promise<{ }> { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) if (withJobs) { await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('session-controller-test') } const session = ctx.sessions.create() - const agent = { + const agent: Agent = { id: session.id, + options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: unsupportedInbox(), status: 'idle', ctx, - } as Agent + send: () => {}, + followup: () => {}, + steer: () => {}, + inject: () => {}, + cancel: () => {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } ctx.agents.register(agent) const control = new SessionControlController(ctx) await new Promise(resolve => setTimeout(resolve, 0)) diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts index 0a1e5420a7..b3f1d9b19d 100644 --- a/packages/api/session-controller/tests/control-queue.host.spec.ts +++ b/packages/api/session-controller/tests/control-queue.host.spec.ts @@ -1,11 +1,20 @@ import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, Inbox } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import { afterEach, describe, expect, it } from 'vitest' import { SessionControlController } from '../src/control.ts' +import type { SessionControlFrame } from '../src/types.ts' +import { + mountAgentLoopTestDependencies, + mountAgentLoopTestHarness, +} from '@deepseek-ai/dsh-agent-loop-testkit' + +const ownedContexts = new Set() +afterEach(async () => { + await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose())) + ownedContexts.clear() +}) async function harness(): Promise<{ ctx: Context @@ -14,14 +23,11 @@ async function harness(): Promise<{ inbox: Inbox }> { const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) - const session = ctx.sessions.create(SessionId('queue-session')) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - const agent = { id: session.id, session, inbox, status: 'running', ctx } as Agent - ctx.agents.register(agent) - return { ctx, control: new SessionControlController(ctx), agent, inbox } + ownedContexts.add(ctx) + await mountAgentLoopTestDependencies(ctx) + const loop = await mountAgentLoopTestHarness(ctx) + const agent = await loop.create(SessionId('queue-session')) + return { ctx, control: new SessionControlController(ctx), agent, inbox: agent.inbox } } function message(text: string, source: 'user' | 'plugin' = 'user') { @@ -32,6 +38,17 @@ function message(text: string, source: 'user' | 'plugin' = 'user') { } describe('Session control queue projection', () => { + /** Consume frames until the next queue replacement (inbox projection frames interleave). */ + async function nextQueueFrame( + iterator: AsyncIterator, + ): Promise> { + for (;;) { + const next = await iterator.next() + if (next.done) throw new Error('stream ended before a queue frame') + if (next.value.type === 'queue') return next.value + } + } + it('projects both pending lists in baselines and live replacement frames', async () => { const { control, inbox } = await harness() const queued = message('queued') @@ -59,13 +76,41 @@ describe('Session control queue projection', () => { const replacement = message('replacement') inbox.append('next-turn', replacement) - const replaced = await iterator.next() - if (replaced.done || replaced.value.type !== 'queue') throw new Error('missing queue replacement') - expect(replaced.value.items.map(item => item.id)).toContain(replacement.id) + const replaced = await nextQueueFrame(iterator) + expect(replaced.items.map(item => item.id)).toContain(replacement.id) inbox.remove(steering.id) - const removed = await iterator.next() - if (removed.done || removed.value.type !== 'queue') throw new Error('missing queue replacement') - expect(removed.value.items.map(item => item.id)).not.toContain(steering.id) + const removed = await nextQueueFrame(iterator) + expect(removed.items.map(item => item.id)).not.toContain(steering.id) + + abort.abort() + await iterator.next() + }) + + it('derives queue replacements from the completed projection regardless of registration order', async () => { + const ctx = new Context() + ownedContexts.add(ctx) + await mountAgentLoopTestDependencies(ctx) + const loop = await mountAgentLoopTestHarness(ctx) + const control = new SessionControlController(ctx) + const agent = await loop.create(SessionId('late-projection-queue')) + const { inbox } = agent + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + await iterator.next() + const pending = message('late projection') + + inbox.append('next-turn', pending) + + await expect(iterator.next()).resolves.toMatchObject({ + value: { + type: 'projection', + key: 'inbox', + value: { 'next-turn': [{ id: pending.id }], 'next-step': [] }, + }, + }) + await expect(nextQueueFrame(iterator)).resolves.toMatchObject({ + items: [{ id: pending.id, placement: 'queued' }], + }) abort.abort() await iterator.next() @@ -133,14 +178,19 @@ describe('Session control queue projection', () => { const { ctx, control, inbox } = await harness() const iterator = control.control(new AbortController().signal)[Symbol.asyncIterator]() await iterator.next() - inbox.append('next-turn', message('first')) - inbox.append('next-turn', message('second')) + const first = message('first') + const second = message('second') + inbox.append('next-turn', first) + inbox.append('next-turn', second) - const first = await iterator.next() - expect(first).toMatchObject({ done: false, value: { type: 'queue' } }) + const queues: Extract[] = [] + ownedContexts.delete(ctx) await ctx.fiber.dispose() - const second = await iterator.next() - expect(second).toMatchObject({ done: false, value: { type: 'queue' } }) - await expect(iterator.next()).resolves.toMatchObject({ done: true }) + for (;;) { + const next = await iterator.next() + if (next.done) break + if (next.value.type === 'queue') queues.push(next.value) + } + expect(queues.map(queue => queue.items.map(item => item.id))).toEqual([[first.id], [first.id, second.id]]) }) }) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index deb58794ca..088fefad1c 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -8,14 +8,15 @@ import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek- import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' +import type { Agent, Inbox } from '@deepseek-ai/dsh-agent' +import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' import { SessionPersistenceRevision, @@ -42,8 +43,8 @@ function promptRequest( } } -function inboxFor(session: Session): Inbox { - return new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) +function inboxFor(): Inbox { + return createInboxStub() } function header(id: string, createdAt: number, extra: Partial = {}): SessionHeader { @@ -545,7 +546,7 @@ describe('subagent ownership fence', () => { }) const followup = vi.fn() const agent = { - id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup, + id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup, } as unknown as Agent ctx.agents.register(agent) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) @@ -566,7 +567,7 @@ describe('subagent ownership fence', () => { const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } }) const followup = vi.fn() const agent = { - id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup, + id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup, } as unknown as Agent ctx.agents.register(agent) const remote = createSessionTestRemote(ctx, { @@ -687,7 +688,7 @@ describe('sessions.prompt synchronous rejection', () => { ctx.agents.register({ id: session.id, session, - inbox: inboxFor(session), + inbox: inboxFor(), status: 'idle', ctx, followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index 1456e87abc..90ffb10456 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -7,19 +7,18 @@ * pushed through the control stream. */ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' -import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import SessionProjectionCache, { projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache' @@ -27,8 +26,19 @@ import Storage from '@deepseek-ai/dsh-storage' import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' import * as StorageJson from '@deepseek-ai/dsh-storage-json' import type { SessionControlFrame, SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types' +import { + mountAgentLoopTestDependencies, + mountAgentLoopTestHarness, +} from '@deepseek-ai/dsh-agent-loop-testkit' import { createSessionTestRemote, testSessionPersistence, type TestSessionRemote } from './test-remote.ts' +const ownedContexts = new Set() +afterEach(async () => { + await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose())) + ownedContexts.clear() +}) +let nextHarnessSession = 1 + declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { 'test/last-user': LastUserState @@ -108,15 +118,35 @@ const privatePromptUnit = () => ({ stateVersion: 1, }) satisfies ProjectionDefinition<'test/private-prompt', string | null> -async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session }> { +async function harness(withRegistry: boolean): Promise<{ + ctx: Context + session: Session + readonly claim: (target: 'next-turn' | 'next-step') => UserMessage[] +}> { const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - if (withRegistry) await ctx.plugin(SessionProjectionRegistry) - const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) - // The gateway reads both the session and durable inbox baseline. - ctx.agents.register({ id: session.id, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', ctx } as Agent) - return { ctx, session } + ownedContexts.add(ctx) + if (!withRegistry) { + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) + return { + ctx, + session, + claim: () => { throw new Error('inbox is unavailable without the projection registry') }, + } + } + await mountAgentLoopTestDependencies(ctx) + const loop = await mountAgentLoopTestHarness(ctx) + const agent = await loop.create( + SessionId(`session-projections-${String(nextHarnessSession++)}`), + {}, + { cwd: '/workspace' }, + ) + return { + ctx, + session: agent.session, + claim: target => loop.claim(agent, target, 1), + } } /** Append `count` user messages so the log has paginable message boundaries. */ @@ -205,6 +235,74 @@ describe('session.history projections block', () => { expect(last?.event.seq).toBe(projections.asOfSeq) }) + it('reconstructs a cold persisted queue without publishing or resuming an Agent', async () => { + const { ctx } = await harness(true) + const coldId = SessionId('cold-persisted-queue') + const meta: SessionHeader = { version: SESSION_FORMAT_VERSION, id: coldId, createdAt: 1, cwd: '/tmp', isSeeded: false } + const message = createUserMessage({ + content: [{ type: 'text', text: 'survive process restart' }], + source: { kind: 'user' }, + }) + const events: SessionEvent[] = [{ + type: 'agent/inbox/spliced', + seq: SessionSeq(0), + time: 2, + data: { target: 'next-turn', start: 0, inserted: [message] }, + }] + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events, inheritedEventCount: SessionLogOffset(0) }), + }) as never) + const snapshot = await opening(remote(ctx), coldId) + + expect(snapshot.projections.values.inbox).toEqual({ + 'next-turn': [message], + 'next-step': [], + }) + expect(ctx.agents.get(coldId)).toBeUndefined() + expect(ctx.sessions.get(coldId)).toBeUndefined() + }) + + it('removes claimed steering from the pending Inbox projection immediately', async () => { + const { ctx, session, claim } = await harness(true) + const proxy = remote(ctx) + const message = createUserMessage({ + content: [{ type: 'text', text: 'apply this now' }], + source: { kind: 'user' }, + }) + const agent = ctx.agents.get(session.id) + if (agent === undefined) throw new Error('missing Agent') + agent.inbox.append('next-step', message) + claim('next-step') + + const during = await opening(proxy, session.id) + expect(during.projections.values.inbox).toEqual({ + 'next-turn': [], + 'next-step': [], + }) + + session.append('user/message', message, { surfaceOp: 'append' }) + const settled = await opening(proxy, session.id) + expect(settled.projections.values.inbox).toEqual({ + 'next-turn': [], + 'next-step': [], + }) + + const rejected = createUserMessage({ + content: [{ type: 'text', text: 'reject this pre-step' }], + source: { kind: 'user' }, + }) + session.append('turn/start', { turn: 1 }) + agent.inbox.append('next-step', rejected) + claim('next-step') + session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } }) + const closed = await opening(proxy, session.id) + expect(closed.projections.values.inbox).toEqual({ + 'next-turn': [], + 'next-step': [], + }) + }) + it('returns a complete current replacement cut on each follow generation', async () => { const { ctx, session } = await harness(true) ctx.sessionProjections.register(lastUserUnit()) diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index 788955ed0d..550d09b852 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index c4a3703704..1921b183d6 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml new file mode 100644 index 0000000000..80c6207706 --- /dev/null +++ b/packages/api/workspace-files/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/workspace-files/README.md +README.md: f7442845bc3c592bee0c59817a72ad07c8c91a2a +README.zh.md: 4acd022335ee7c276aca00d66e177c19b060df0a diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md new file mode 100644 index 0000000000..f7442845bc --- /dev/null +++ b/packages/api/workspace-files/README.md @@ -0,0 +1,154 @@ +--- +description: "Workspace file service for the web GUI: paged read, byte windows, stat, directory listing, and the Agent-write change feed inside the Session workspace root, exposed as the workspaceFiles Remote namespace." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-workspace-files + +English | [中文](README.zh.md) + +## Summary + +`@deepseek-ai/dsh-api-workspace-files` owns the Host `ctx.workspaceFiles` service and the generated Client `workspaceFiles` Remote namespace: `read` returns one page of lines from a UTF-8 text file, `readBytes` returns one window of raw bytes from any regular file, `stat` returns a file's version and size without its content, `list` returns one directory's direct children, and `changes` streams every filesystem observation an Agent makes inside the Session's workspace root. All five run over the composed `ctx.fs` and confine themselves to the workspace root the sandbox policy resolves for the addressed Session; the filesystem backend's own cwd never decides. Client packages reach the namespace through the [`api-remotes`](../../api/remotes/README.md) assembly. The package's `./client` export registers the `file` resource provider that turns `stat` and `changes` into live file metadata for `useResource<'file'>`; the Sidebar's file tree tab lists directories through `list`. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, and the Typert Gateway; the bundle does so right after the Session Controller. Every method takes the Session identity on the wire, so a Client calls `remote.workspaceFiles.read(agent, path, range, signal)`, `stat(agent, path, signal)`, `readBytes(agent, path, range, signal)`, `list(agent, path, signal)`, or `changes(agent, signal)` and never names a root itself. + +| Method | Returns | Purpose | +|---|---|---| +| `stat(path)` | `WorkspaceFileStat { absolutePath, version, bytes? }` | Identity, version, and size of one regular file, without content | +| `read(path, { offset?, limit? })` | `WorkspaceFileText` = stat + `{ offset, text, lines, eof }` | One window of lines from a UTF-8 text file; `lines` counts them, so one empty line and a page past the end read differently | +| `readBytes(path, { offset?, length? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | One window of raw bytes from any regular file, base64-encoded | +| `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | Direct children of one directory | +| `changes()` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then Agent observations inside the workspace root | + +### Addressing and paths + +`read`, `stat`, and `list` accept a workspace path that is absolute or relative to the Session's workspace root. Two path vocabularies leave the service, and each method uses exactly one: `read`, `stat`, and `changes` report a file as its absolute path in the filesystem's execution world, symlinks resolved (`WorkspaceFileStat.absolutePath`, `WorkspaceFileChange.absolutePath`), because their consumer is the Client resource system, which follows changes by that path; `list` reports the listed directory as a workspace path relative to the root — empty for the root itself — because its consumer is a tree rooted there, and a child's path is that value joined with the entry name by `/`. + +### Pages + +`read` returns one line window, never the whole file. `range.offset` is the 1-based first line and defaults to 1; `range.limit` is the largest number of lines on the page and defaults to `maxLines`, which it may not exceed — a larger limit, or an offset or limit that is not a positive integer, is a `gateway/bad-request`. Lines end at `\n`, and a final `\n` terminates the last line rather than starting an empty one, so a two-line file has two lines. The page's `text` joins its lines with `\n` and carries no terminator after the last; `eof` is true when the page includes the file's last line, and an offset past the end returns an empty page with `eof` true. Every page also carries the file's `version` from the stat that preceded it, so a consumer can tell a fresh page from a stale one, and `bytes`, the complete file's size when the backend reports it. The service reads the file only up to the first character past the page, so a very large file costs one page of memory per request. + +### Byte windows + +`read` pages by lines and never by bytes; a byte window is `readBytes`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest number of bytes in the window and defaults to `maxBytes`, which it may not exceed — a longer window fails with `too-large` instead of arriving shortened, and an offset or length that is not an integer in range is a `gateway/bad-request`. The window comes back as base64 `data`, shorter than `length` at the end of the file and empty at or past it; `eof` is true when the window includes the file's last byte. Nothing is decoded and nothing is refused as binary, so an image or a NUL-laden file reads where `read` fails with `not-text`. The same `version` and `bytes` ride along as on a page. + +### The four gates + +Every read, stat, and listing passes four gates in this order. First, `lstat` inspects the path itself before anything follows it: a symlink, wherever it points, fails `read` and `stat` with `not-regular-file` and `list` with `not-directory`, each carrying the entry's `kind`. Second, containment: the path resolves to a target and `ctx.fs.contains(root, target)` decides, so a `..` traversal or an absolute path outside the root fails with `outside-workspace` — never a string-prefix comparison, which cannot see a realpath that leaves the root. Third, the caps: a page whose text exceeds `maxBytes` fails with `too-large` instead of arriving shortened — the file itself has no size cap — while `maxEntries` cuts a listing and sets `truncated`. Fourth, text: content that is not UTF-8 up to the end of the page, or a page that carries a NUL byte, fails with `not-text`; bytes past the page are not inspected. A missing path fails with `not-found`; an empty path is a `gateway/bad-request`. + +### The change feed + +`changes` is a `stream` Remote. A generation registers its observation queue and resolves the Session workspace root before yielding `{ kind: 'ready' }`. It then yields `{ kind: 'change', change }`, where `change` is `{ absolutePath, version }` for a present file or `{ absolutePath, absent: true }` for one observed gone. The source is `fs/observed`, filtered to targets inside that root; the operating system is not watched. Observations after the generation's first pull are queued, including while the root resolves. The generation ends on cancellation or plugin disposal. + +### Configuration + +| Field | Default | Meaning | +|---|---|---| +| `maxBytes` | `2097152` (2 MiB) | Inclusive byte cap on one page's text and on one byte window; a larger page or window fails | +| `maxLines` | `5000` | Default and largest page size in lines; a larger `limit` is refused | +| `maxEntries` | `2000` | Cap on returned directory entries; the rest is dropped and reported cut | + +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-workspace-files) is the exhaustive source for every accepted field and its JSDoc. + +### Failures + +Each failure is one `RemoteError` code with typed details, declared in [`src/types.ts`](src/types.ts): `workspace-file/not-found`, `workspace-file/outside-workspace`, `workspace-file/too-large` (with `limit`, the page and window cap), `workspace-file/not-text`, `workspace-file/not-regular-file` (`kind`: `directory`, `symlink`, or `other`), and `workspace-file/not-directory` (`kind`: `file`, `symlink`, or `other`). Callers branch on the code, never on message text. + +### Client file resources + +The browser export registers the `file` provider into `ctx.resources` and requires `resources`, `remote`, `remote.workspaceFiles`, and `sessions`. The bundle's single `workspace-files` row supplies both faces; the Client has no separate configuration. A component follows a file through its standard `useResource<'file'>(address)` prop and reads `{ absolutePath, version, bytes?, changed }`; content is fetched separately through the paged methods. + +A `session//` resource address sends its relative path unchanged to the Host, which resolves and confines it against that Session's workspace root; the Client needs no Session `cwd`. An `absolute/` address reads through the current Session. Both use the `dsh-resource://file/` grammar in [workspace-path](../../util/workspace-path/README.md). An absolute address without a current Session produces `workspace-file/unknown-workspace`; an unsupported address produces `workspace-file/unsupported-address`. These Client failures end the stream and make reload a no-op. + +The provider waits for the Host's `ready` frame before its first `stat`, queues changes during the read, then binds the follower to `stat.absolutePath`. Both queued and live changes match that Host-returned path. A new write version raises `changed` while retaining the last byte size; duplicate versions are ignored. An absent notice or reload re-stats the file. A failed stat keeps the address followed; a later write or reload can recover it, and any Session write can trigger a retry before the first successful path binding. Reload clears `changed`; a Host-triggered re-stat keeps it raised. Frames are `RemoteResult` values, and programming exceptions remain uncaught. + +One supervised `changes` stream serves every followed file in a Session. Followers match absolute paths with backslashes normalized to slashes. Carrier loss reconnects through the Gateway supervisor; a Host-ended or terminally failed feed ends its followers and leaves their last metadata readable until reopened. The last follower leaving disposes the stream, a successor waits for that disposal, and plugin teardown awaits all pending closes. The provider declares `ResourceProtocolMap.file`; the text preview declares its Sidebar line-navigation parameters. + +----- + + +## Understand the implementation + +

+Implementation internals — click to expand + +### Design concept + +Reads through `ctx.fs` are deliberately unconfined — the sandboxing backend fences writes and edits only — so every constraint here is the service's own. A page is cut from `streamText`, which decodes and rejects non-UTF-8 chunk by chunk: the cutter counts lines before the window without keeping them, admits each in-window segment against the byte cap before buffering it, and returns at the first character past the window, so neither a huge file nor one giant line can hold more than a page in memory; the NUL scan then runs on the page. One `stat` before the stream names the version and size the page reports. The path gate runs before containment on purpose: `lstat` is path-shaped and sees the link, while `resolve` follows it; the price is that an entry outside the root reports its own kind before its position. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`: the `workspaceFiles` service and Remote namespace, `Config`, the gates, the page cutter, `read`, `readBytes`, `stat`, `list` | +| [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`: `fs/observed` subscription and one queue per open `changes` generation | +| [`src/types.ts`](src/types.ts) | Wire types and the `RemoteErrorDetailsMap` codes, published as `./types` for Client packages | +| [`src/client/index.ts`](src/client/index.ts), [`provider.ts`](src/client/provider.ts), [`change-feed.ts`](src/client/change-feed.ts) | Browser plugin, file metadata, and per-Session change feed | +| [`src/client/types.ts`](src/client/types.ts), [`remote.ts`](src/client/remote.ts) | Resource values, parameters, Client error codes, and generated Remote types | +| — | No runtime invariant companion is published; every Host answer is derived from `ctx.fs` and the sandbox policy at call time. | + +Typert generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`. + +
+ +----- + + +## Further Exploration + +- [Filesystem capability](../../fs/fs/README.md) — the `ctx.fs` contract this service reads through, including `fs/observed` and `readByteRange`. +- [Sandbox policy](../../sandbox/sandbox-policy/README.md) — where the Session's workspace root comes from. +- [Remote assembly](../../api/remotes/README.md) — how Client packages reach the `workspaceFiles` namespace. +- [Client resources](../../client/resources/README.md) — the resource model, `useResource`, pins, and provider lifetime. +- [Workspace path helpers](../../util/workspace-path/README.md) — `fileAddressFor` and `parseFileAddress`, the `dsh-resource://file/…` address grammar both ends share. +- [Sidebar text preview](../../client/ui-sidebar-textpreview/README.md) — the tab type that follows a file through the `file` provider and reads its pages. + +----- + + +## Model Experience + +None, as this package registers no tool, contributes no prompt section, and appends no session event. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +- **Agent writes only** — `changes` relays `fs/observed` emissions; a file changed by a subprocess, a shell command, or the user's editor produces no frame. +- **Kind before position** — an entry outside the workspace whose type already disqualifies it reports `not-regular-file` or `not-directory`, not `outside-workspace`, because the path gate precedes containment. +- **No total line count** — a page reports `eof`, not how many lines follow; a consumer that needs the total pages to the end or estimates from `bytes`. +- **One giant line has no page** — a single line above `maxBytes` fails `too-large` at every window that includes it, because pages are cut by lines, not bytes. +- **Version precedes content** — the `version` on a page is the stat's, taken before the stream; a write landing between the two leaves the page one version behind, which the next `changes` frame reports. +- **Unbounded generation queue** — a `changes` generation buffers every contained observation until its consumer pulls; a stalled consumer grows Host memory for the life of the stream. +- **`maxEntries` bounds the answer, not the listing** — `list` asks `ctx.fs.listDir` for every child and cuts the array afterwards, so a directory far above the cap still costs the Host the whole listing (on `fs-local`, one stat per child); bounding that work needs a limit on the filesystem seam's `listDir`. +- **Dead feeds retain metadata** — after the Host ends `changes` or the stream fails terminally, open values retain their last state until reopened; reload does not reopen the stream. +- **Reload is shared by path** — a reload re-stats every follower of that absolute path in the Session and clears their `changed` flags, including readers that did not reload their content. Per-record reload delivery remains deferred. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md new file mode 100644 index 0000000000..4acd022335 --- /dev/null +++ b/packages/api/workspace-files/README.zh.md @@ -0,0 +1,154 @@ +--- +description: "面向 Web GUI 的工作区文件服务:在 Session 工作区根内做分页读取、字节窗口、stat、目录列举与 Agent 写入变更流,以 workspaceFiles Remote 命名空间暴露。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-workspace-files + +[English](README.md) | 中文 + +## 概述 + +`@deepseek-ai/dsh-api-workspace-files` 拥有 Host 侧 `ctx.workspaceFiles` 服务与生成的 Client 侧 `workspaceFiles` Remote 命名空间:`read` 返回一个 UTF-8 文本文件的一页行,`readBytes` 返回任意普通文件的一个原始字节窗口,`stat` 返回文件的版本与大小而不带内容,`list` 返回一个目录的直接子项,`changes` 流式推送 Agent 在 Session 工作区根内做出的每一次文件系统观察。五者都经组合后的 `ctx.fs` 运行,并把自己限定在沙箱策略为被寻址 Session 解析出的工作区根内;文件系统后端自己的 cwd 从不参与判定。Client 包经 [`api-remotes`](../../api/remotes/README.zh.md) 装配触达该命名空间。本包的 `./client` 导出注册 `file` 资源提供者,把 `stat` 与 `changes` 变成 `useResource<'file'>` 的实时文件元数据;Sidebar 的文件树 tab 经 `list` 列举目录。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +把本包与 `dsh-fs`、`dsh-sandbox-policy` 和 Typert Gateway 一起挂载;bundle 把它紧随 Session Controller 之后挂载。每个方法都在线路上携带 Session 身份,Client 调用 `remote.workspaceFiles.read(agent, path, range, signal)`、`stat(agent, path, signal)`、`list(agent, path, signal)` 或 `changes(agent, signal)`,从不自己指定根。 + +| 方法 | 返回 | 用途 | +|---|---|---| +| `stat(path)` | `WorkspaceFileStat { absolutePath, version, bytes? }` | 一个普通文件的身份、版本与大小,不含内容 | +| `read(path, { offset?, limit? })` | `WorkspaceFileText` = stat + `{ offset, text, lines, eof }` | UTF-8 文本文件的一个行窗口;`lines` 计行数,使单个空行与越过文件末尾的页可区分 | +| `readBytes(path, { offset?, length? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | 任意普通文件的一个原始字节窗口,base64 编码 | +| `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | 一个目录的直接子项 | +| `changes()` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为工作区根内的 Agent 观察 | + +### 寻址与路径 + +`read`、`stat` 与 `list` 接受工作区路径,可以是绝对路径,也可以是相对于 Session 工作区根的路径。离开服务的路径词汇有两套,每个方法只用其中一套:`read`、`stat` 与 `changes` 以文件系统执行环境中的绝对路径报告文件,符号链接已解析(`WorkspaceFileStat.absolutePath`、`WorkspaceFileChange.absolutePath`),因为其消费方是 Client 资源系统,它按这条路径跟随变更;`list` 以相对于根的工作区路径报告被列举目录——根自身为空串——因为其消费方是一棵以根为起点的树,子项路径就是该值与条目名以 `/` 连接。 + +### 分页 + +`read` 返回一个行窗口,绝不返回整个文件。`range.offset` 是 1 起算的首行,缺省为 1;`range.limit` 是该页最多的行数,缺省为 `maxLines` 且不得超过它——更大的 limit,或不是正整数的 offset / limit,都是 `gateway/bad-request`。行以 `\n` 结束,末尾的 `\n` 是最后一行的终止符而不是再起一空行,所以两行文件就是两行。页的 `text` 以 `\n` 连接各行,最后一行之后不带终止符;`eof` 在该页含文件最后一行时为 true,offset 越过末尾则返回空页且 `eof` 为 true。每页还带上前置 stat 得到的文件 `version`,消费方据此分辨新页与旧页,以及 `bytes`——后端能报告时的整文件大小。服务只把文件读到该页之后的第一个字符为止,所以再大的文件每次请求也只占一页内存。 + +### 字节窗口 + +`read` 按行分页,绝不按字节;字节窗口走 `readBytes`。`range.offset` 是 0 起算的首字节,缺省为 0;`range.length` 是窗口最多的字节数,缺省为 `maxBytes` 且不得超过它——更长的窗口以 `too-large` 失败而不是被截短,不是整数或越界的 offset / length 则是 `gateway/bad-request`。窗口以 base64 的 `data` 返回,到文件末尾时短于 `length`,位于或越过末尾时为空;窗口含文件最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝,因此图片或含 NUL 的文件在 `read` 以 `not-text` 失败之处仍可读出。与页一样附带同一 `version` 与 `bytes`。 + +### 四道关 + +每次读取、stat 与列举依次过四道关。第一,`lstat` 在跟随任何东西之前检查路径本身:符号链接不论指向哪里,`read` 与 `stat` 都以 `not-regular-file`、`list` 都以 `not-directory` 拒绝,并带上条目的 `kind`。第二,包含判定:路径解析为目标后由 `ctx.fs.contains(root, target)` 裁决,所以 `..` 上溯或根外绝对路径都以 `outside-workspace` 失败——绝不做字符串前缀比较,那看不见离开根的 realpath。第三,上限:文本超过 `maxBytes` 的页以 `too-large` 失败而不是被截短送达——文件本身没有大小上限——`maxEntries` 则截断列举并置 `truncated`。第四,文本:到该页末尾为止非 UTF-8 的内容,或含 NUL 字节的页,以 `not-text` 失败;页之后的字节不检查。路径不存在以 `not-found` 失败;空路径是 `gateway/bad-request`。 + +### 变更流 + +`changes` 是 `stream` 模式的 Remote。一代流注册观察队列并解析 Session 工作区根之后,才产出 `{ kind: 'ready' }`。随后产出 `{ kind: 'change', change }`,其中 `change` 对存在的文件为 `{ absolutePath, version }`,对被观察到已消失的文件为 `{ absolutePath, absent: true }`。来源是按该根内目标过滤的 `fs/observed`;操作系统并未被监视。一代流首次拉取后的观察都会排队,包括解析根期间的观察。流在取消或插件释放时结束。 + +### 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `maxBytes` | `2097152`(2 MiB) | 单页文本与单个字节窗口的字节上限(含);更大的页或窗口失败 | +| `maxLines` | `5000` | 页大小的缺省值与上限(行);更大的 `limit` 被拒绝 | +| `maxEntries` | `2000` | 返回目录条目数上限;其余丢弃并报告截断 | + +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-workspace-files)是每个可接受字段及其 JSDoc 的完备来源。 + +### 失败 + +每种失败都是一个带类型化 details 的 `RemoteError` 代码,声明于 [`src/types.ts`](src/types.ts):`workspace-file/not-found`、`workspace-file/outside-workspace`、`workspace-file/too-large`(带 `limit`,即页与窗口上限)、`workspace-file/not-text`、`workspace-file/not-regular-file`(`kind` 为 `directory`、`symlink` 或 `other`)以及 `workspace-file/not-directory`(`kind` 为 `file`、`symlink` 或 `other`)。调用方按代码分支,绝不按消息文本。 + +### Client 文件资源 + +浏览器导出向 `ctx.resources` 注册 `file` 提供者,要求 `resources`、`remote`、`remote.workspaceFiles` 和 `sessions` 在场。bundle 中单个 `workspace-files` 条目供应两面;Client 没有单独配置。组件经标准 prop `useResource<'file'>(address)` 跟随文件,读取 `{ absolutePath, version, bytes?, changed }`;内容通过分页方法另行获取。 + +`session//` 资源地址把相对路径原样发送给 Host,由 Host 按该 Session 的工作区根解析并检查包含关系;Client 不需要 Session `cwd`。`absolute/` 地址经当前 Session 读取。两者都使用[workspace-path](../../util/workspace-path/README.zh.md)规定的 `dsh-resource://file/` 语法。没有当前 Session 的绝对地址产生 `workspace-file/unknown-workspace`;不支持的地址产生 `workspace-file/unsupported-address`。这些 Client 失败会结束流,并使刷新无动作。 + +提供者等到 Host 的 `ready` 帧后才发首次 `stat`,读取期间将变更排队,随后将跟随者绑定到 `stat.absolutePath`。排队与实时变更都按该 Host 返回路径匹配。新的写入版本置 `changed`,并保留最近的字节大小;重复版本被忽略。消失通知或刷新会重新 stat 文件。stat 失败后仍跟随地址,后续写入或刷新可使其恢复;首次成功绑定路径前,Session 内任何写入都可触发重试。刷新清除 `changed`,由 Host 触发的重新 stat 保留标记。帧是 `RemoteResult` 值,编程异常不被捕获。 + +每个 Session 的所有被跟随文件共用一条受监督的 `changes` 流。跟随者按反斜杠归一为斜杠的绝对路径匹配。载体掉线由 Gateway 监督器重连;Host 结束或终态失败的流会结束其跟随者,最后的元数据仍可读取,直到重新打开。最后一个跟随者离开时释放流,后继流等待该释放完成,插件拆除等待所有在途关闭。提供者声明 `ResourceProtocolMap.file`;文本预览声明其 Sidebar 行号导航参数。 + +----- + + +## 理解实现 + +
+实现内幕——点击展开 + +### 设计概念 + +经 `ctx.fs` 的读取是有意不加限制的——沙箱后端只围栏写与编辑——所以这里的每条约束都是服务自己的。页从 `streamText` 切出,后者逐块解码并拒绝非 UTF-8:切页器对窗口之前的行只计数不保留,对窗口内的每个片段先按字节上限验收再缓冲,并在窗口之后的第一个字符处返回,所以无论多大的文件或多长的单行都不会在内存里超过一页;随后在该页上做 NUL 扫描。流之前的一次 `stat` 给出页所报告的版本与大小。路径关有意先于包含判定:`lstat` 面向路径、看得见链接,而 `resolve` 会跟随它;代价是根外条目会先报告自己的类型再报告位置。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`:`workspaceFiles` 服务与 Remote 命名空间、`Config`、四道关、切页器、`read`、`readBytes`、`stat`、`list` | +| [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`:`fs/observed` 订阅与每个打开的 `changes` generation 各一条队列 | +| [`src/types.ts`](src/types.ts) | 线路类型与 `RemoteErrorDetailsMap` 错误码,以 `./types` 发布给 Client 包 | +| [`src/client/index.ts`](src/client/index.ts)、[`provider.ts`](src/client/provider.ts)、[`change-feed.ts`](src/client/change-feed.ts) | 浏览器插件、文件元数据与每 Session 变更流 | +| [`src/client/types.ts`](src/client/types.ts)、[`remote.ts`](src/client/remote.ts) | 资源值、参数、Client 错误码与生成的 Remote 类型 | +| — | 不发布运行时 invariant 伴生件;每个 Host 答案都在调用时由 `ctx.fs` 与沙箱策略推导。 | + +Typert 生成 `./typert` 与 `./remote` 暴露的 Host 与 Client Remote 产物。 + +
+ +----- + + +## 进一步探索 + +- [文件系统能力](../../fs/fs/README.zh.md)——本服务经由读取的 `ctx.fs` 契约,含 `fs/observed` 与 `readByteRange`。 +- [沙箱策略](../../sandbox/sandbox-policy/README.zh.md)——Session 工作区根的来源。 +- [Remote 装配](../../api/remotes/README.zh.md)——Client 包如何触达 `workspaceFiles` 命名空间。 +- [Client 资源](../../client/resources/README.zh.md)——资源模型、`useResource`、pin 与提供者生命周期。 +- [工作区路径辅助](../../util/workspace-path/README.zh.md)——`fileAddressFor` 与 `parseFileAddress`,两端共享的 `dsh-resource://file/…` 地址语法。 +- [Sidebar 文本预览](../../client/ui-sidebar-textpreview/README.zh.md)——经 `file` 提供者跟随文件并读取其页的 tab 类型。 + +----- + + +## 模型体验 + +无,本包不注册任何工具、不贡献提示词章节、不追加任何会话事件。 + +#### KV Cache 影响 + +无;本包既不装配也不发送提供方请求。 + +## 已知限制与延期工作 + + + +- **仅覆盖 Agent 写入**——`changes` 转发 `fs/observed` 的发射;子进程、shell 命令或用户编辑器改动的文件不产生任何帧。 +- **类型先于位置**——根外条目若类型本身就不合格,报告的是 `not-regular-file` 或 `not-directory` 而非 `outside-workspace`,因为路径关先于包含判定。 +- **没有总行数**——页只报告 `eof`,不报告后面还有多少行;需要总数的消费方要翻到末尾或按 `bytes` 估算。 +- **超长单行没有页**——超过 `maxBytes` 的单行在包含它的每个窗口都以 `too-large` 失败,因为页按行而非按字节切。 +- **版本先于内容**——页上的 `version` 来自流之前的 stat;两者之间落地的写入会让该页落后一个版本,下一帧 `changes` 会报告它。 +- **generation 队列无界**——一个 `changes` generation 会缓冲每一条被包含的观察直到消费方 pull;停滞的消费方会在流的生命期内持续增长 Host 内存。 +- **`maxEntries` 限制的是答案,不是列举**——`list` 让 `ctx.fs.listDir` 列出全部子项后再截断数组,远超上限的目录仍让 Host 付出整个列举的代价(`fs-local` 上每个子项一次 stat);要限制这份工作,需要文件系统 seam 的 `listDir` 支持上限。 +- **失效流保留元数据**——Host 结束 `changes` 或流终态失败后,已打开的值保持最后已知状态,直到重新打开;刷新不会重开流。 +- **刷新按路径共享**——同一会话中,一次刷新会重新 stat 此绝对路径的全部跟随者并清除其 `changed` 标记,包括没有重读内容的其它读者。按记录投递刷新仍是延期工作。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json new file mode 100644 index 0000000000..b51b6e9594 --- /dev/null +++ b/packages/api/workspace-files/package.json @@ -0,0 +1,86 @@ +{ + "name": "@deepseek-ai/dsh-api-workspace-files", + "description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace", + "version": "0.1.3-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/workspace-files" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-resources" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-deque": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-resources": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ] +} diff --git a/packages/api/workspace-files/src/changes.ts b/packages/api/workspace-files/src/changes.ts new file mode 100644 index 0000000000..6a1bd7a864 --- /dev/null +++ b/packages/api/workspace-files/src/changes.ts @@ -0,0 +1,113 @@ +/** + * Producer of the `changes` stream: every `fs/observed` emission whose target + * lies inside a generation's workspace root becomes one frame of that + * generation. Observations are emitted by tools after their own filesystem + * operation, so the feed covers Agent writes only; the OS is not watched. + * Each generation acknowledges its observation queue and resolved workspace + * root with `ready` before emitting any queued or live changes. + */ + +import type { Context } from '@deepseek-ai/cordis' +import { Deque } from '@deepseek-ai/dsh-deque' +import type { FsObservation, FsTarget } from '@deepseek-ai/dsh-fs' +import type { WorkspaceFileWatchFrame } from './types.ts' + +/** One `fs/observed` emission as received, before any generation filters it. */ +type Observed = readonly [target: FsTarget, observation: FsObservation] + +/** Owns `fs/observed` observation and every open `changes` generation. */ +export class WorkspaceChangeFeed { + private readonly followers = new Set() + + /** @param ctx - Host context carrying the filesystem the observations come from. */ + constructor(private readonly ctx: Context) { + ctx.on('fs/observed', (target, observation) => { + for (const follower of this.followers) follower.push([target, observation]) + }) + ctx.effect(() => () => { + for (const follower of this.followers) follower.close() + this.followers.clear() + }, 'workspace-files.changes') + } + + /** + * Open one generation reporting observations inside `workspaceRoot`. + * @param workspaceRoot - the session's workspace root path. + * @param signal - generation cancellation. + * @returns `ready` after observation is active and the root resolves, then + * observations made after the generation was first pulled, in emission order. + */ + async *follow(workspaceRoot: string, signal: AbortSignal): AsyncIterable { + signal.throwIfAborted() + // Registered before the root resolves, so nothing observed while it does is + // missed; the root only filters at drain time. + const follower = new ChangeFollower() + this.followers.add(follower) + try { + // Under the generation's signal, so a consumer leaving mid-resolve on a slow + // backend releases the follower now rather than when the resolve settles; + // a rejection the abort caused is the quiet end every other abort takes here. + const root = await this.ctx.fs.resolve(workspaceRoot, { signal }).catch((error: unknown) => { + if (signal.aborted) return undefined + throw error + }) + if (root === undefined || signal.aborted || follower.isClosed) return + yield { kind: 'ready' } + for await (const [target, observation] of follower.read(signal)) { + if (!this.ctx.fs.contains(root, target)) continue + const absolutePath = this.ctx.fs.processPath(target) + yield { + kind: 'change', + change: observation.kind === 'present' + ? { absolutePath, version: observation.version } + : { absolutePath, absent: true }, + } + } + } finally { + this.followers.delete(follower) + follower.close() + } + } +} + +/** One generation's queue: observations wait here until its consumer pulls them. */ +class ChangeFollower { + private readonly queue = new Deque() + private wake: (() => void) | undefined + private closed = false + + /** Whether the generation was closed while its workspace root resolved. */ + get isClosed(): boolean { + return this.closed + } + + push(observed: Observed): void { + this.queue.pushBack(observed) + this.wake?.() + } + + close(): void { + this.closed = true + this.wake?.() + } + + /** Drain until closed or aborted; anything still queued then is dropped with the generation. */ + async *read(signal: AbortSignal): AsyncIterable { + const abort = (): void => { this.close() } + signal.addEventListener('abort', abort, { once: true }) + if (signal.aborted) abort() + try { + while (!this.closed) { + const observed = this.queue.popFront() + if (observed !== undefined) { + yield observed + continue + } + await new Promise((resolve) => { this.wake = resolve }) + this.wake = undefined + } + } finally { + signal.removeEventListener('abort', abort) + } + } +} diff --git a/packages/api/workspace-files/src/client/change-feed.ts b/packages/api/workspace-files/src/client/change-feed.ts new file mode 100644 index 0000000000..cc587d5f1f --- /dev/null +++ b/packages/api/workspace-files/src/client/change-feed.ts @@ -0,0 +1,318 @@ +/** + * One Host `changes` subscription per session, fanned out to the open files of + * that session. + * + * The Host reports every agent write in a session on one stream; each open file + * wants only its own. The feed opens the session stream when the first follower + * arrives, hands each frame to the followers of its path, and disposes the + * stream when the last follower leaves. A follower buffers session changes + * until `stat` supplies its Host absolute path, then filters queued and live + * frames by that path, with `\\` normalized to `/`. Resource addresses identify + * reload requests; they never determine a notification path. + */ +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceFileChange, WorkspaceFileWatchFrame } from '../types.ts' +import type { SupervisedStream, WorkspaceFilesRemote } from './remote.ts' +import type { WorkspaceFileEdit, WorkspaceFileNotice } from './types.ts' + +/** + * The follower key of one absolute path. + * @param path - an absolute path from a Host stat or change frame. + * @returns the path with `\\` normalized to `/`. + */ +function keyOf(path: string): string { + return path.replace(/\\/g, '/') +} + +/** Notices of one follower, delivered in order and pulled by its consumer. */ +class Follower implements AsyncIterable { + private readonly pending: Array<{ readonly key: string | undefined; readonly notice: WorkspaceFileNotice }> = [] + private readonly started = Promise.withResolvers() + private wake: (() => void) | undefined + private ended = false + private hostKey: string | undefined + + /** + * Resolves true after the Host acknowledges its subscription, or false if + * this follower ends before acknowledgement. + */ + readonly ready = this.started.promise + + /** + * @param address - resource address used for reload lookup. + * @param leave - unregisters this follower and its abort listener. + */ + constructor(readonly address: string, private readonly leave: () => void) {} + + /** The normalized Host path, absent until a successful stat. */ + get key(): string | undefined { + return this.hostKey + } + + /** + * Select the Host path for queued and future changes. + * @param absolutePath - the successful stat's absolute path. + */ + bind(absolutePath: string): void { + this.hostKey = keyOf(absolutePath) + } + + /** The Host acknowledged an active subscription and resolved workspace root. */ + start(): void { + this.started.resolve(true) + } + + /** + * Queue one notice. + * @param notice - what the consumer receives next. + * @param key - normalized Host path for a change; absent for a reload. + */ + push(notice: WorkspaceFileNotice, key?: string): void { + this.pending.push({ key, notice }) + this.wake?.() + } + + /** Deliver what is queued, then finish. */ + end(): void { + this.ended = true + this.started.resolve(false) + this.wake?.() + } + + /** Unregister even when the consumer has not started pulling notices. */ + dispose(): void { + this.leave() + } + + /** @inheritdoc */ + async *[Symbol.asyncIterator](): AsyncIterator { + try { + while (true) { + const next = this.pending.shift() + if (next !== undefined) { + if (next.key === undefined || this.hostKey === undefined || next.key === this.hostKey) yield next.notice + continue + } + if (this.ended) return + await new Promise((resolve) => { this.wake = resolve }) + this.wake = undefined + } + } finally { + this.dispose() + } + } +} + +/** The stream and followers of one session. */ +class SessionFeed { + private readonly followers = new Set() + private readonly stream: SupervisedStream + private closed = false + private started = false + + /** + * @param remote - the Remote face carrying `workspaceFiles.changes`. + * @param sessionId - the session whose writes this feed follows. + * @param after - the previous feed of this session still closing, if any; the stream opens once it has settled. + * @param onClose - called once when the stream is gone, whatever the cause, with the dispose that is closing it. + */ + constructor( + remote: WorkspaceFilesRemote, + sessionId: SessionId, + after: Promise | undefined, + private readonly onClose: (closed: Promise) => void, + ) { + this.stream = remote.$stream({ + name: `workspace file changes of ${sessionId}`, + // A predecessor still closing finishes first, so one session never has + // two Host streams open at once. + open: (signal) => { + this.started = false + return openAfter(after, () => remote.workspaceFiles.changes(sessionId, signal)) + }, + // A normal end means the Host closed the session's feed: the session is + // gone or the Host is shutting down, so there is nothing to reopen. + ended: () => new Error(`workspace file changes of ${sessionId} ended`), + }) + void this.pump() + } + + /** + * Register one resource address before its Host path is known. + * @param follower - receives changes and binds its path after stat. + */ + add(follower: Follower): void { + this.followers.add(follower) + if (this.started) follower.start() + } + + /** + * Unregister one follower; the last one leaving disposes the stream. + * @param follower - the follower to drop. + */ + remove(follower: Follower): void { + this.followers.delete(follower) + if (this.followers.size === 0) this.close() + } + + /** + * Reload an address and every follower bound to the same Host path. + * @param address - the resource address requesting a reload. + */ + requestRestat(address: string): void { + const keys = new Set() + for (const follower of this.followers) { + if (follower.address === address && follower.key !== undefined) keys.add(follower.key) + } + for (const follower of this.followers) { + if (follower.address === address || (follower.key !== undefined && keys.has(follower.key))) { + follower.push({ kind: 'restat' }) + } + } + } + + private async pump(): Promise { + try { + for await (const item of this.stream) { + const frame = item.value + switch (frame.kind) { + case 'ready': + item.accept() + this.started = true + for (const follower of this.followers) follower.start() + break + case 'change': { + const key = keyOf(frame.change.absolutePath) + const notice = editOf(frame.change) + for (const follower of this.followers) follower.push(notice, key) + break + } + default: + assertNever(frame) + } + } + } catch { + // A terminal stream failure or the Host's end: followers end quietly + // below, and the metadata they hold stays the last known. + } finally { + this.close() + } + } + + private close(): void { + if (this.closed) return + this.closed = true + const closed = this.stream.dispose() + for (const follower of this.followers) follower.end() + this.followers.clear() + this.onClose(closed) + } +} + +/** + * Open a Host stream once a predecessor has finished closing. + * @param after - the predecessor's dispose, or nothing to wait for. + * @param open - opens the stream. + * @returns the stream's items. + */ +async function* openAfter(after: Promise | undefined, open: () => AsyncIterable): AsyncIterable { + await after + yield* open() +} + +/** + * The write one Host frame reports. + * @param frame - the Host frame. + * @returns the edit notice followers receive. + */ +function editOf(frame: WorkspaceFileChange): WorkspaceFileEdit { + return 'absent' in frame ? { kind: 'absent' } : { kind: 'changed', version: frame.version } +} + +function assertNever(frame: never): never { + throw new Error(`Unexpected workspace file watch frame: ${JSON.stringify(frame)}`) +} + +/** + * Per-session fan-out of the Host's workspace file change stream. + * + * Owned by the provider; one instance serves every session of the Client. + */ +export class ChangeFeed { + /** Live feeds only: a feed removes itself when its stream closes. */ + private readonly sessions = new Map() + /** Streams still closing, by session: the session's next feed opens after its predecessor has settled. */ + private readonly closing = new Map>() + + /** + * @param remote - the Remote face carrying `$stream` and `workspaceFiles.changes`. + */ + constructor(private readonly remote: WorkspaceFilesRemote) {} + + /** + * Follow one resource address in one session before its Host path is known. + * + * The follower is registered on call, not on first pull. Changes delivered + * to this Client are queued while stat is pending. The first follower starts + * the session's local `changes` call. The iterable ends + * when `signal` aborts or when the session stream is gone; ending it early + * (`break`, `return`) unregisters the follower as well, and the last follower + * of a session disposes its stream. Await a true `ready` result before stat + * so the Host subscription is active, then bind each stat's absolute path. Until binding, + * any session write can trigger a retry; after binding, only matching queued + * and live changes pass. + * @param sessionId - the session whose workspace holds the file. + * @param address - the resource address, used only for reload lookup. + * @param signal - ends the follow. + * @returns a single-consumer subscription with Host-path binding and explicit disposal. + */ + follow(sessionId: SessionId, address: string, signal: AbortSignal): Follower { + const feed = signal.aborted ? undefined : this.feedOf(sessionId) + const leave = (): void => { + signal.removeEventListener('abort', leave) + follower.end() + feed?.remove(follower) + } + const follower = new Follower(address, leave) + if (feed === undefined) { + follower.end() + } else { + feed.add(follower) + signal.addEventListener('abort', leave, { once: true }) + } + return follower + } + + /** + * Ask an address and its same-session Host-path peers to `stat` again. + * @param sessionId - the session whose workspace holds the file. + * @param address - the resource address requesting a reload. + */ + requestRestat(sessionId: SessionId, address: string): void { + this.sessions.get(sessionId)?.requestRestat(address) + } + + /** + * Wait for every stream that is still closing, so an owner tearing down + * leaves no Host stream behind. + * @returns resolves once no stream of this feed is closing. + */ + async settle(): Promise { + await Promise.all(this.closing.values()) + } + + private feedOf(sessionId: SessionId): SessionFeed { + const existing = this.sessions.get(sessionId) + if (existing !== undefined) return existing + const feed = new SessionFeed(this.remote, sessionId, this.closing.get(sessionId), (closed) => { + this.sessions.delete(sessionId) + // A dispose that rejects is still a settled close: nothing remains to wait for. + const tracked: Promise = closed.then(() => undefined, () => undefined).then(() => { + if (this.closing.get(sessionId) === tracked) this.closing.delete(sessionId) + }) + this.closing.set(sessionId, tracked) + }) + this.sessions.set(sessionId, feed) + return feed + } +} diff --git a/packages/api/workspace-files/src/client/index.ts b/packages/api/workspace-files/src/client/index.ts new file mode 100644 index 0000000000..418cd5fcf5 --- /dev/null +++ b/packages/api/workspace-files/src/client/index.ts @@ -0,0 +1,42 @@ +/** + * Browser half: the `file` resource provider over `remote.workspaceFiles`. + * + * `types.ts` is what the protocol publishes, `change-feed.ts` shares one Host + * `changes` stream per session, `provider.ts` turns it and `stat` into a value + * stream, and this module only wires them into `ctx.resources`. + */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-gateway/client' +// The `sessions` service face supplies the current Session for absolute addresses. +import type {} from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-client-resources/client' +import { ChangeFeed } from './change-feed.ts' +import { createFileResourceProvider, type SessionLookup } from './provider.ts' + +export type { SessionLookup } from './provider.ts' +export type { WorkspaceFileParams, WorkspaceFileResource } from './types.ts' + +/** Required browser services: the resource model, the Remote carrier and its namespace, and the Session list. */ +export const inject = ['resources', 'remote', 'remote.workspaceFiles', 'sessions'] + +/** + * Client plugin body: register the `file` provider for this plugin's lifetime. + * @param ctx - client root context carrying `resources`, the Remote face, and `sessions`. + */ +export function apply(ctx: ClientContext): void { + // The current Session changes with navigation; absolute addresses read it on demand. + const sessions: SessionLookup = { + current: () => ctx.sessions.list.getSnapshot().current, + } + const changes = new ChangeFeed(ctx.remote) + const provider = createFileResourceProvider(ctx.remote, changes, sessions) + ctx.effect(() => { + const release = ctx.resources.register(provider) + // Teardown waits for every session stream still closing, so the plugin + // leaves no Host stream behind. + return async () => { + release() + await changes.settle() + } + }, 'workspace-files: file resource provider') +} diff --git a/packages/api/workspace-files/src/client/provider.ts b/packages/api/workspace-files/src/client/provider.ts new file mode 100644 index 0000000000..0010ad9d3c --- /dev/null +++ b/packages/api/workspace-files/src/client/provider.ts @@ -0,0 +1,181 @@ +/** + * The `file` protocol's provider: a workspace file's metadata as a stream of + * `RemoteResult` frames. + * + * An address names the file in one of two scopes. A `session` address, + * `dsh-resource://file/session//`, carries a path relative to + * that Session's workspace root: the Host receives the relative path as-is and + * resolves it against the root it holds. Only the Host's `stat.absolutePath` + * selects the change-feed key; no Client Session summary is needed. + * An `absolute` address, `dsh-resource://file/absolute/`, carries no + * Session and is read through the Session on screen. An address neither scope + * resolves yields one failure frame — `workspace-file/unsupported-address` for + * a string outside the grammar, `workspace-file/unknown-workspace` when the + * absolute address has no current Session — and ends. + * + * The first frame is the file's `stat`; every Host-reported write yields the + * metadata flagged `changed`; a reported disappearance, or a write while the + * last stat had failed, runs `stat` again and flags what it finds; a reload + * runs `stat` again and clears the flag. Failures travel as `ok: false` frames, never as thrown errors: the + * Remote face does not reject, and anything thrown inside the stream is a + * programming error the resource model lets surface. A failed stat does not end + * the stream: the next write or reload stats again. One {@link ChangeFeed} + * serves every open file of the Client. + */ +import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import type { WorkspaceFileStat } from '../types.ts' +import type { ChangeFeed } from './change-feed.ts' +import type { WorkspaceFilesRemote } from './remote.ts' +import type { WorkspaceFileResource } from './types.ts' + +/** The current Session used to authorize an absolute address. */ +export interface SessionLookup { + /** + * The Session on screen, which an `absolute` address is read through. + * @returns its id, or `undefined` while no Session is current. + */ + current(): SessionId | undefined +} + +/** The Session and unmodified path submitted to the Host. */ +interface HostFile { + readonly sessionId: SessionId + /** The path the Host receives: workspace-relative for a `session` address, absolute for an `absolute` one. */ + readonly path: string +} + +/** + * Build the `file` provider over one Remote face, one change feed, and the Client's Session list. + * @param remote - the Remote face carrying `workspaceFiles.stat`. + * @param changes - the per-session change fan-out. + * @param sessions - the current Session, read for absolute addresses on every open and reload. + * @returns the provider to register into `ctx.resources`. + */ +export function createFileResourceProvider( + remote: WorkspaceFilesRemote, + changes: ChangeFeed, + sessions: SessionLookup, +): ResourceProvider<'file'> { + return { + protocol: 'file', + async *open(address, { signal }): AsyncIterable> { + const resolved = resolve(address, sessions) + if (!resolved.ok) { + yield resolved + return + } + const { sessionId, path } = resolved.value + // Queue changes delivered to this Client while stat is pending. + const notices = changes.follow(sessionId, address, signal) + const stat = (): Promise> => remote.workspaceFiles.stat(sessionId, path, signal) + // Read through a call: a plain `signal.aborted` is narrowed to `false` by + // the first check and would read as always-false after the later awaits. + const aborted = (): boolean => signal.aborted + // Undefined while the last stat failed: the follow is on the address, not + // on the file, so a write or a reload can still bring the file live. + let current: WorkspaceFileResource | undefined + try { + if (!await notices.ready || aborted()) return + const first = await stat() + if (aborted()) return + if (first.ok) { + notices.bind(first.value.absolutePath) + current = metadataOf(first.value, false) + yield { ok: true, value: current } + } else { + yield first + } + for await (const notice of notices) { + if (current === undefined) { + // Still gone: nothing new to report. + if (notice.kind === 'absent') continue + } else if (notice.kind === 'changed') { + // Frames report observations: holding this version already means the + // consumer learns nothing new. + if (notice.version === current.version) continue + current = { ...current, version: notice.version, changed: true } + yield { ok: true, value: current } + continue + } + // A Host notice may mean stale content; only a reload clears the flag. + const again = await stat() + if (aborted()) return + if (!again.ok) { + current = undefined + yield again + continue + } + notices.bind(again.value.absolutePath) + current = metadataOf(again.value, notice.kind !== 'restat') + yield { ok: true, value: current } + } + } finally { + notices.dispose() + } + }, + reload(address) { + const resolved = resolve(address, sessions) + if (resolved.ok) changes.requestRestat(resolved.value.sessionId, address) + }, + } +} + +/** + * Resolve one address to the Host call it stands for, or to the failure frame it earns. + * @param address - the full address, scheme included. + * @param sessions - the Client's Session list. + * @returns the Host file, or the `unsupported-address` / `unknown-workspace` failure. + */ +function resolve(address: string, sessions: SessionLookup): RemoteResult { + const parsed = parseFileAddress(address) + if (parsed === undefined) return { ok: false, error: unsupportedAddress(address) } + if (parsed.scope === 'session') { + // The address is a string boundary: its id segment is the Session id it names. + const sessionId = parsed.sessionId as SessionId + return { ok: true, value: { sessionId, path: parsed.path } } + } + const sessionId = sessions.current() + if (sessionId === undefined) return { ok: false, error: unknownWorkspace(address) } + return { ok: true, value: { sessionId, path: parsed.path } } +} + +/** + * The failure frame's error for an address this provider does not serve. + * @param address - the offending address. + * @returns the typed error. + */ +function unsupportedAddress(address: string): RemoteError<'workspace-file/unsupported-address'> { + return new RemoteError( + 'workspace-file/unsupported-address', + `${address} is not a dsh-resource://file/session// or dsh-resource://file/absolute/ address`, + { address }, + ) +} + +/** + * The failure frame's error for an absolute address with no current Session. + * @param address - the offending address. + * @returns the typed error. + */ +function unknownWorkspace(address: string): RemoteError<'workspace-file/unknown-workspace'> { + return new RemoteError( + 'workspace-file/unknown-workspace', + `${address} requires a current Session`, + { address }, + ) +} + +/** + * The resource value one `stat` result amounts to. + * @param stat - what the Host reported. + * @param changed - whether the consumer's content may be stale: `true` after a + * Host notice prompted the stat, `false` for the opening stat and a reload's. + * @returns the metadata frame value. + */ +function metadataOf(stat: WorkspaceFileStat, changed: boolean): WorkspaceFileResource { + return { absolutePath: stat.absolutePath, version: stat.version, changed, ...(stat.bytes === undefined ? {} : { bytes: stat.bytes }) } +} diff --git a/packages/api/workspace-files/src/client/remote.ts b/packages/api/workspace-files/src/client/remote.ts new file mode 100644 index 0000000000..d1be9c197a --- /dev/null +++ b/packages/api/workspace-files/src/client/remote.ts @@ -0,0 +1,50 @@ +/** + * The slice of the Client Remote this package calls: the generated + * `workspaceFiles` methods by name, and the stream supervisor structurally, so + * the feed and the provider are testable against a scripted face. + */ +import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +// Merges the generated `workspaceFiles` namespace into the Remote face. +import type {} from '@deepseek-ai/dsh-api-workspace-files/remote' + +/** One item of a supervised stream; `accept` marks the delivering generation as healthy. */ +export interface SupervisedStreamItem { + /** The decoded frame. */ + readonly value: Item + /** Reset the reconnect backoff: this generation is delivering. */ + accept(): void +} + +/** A reconnecting single-consumer stream the Remote supervises. */ +export interface SupervisedStream extends AsyncIterable> { + /** + * Stop the stream for good. + * @returns once the active generation and the consumer iterator are closed. + */ + dispose(): Promise +} + +/** What one supervised stream needs from its owner. */ +export interface SupervisedStreamOptions { + /** Diagnostic owner name. */ + readonly name: string + /** Open one physical generation; `signal` aborts it. */ + readonly open: (signal: AbortSignal) => AsyncIterable + /** The error a generation's normal end amounts to; a carrier error asks for a reopen, anything else is terminal. */ + readonly ended: (accepted: boolean) => Error +} + +/** The `workspaceFiles` namespace methods this package calls, as the generated Remote declares them. */ +export type WorkspaceFilesNamespace = Pick + +/** The Client Remote as this package sees it. */ +export interface WorkspaceFilesRemote { + /** + * Create one reconnecting stream. + * @param options - opener and end classification. + * @returns the supervised stream, unstarted until iterated. + */ + $stream(options: SupervisedStreamOptions): SupervisedStream + /** The `workspaceFiles` namespace. */ + readonly workspaceFiles: WorkspaceFilesNamespace +} diff --git a/packages/api/workspace-files/src/client/types.ts b/packages/api/workspace-files/src/client/types.ts new file mode 100644 index 0000000000..606e1164b3 --- /dev/null +++ b/packages/api/workspace-files/src/client/types.ts @@ -0,0 +1,68 @@ +/** + * The `file` protocol's resource metadata, navigation params, Client errors, + * and internal change-feed notices. + */ +// Bring the base `ResourceProtocolMap` declaration into this program so the +// augmentation below merges into it instead of declaring a second interface. +import type {} from '@deepseek-ai/dsh-client-resources/client' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface ResourceProtocolMap { + /** + * One workspace file's metadata, addressed as + * `dsh-resource://file/session//` or + * `dsh-resource://file/absolute/`. + */ + file: WorkspaceFileResource + } +} + +/** What a `file` tab is asked to reveal on open or navigation; JSON-shaped. */ +export interface WorkspaceFileParams { + /** 1-based line to scroll into view; absent leaves the position alone. */ + readonly line?: number +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** + * The address is not a `dsh-resource://file/` address in a scope the + * provider serves: `session//` or + * `absolute/`. Raised by the Client provider; the Host never + * emits it. + */ + 'workspace-file/unsupported-address': { readonly address: string } + /** + * An `absolute` address has no current Session to authorize its Host call. + * Raised by the Client provider; the Host never emits it. Session addresses + * are resolved by the Host without a Client Session summary. + */ + 'workspace-file/unknown-workspace': { readonly address: string } + } +} + +/** + * One workspace file as the resource model carries it: metadata only. + * + * The stream reports that the file moved on; it never carries content. A + * consumer reads the text itself, by page, and uses `version` and `changed` to + * know when its pages are stale. + */ +export interface WorkspaceFileResource { + /** Absolute path in the Host filesystem, as returned by the last successful stat. */ + readonly absolutePath: string + /** The Host's latest report of the file's version: from `stat` first, then from each reported write. */ + readonly version: string + /** Byte size as of the last `stat`, when the backend reports it. */ + readonly bytes?: number + /** The Host reported a write after the last `stat`; a reload (`stat` again) clears it. */ + readonly changed: boolean +} + +/** One Host-reported write inside the session's workspace. */ +export type WorkspaceFileEdit = + | { readonly kind: 'changed'; readonly version: string } + | { readonly kind: 'absent' } + +/** What one follower of a path receives: a Host write, or a local request to `stat` again. */ +export type WorkspaceFileNotice = WorkspaceFileEdit | { readonly kind: 'restat' } diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts new file mode 100644 index 0000000000..42dfb13bc1 --- /dev/null +++ b/packages/api/workspace-files/src/index.ts @@ -0,0 +1,400 @@ +/** + * Workspace file service: paged text reads, byte-window reads, stats, directory + * listings, and the agent-write change feed inside one session's workspace + * root, exposed as the `workspaceFiles` Remote namespace. + * + * Reads through `ctx.fs` are deliberately unconfined — the sandboxing backend + * fences writes and edits only, and says so. Every constraint this service + * needs is therefore its own, and there are four: + * + * 1. The path is authorized by containment in the session's workspace root. + * 2. Containment is decided by {@link FileSystem.contains}, never by comparing + * path strings: `resolve` realpaths, so a prefix test cannot see a symlink + * that leaves the root. `lstat` rejects a link before that follow happens. + * 3. Every cap is validated Config, changeable per deployment. A page is cut by + * lines and refused, not shortened, when its bytes exceed the byte cap; a + * listing is cut by entries and says so. + * 4. Failures are one `RemoteError` per reason, declared in `./types`. + * + * A page is cut from `streamText`, which decodes and rejects non-UTF-8 as it + * goes, so the file is read only up to the first character past the page and + * never held whole in memory; the NUL scan runs on the page itself. + * + * This is NOT modelled on `session.openWorkspacePath`. That endpoint hands a + * path to the local opener and leaves the effect on the machine; this one sends + * file content across the wire, which is a different level of exposure. + */ + +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-fs' +import type { FsDirEntry, FsInfo, FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { WorkspaceChangeFeed } from './changes.ts' +import type { + WorkspaceByteRange, + WorkspaceDirectoryEntry, + WorkspaceDirectoryListing, + WorkspaceFileBytes, + WorkspaceFileRange, + WorkspaceFileStat, + WorkspaceFileText, + WorkspaceFileWatchFrame, +} from './types.ts' + +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host owner of the `workspaceFiles` Remote namespace. */ + workspaceFiles: WorkspaceFiles + } +} + +/** Deployment caps on one page or one listing. */ +export interface Config { + /** + * Inclusive byte cap on one page's text and on one byte window. + * + * A page above this fails; it is not shortened, because a silently cut page + * reads as the whole page. A byte window asking for more is refused the same + * way. The file itself has no size cap: a caller pages through it. + */ + readonly maxBytes: number + /** Default and largest page size in lines; a request asking for more is refused. */ + readonly maxLines: number + /** Cap on returned directory entries; the rest is dropped and reported cut. */ + readonly maxEntries: number +} + +/** One page cut from a decoded text stream. */ +interface Page { + readonly text: string + /** Lines in `text`; `0` for a page past the last line. */ + readonly lines: number + readonly eof: boolean +} + +/** The byte text never carries: its presence marks a page as binary. */ +const NUL = String.fromCharCode(0) + +/** Refuse anything the wire schema admits as a number but a window cannot use: only safe integers index a file. */ +function integerAtLeast(value: number, min: number, name: string): number { + if (!Number.isSafeInteger(value) || value < min) { + throw new RemoteError('gateway/bad-request', `${name} must be a safe integer of at least ${min}`, {}) + } + return value +} + +/** + * Cut lines `offset` through `offset + limit - 1` from decoded chunks, stopping + * at the first character past the page so the rest of the file is never read. + * Lines before the page are counted, not kept, and the page is refused the + * moment its bytes exceed `maxBytes`, so one giant line cannot grow memory past + * the cap either. + */ +async function cutPage( + chunks: AsyncIterable, + offset: number, + limit: number, + maxBytes: number, + path: string, +): Promise { + const last = offset + limit - 1 + const lines: string[] = [] + let current = '' + let bytes = 0 + let lineNumber = 1 + const admit = (size: number): void => { + bytes += size + if (bytes > maxBytes) { + throw new RemoteError( + 'workspace-file/too-large', + `lines ${offset}-${last} of "${path}" exceed the ${maxBytes} byte cap`, + { path, limit: maxBytes }, + ) + } + } + const complete = (): void => { + if (lines.length > 0) admit(1) + lines.push(current) + current = '' + } + for await (const chunk of chunks) { + let position = 0 + while (position < chunk.length) { + if (lineNumber > last) return { text: lines.join('\n'), lines: lines.length, eof: false } + const newline = chunk.indexOf('\n', position) + const segment = newline === -1 ? chunk.slice(position) : chunk.slice(position, newline) + if (lineNumber >= offset) { + admit(Buffer.byteLength(segment, 'utf8')) + current += segment + } + if (newline === -1) break + if (lineNumber >= offset) complete() + lineNumber += 1 + position = newline + 1 + } + } + // Only an in-page line can be pending here: earlier lines were never kept, + // and a character past the page returned above. + if (current.length > 0) complete() + return { text: lines.join('\n'), lines: lines.length, eof: true } +} + +/** + * Workspace path of `target` relative to `root`, derived from the two canonical + * `file:` URIs so the answer is `/`-joined on every platform. Empty for the root. + */ +function workspacePathOf(rootUrl: string, targetUrl: string): string { + const root = new URL(rootUrl).pathname.replace(/\/+$/, '') + const target = new URL(targetUrl).pathname + if (target === root) return '' + return target.slice(root.length + 1).split('/').map(decodeURIComponent).join('/') +} + +/** Strip the resolved child target: the wire carries names and metadata only. */ +function directoryEntry(child: FsDirEntry): WorkspaceDirectoryEntry { + return { + name: child.name, + type: child.type, + ...child.size === undefined ? {} : { size: child.size }, + } +} + +/** Host Remote service over the composed filesystem, confined to one workspace. */ +export class WorkspaceFiles extends TypertRemoteService { + static inject = ['fs', 'sandboxPolicy', 'typert'] + + static Config: z = z.object({ + maxBytes: z.number().step(1).min(1).default(2 * 1024 * 1024), + maxLines: z.number().step(1).min(1).default(5000), + maxEntries: z.number().step(1).min(1).default(2000), + }) + + private readonly feed: WorkspaceChangeFeed + + /** + * @param ctx - Host context carrying the filesystem and the sandbox policy. + * @param config - deployment caps on one page or one listing. + */ + constructor(ctx: Context, private readonly config: Config) { + super(ctx, 'workspaceFiles') + this.feed = new WorkspaceChangeFeed(ctx) + } + + /** + * Read one page of lines from a UTF-8 text file inside the Agent's workspace. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param path - workspace path, absolute or relative to the workspace root. + * @param range - the line window; omitted fields take the page defaults. + * @param signal - caller cancellation. + * @returns the page, the file's version at the stat before it, and whether it reaches the last line. + */ + @Remote + async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise { + const { offset, limit } = this.resolvePage(range) + const { target, info } = await this.locateFile(agent, path, signal) + const page = await this.cutPage(target, offset, limit, signal, path) + if (page.text.includes(NUL)) { + throw new RemoteError('workspace-file/not-text', `"${path}" contains NUL bytes`, { path }) + } + return { ...this.statOf(target, info), offset, text: page.text, lines: page.lines, eof: page.eof } + } + + /** + * Read one byte window of a regular file inside the Agent's workspace: raw + * bytes, no text decoding and no binary rejection. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param path - workspace path, absolute or relative to the workspace root. + * @param range - the byte window; omitted fields take the window defaults. + * @param signal - caller cancellation. + * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. + */ + @Remote + async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise { + const { offset, length } = this.resolveWindow(range, path) + const { target, info } = await this.locateFile(agent, path, signal) + const data = await this.ctx.fs.readByteRange(target, { offset, length }, signal) + const eof = info.size === undefined ? data.length < length : offset + data.length >= info.size + return { ...this.statOf(target, info), offset, data: Buffer.from(data).toString('base64'), eof } + } + + /** + * Report one regular file's identity, version, and size without its content. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param path - workspace path, absolute or relative to the workspace root. + * @param signal - caller cancellation. + * @returns the file's absolute path, current version, and byte size. + */ + @Remote + async stat(agent: Agent, path: string, signal: AbortSignal): Promise { + const { target, info } = await this.locateFile(agent, path, signal) + return this.statOf(target, info) + } + + /** + * List the direct children of one directory inside the Agent's workspace. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param path - workspace path, absolute or relative to the workspace root. + * @param signal - caller cancellation. + * @returns the directory's children in the backend's stable name order, bounded by the entry cap. + */ + @Remote + async list(agent: Agent, path: string, signal: AbortSignal): Promise { + const { root, workspaceRoot, entry } = await this.inspect(agent, path, signal) + if (entry.type !== 'directory') { + throw new RemoteError( + 'workspace-file/not-directory', + `"${path}" is a ${entry.type}`, + { path, kind: entry.type }, + ) + } + const target = await this.confine(root, workspaceRoot, path, signal) + const children = await this.ctx.fs.listDir(target, signal) + return { + path: workspacePathOf(this.ctx.fs.fileUrl(root), this.ctx.fs.fileUrl(target)), + entries: children.slice(0, this.config.maxEntries).map(directoryEntry), + truncated: children.length > this.config.maxEntries, + } + } + + /** + * Stream every `fs/observed` observation of a file inside the Agent's + * workspace. Only Agent filesystem operations report here; the OS is not + * watched. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param signal - generation cancellation. + * @returns `ready` once the Host observation queue is active and the workspace + * root is resolved, then queued and live observations in emission order. + */ + @Remote({ mode: 'stream' }) + changes(agent: Agent, signal: AbortSignal): AsyncIterable { + return this.feed.follow(this.workspaceRootOf(agent), signal) + } + + /** Apply the page defaults and caps here, so the request never carries them implicitly. */ + private resolvePage(range: WorkspaceFileRange): { offset: number; limit: number } { + const offset = range.offset === undefined ? 1 : integerAtLeast(range.offset, 1, 'offset') + const limit = range.limit === undefined ? this.config.maxLines : integerAtLeast(range.limit, 1, 'limit') + if (limit > this.config.maxLines) { + throw new RemoteError('gateway/bad-request', `limit must be at most ${this.config.maxLines}`, {}) + } + return { offset, limit } + } + + /** Apply the byte-window defaults and cap; a window above the cap is refused, not shortened. */ + private resolveWindow(range: WorkspaceByteRange, path: string): { offset: number; length: number } { + const offset = range.offset === undefined ? 0 : integerAtLeast(range.offset, 0, 'offset') + const length = range.length === undefined ? this.config.maxBytes : integerAtLeast(range.length, 1, 'length') + if (offset + length > Number.MAX_SAFE_INTEGER) { + throw new RemoteError('gateway/bad-request', 'offset plus length must stay a safe integer', {}) + } + if (length > this.config.maxBytes) { + throw new RemoteError( + 'workspace-file/too-large', + `${length} bytes of "${path}" exceed the ${this.config.maxBytes} byte cap`, + { path, limit: this.config.maxBytes }, + ) + } + return { offset, length } + } + + + /** + * The workspace root comes from the policy, not from the backend's own cwd + * default: the `minimal` preset shadows the host provider with a bare + * `fs-local` whose cwd differs, and resolving explicitly makes the answer + * the same whichever instance answers. + */ + private workspaceRootOf(agent: Agent): string { + return this.ctx.sandboxPolicy.resolve({ session: agent.session }).workspaceRoot + } + + /** + * Gates 1 and 2 up to the point where the path's own type is known. The + * path is inspected before containment is decided, so a caller learns whether + * an outside path exists and what kind it is before `outside-workspace` + * refuses it; the caller is the Session's own owner, who can read the Host + * through the Agent anyway, and the accepted cost buys one `lstat` gate for + * every method instead of two resolution orders. + */ + private async inspect( + agent: Agent, + path: string, + signal: AbortSignal, + ): Promise<{ root: FsTarget; workspaceRoot: string; entry: FsPathInfo }> { + if (path.length === 0) throw new RemoteError('gateway/bad-request', 'path is required', {}) + const workspaceRoot = this.workspaceRootOf(agent) + const root = await this.ctx.fs.resolve(workspaceRoot, { signal }) + // Gate on the path itself before anything follows it. + const entry = await this.ctx.fs.lstat(path, { cwd: workspaceRoot }, signal) + if (entry === undefined) { + throw new RemoteError('workspace-file/not-found', `no entry at "${path}"`, { path }) + } + return { root, workspaceRoot, entry } + } + + /** Resolve an inspected path and refuse it unless the workspace contains it. */ + private async confine(root: FsTarget, workspaceRoot: string, path: string, signal: AbortSignal): Promise { + const target = await this.ctx.fs.resolve(path, { cwd: workspaceRoot, signal }) + if (!this.ctx.fs.contains(root, target)) { + throw new RemoteError('workspace-file/outside-workspace', `"${path}" is outside the workspace`, { path }) + } + return target + } + + /** + * All gates for a regular file, ending in the one stat that names its version + * and size. The stat re-checks what `lstat` saw: the file may have gone or + * changed kind in between. + */ + private async locateFile(agent: Agent, path: string, signal: AbortSignal): Promise<{ target: FsTarget; info: FsInfo }> { + const { root, workspaceRoot, entry } = await this.inspect(agent, path, signal) + if (entry.type !== 'file') { + throw new RemoteError('workspace-file/not-regular-file', `"${path}" is a ${entry.type}`, { path, kind: entry.type }) + } + const target = await this.confine(root, workspaceRoot, path, signal) + const info = await this.ctx.fs.stat(target, signal) + if (info === undefined) { + throw new RemoteError('workspace-file/not-found', `no entry at "${path}"`, { path }) + } + if (info.type !== 'file') { + throw new RemoteError('workspace-file/not-regular-file', `"${path}" is a ${info.type}`, { path, kind: info.type }) + } + return { target, info } + } + + private statOf(target: FsTarget, info: FsInfo): WorkspaceFileStat { + return { + absolutePath: this.ctx.fs.processPath(target), + version: info.version, + ...info.size === undefined ? {} : { bytes: info.size }, + } + } + + /** Stream the file as text and cut the page, classifying the backend's non-text refusal. */ + private async cutPage(target: FsTarget, offset: number, limit: number, signal: AbortSignal, path: string): Promise { + try { + return await cutPage(await this.ctx.fs.streamText(target, signal), offset, limit, this.config.maxBytes, path) + } catch (error: unknown) { + if (isNotTextRefusal(error)) { + throw new RemoteError('workspace-file/not-text', `"${path}" is not UTF-8 text`, { path }, { cause: error }) + } + throw error + } + } +} + +/** + * The backend's non-text refusal, recognized by its code alone: the error class + * belongs to whichever `dsh-fs` instance the provider loaded, so no class + * identity is shared across the package boundary. + */ +function isNotTextRefusal(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'FS_NOT_TEXT' +} + +export default WorkspaceFiles diff --git a/packages/api/workspace-files/src/types.ts b/packages/api/workspace-files/src/types.ts new file mode 100644 index 0000000000..83aad4841f --- /dev/null +++ b/packages/api/workspace-files/src/types.ts @@ -0,0 +1,166 @@ +/** + * Wire types of the `workspaceFiles` Remote namespace. Types only: generated + * Remote clients consume this module without Host runtime code. + * + * Two path vocabularies leave here, and each method uses exactly one: + * + * - `read`, `readBytes`, `stat`, and `changes` name a file by its absolute path in the + * filesystem's execution world, because their consumer is the Client + * resource system, whose `dsh-resource://file/session//` address carries that + * same path. + * - `list` speaks workspace paths — the same syntax its `path` argument accepts — + * because its consumer is a tree rooted at the workspace root. + * + * @module @deepseek-ai/dsh-api-workspace-files/types + */ + +// Import the protocol module so the declaration at the end of this file +// augments its error map rather than defining an unrelated ambient module. +import type {} from '@deepseek-ai/dsh-typert-protocol' + +/** Identity and freshness of one workspace file, without its content. */ +export interface WorkspaceFileStat { + /** + * Absolute path of the file in the filesystem's execution world, symlinks + * resolved: `/`-separated on POSIX, drive-rooted with the platform separator + * on Windows. What a `dsh-resource://file/absolute/…` address carries, and + * what a `dsh-resource://file/session//…` address's + * workspace-relative path resolves to against that Session's root. + */ + readonly absolutePath: string + /** Opaque freshness token at the time of the stat; never parsed. */ + readonly version: string + /** Byte size of the complete file, when the backend reports it. */ + readonly bytes?: number +} + +/** + * The line window one `read` returns. Lines are 1-based and end at `\n`; a + * final `\n` terminates the last line rather than starting an empty one. + */ +export interface WorkspaceFileRange { + /** First line of the page. Defaults to 1. */ + readonly offset?: number + /** Largest number of lines on the page. Defaults to, and may not exceed, the configured `maxLines`. */ + readonly limit?: number +} + +/** One page of a workspace text file as a Client reads it. */ +export interface WorkspaceFileText extends WorkspaceFileStat { + /** First line of the page, as requested. */ + readonly offset: number + /** + * The page's lines joined by `\n`, without a terminator after the last one. + * Empty for a page past the file's last line and for a page holding one + * empty line; `lines` tells them apart. + */ + readonly text: string + /** How many lines the page holds; `0` when `offset` lies past the file's last line. */ + readonly lines: number + /** Whether the page includes the file's last line. */ + readonly eof: boolean +} + +/** The byte window one `readBytes` returns. Offsets are 0-based. */ +export interface WorkspaceByteRange { + /** First byte of the window. Defaults to 0. */ + readonly offset?: number + /** Largest number of bytes in the window. Defaults to, and may not exceed, the configured `maxBytes`. */ + readonly length?: number +} + +/** + * One byte window of a workspace file as a Client reads it: raw bytes, no text + * decoding and no binary rejection. `bytes` is the complete file's size. + */ +export interface WorkspaceFileBytes extends WorkspaceFileStat { + /** First byte of the window, as requested. */ + readonly offset: number + /** The window's bytes in base64; empty when `offset` lies at or past the file's end. */ + readonly data: string + /** Whether the window includes the file's last byte. */ + readonly eof: boolean +} + +/** One direct child of a listed workspace directory. */ +export interface WorkspaceDirectoryEntry { + /** Basename inside the listed directory. */ + readonly name: string + /** + * What the child resolves to. A symlink reports the type of its destination, + * and `other` covers everything that is neither a regular file nor a + * directory; `read` still refuses a symlink, so `file` here is a listing fact, + * not a promise that the content is readable. + */ + readonly type: 'file' | 'directory' | 'other' + /** Byte size, present only for a regular file whose backend reports it. */ + readonly size?: number +} + +/** Direct children of one workspace directory. */ +export interface WorkspaceDirectoryListing { + /** + * The listed directory as a workspace path, relative to the workspace root + * and empty for the root itself. A child's path is this value joined with + * {@link WorkspaceDirectoryEntry.name} by `/`. + */ + readonly path: string + /** + * Direct children in the backend's stable name order, cut to the configured + * entry cap. Presentation order is the caller's choice. + */ + readonly entries: readonly WorkspaceDirectoryEntry[] + /** Whether the entry cap dropped children from {@link entries}. */ + readonly truncated: boolean +} + +/** + * One observation of a workspace file made by an Agent's own filesystem + * operation. Frames report observations, not deltas: a consumer already holding + * `version` learns nothing new from the frame and can ignore it. + */ +export type WorkspaceFileChange = + | { + /** Absolute path of the observed file, in the same form as {@link WorkspaceFileStat.absolutePath}. */ + readonly absolutePath: string + /** Opaque freshness token after the observed operation; never parsed. */ + readonly version: string + } + | { + /** Absolute path of the observed file, in the same form as {@link WorkspaceFileStat.absolutePath}. */ + readonly absolutePath: string + /** The file was observed to be gone. */ + readonly absent: true + } + +/** + * One frame of a workspace file watch generation. `ready` confirms that the + * Host is observing filesystem operations and has resolved the workspace + * root; observations queued during that resolution follow as `change` frames. + */ +export type WorkspaceFileWatchFrame = + | { readonly kind: 'ready' } + | { readonly kind: 'change'; readonly change: WorkspaceFileChange } + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** No entry exists at that path inside the workspace. */ + 'workspace-file/not-found': { readonly path: string } + /** The path resolves outside the session's workspace root. */ + 'workspace-file/outside-workspace': { readonly path: string } + /** The requested page exceeds the configured byte cap; nothing is returned. */ + 'workspace-file/too-large': { readonly path: string; readonly limit: number } + /** The content read so far is not decodable UTF-8 text, or the page carries NUL bytes. */ + 'workspace-file/not-text': { readonly path: string } + /** The path is not a regular file, so it has no text to read. */ + 'workspace-file/not-regular-file': { + readonly path: string + readonly kind: 'directory' | 'symlink' | 'other' + } + /** The path is not a directory, so it has no children to list. */ + 'workspace-file/not-directory': { + readonly path: string + readonly kind: 'file' | 'symlink' | 'other' + } + } +} diff --git a/packages/api/workspace-files/tests/apply.client.spec.ts b/packages/api/workspace-files/tests/apply.client.spec.ts new file mode 100644 index 0000000000..5153814358 --- /dev/null +++ b/packages/api/workspace-files/tests/apply.client.spec.ts @@ -0,0 +1,61 @@ +/** + * The plugin body: one `file` provider registered into `ctx.resources` for the + * fiber's lifetime, reading through `ctx.remote` and resolving addresses + * authorizing absolute addresses through the current `ctx.sessions` entry. + */ +import { Context } from '@deepseek-ai/cordis' +import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client' +import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import { describe, expect, it, onTestFinished } from 'vitest' +import { apply, inject } from '../src/client/index.ts' +import { FakeRemote, settle } from './fake-remote.client.ts' + +describe('workspace-files client apply', () => { + it('registers the file provider over ctx.remote and ctx.sessions, and unregisters it with the fiber', async () => { + const ctx = new Context() + const remote = new FakeRemote() + const controller = new AbortController() + const pulls: Array> = [] + ctx.provide('remote', remote as never) + ctx.provide('remote.workspaceFiles', remote.workspaceFiles as never) + // No Session summary or cwd is available; only absolute addresses need current. + ctx.provide('sessions', { + list: { getSnapshot: () => ({ byId: {}, current: 's1' }) }, + } as never) + const registered: Array> = [] + let released = 0 + ctx.provide('resources', { + register: (provider: ResourceProvider<'file'>) => { + registered.push(provider) + return () => { + released++ + controller.abort() + for (const request of remote.stats) { + request.resolve({ ok: true, value: { absolutePath: '/host/late-stat', version: 'v0' } }) + } + } + }, + } as never) + const fiber = ctx.plugin({ inject: [...inject], apply }) + onTestFinished(async () => { + await fiber.dispose() + await Promise.all(pulls) + }) + await fiber.await() + + expect(registered.map(provider => provider.protocol)).toEqual(['file']) + const signal = controller.signal + // A session address reaches the Host as its relative path; an absolute one + // is read through the current Session with its absolute path. + pulls.push(registered[0]!.open(sessionFileAddress('s1', 'a.txt'), { signal })[Symbol.asyncIterator]().next()) + pulls.push(registered[0]!.open(absoluteFileAddress('/etc/hosts'), { signal })[Symbol.asyncIterator]().next()) + await settle() + expect(remote.stats.map(pending => [pending.sessionId, pending.path])).toEqual([['s1', 'a.txt'], ['s1', '/etc/hosts']]) + expect(remote.opened).toHaveLength(1) + + await fiber.dispose() + await Promise.all(pulls) + expect(released).toBe(1) + expect(remote.opened[0]!.source.aborted).toBe(true) + }) +}) diff --git a/packages/api/workspace-files/tests/change-feed.client.spec.ts b/packages/api/workspace-files/tests/change-feed.client.spec.ts new file mode 100644 index 0000000000..9fe3ed4c73 --- /dev/null +++ b/packages/api/workspace-files/tests/change-feed.client.spec.ts @@ -0,0 +1,289 @@ +/** + * The change feed's promises: one Host stream per session, delivery by absolute + * path, local stat requests, and a follower's life bounded by its signal or by + * the stream's end. + */ +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { describe, expect, it } from 'vitest' +import { ChangeFeed } from '../src/client/change-feed.ts' +import type { WorkspaceFileWatchFrame } from '../src/types.ts' +import { FakeRemote, peek, settle } from './fake-remote.client.ts' + +const S1 = 's1' as SessionId +const S2 = 's2' as SessionId + +function harness() { + const remote = new FakeRemote() + const feed = new ChangeFeed(remote) + const follow = (sessionId: SessionId, path: string, controller = new AbortController()) => { + const follower = feed.follow(sessionId, path, controller.signal) + follower.bind(path) + return { it: follower[Symbol.asyncIterator](), controller } + } + return { remote, feed, follow } +} + +describe('ChangeFeed — one Host stream per session', () => { + it('starts a later follower from the existing session acknowledgement without opening another stream', async () => { + const { remote, feed } = harness() + const controller = new AbortController() + const first = feed.follow(S1, 'first-resource', controller.signal) + try { + await expect(first.ready).resolves.toBe(true) + const second = feed.follow(S1, 'second-resource', controller.signal) + await expect(second.ready).resolves.toBe(true) + expect(remote.calls).toEqual(['changes', 'accept']) + expect(remote.opened).toHaveLength(1) + + second.bind('/w/second.txt') + const iterator = second[Symbol.asyncIterator]() + await remote.opened[0]!.source.deliver({ + kind: 'change', change: { absolutePath: '/w/second.txt', version: 'v1' }, + }) + await expect(iterator.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } }) + controller.abort() + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + await feed.settle() + expect(remote.disposed).toEqual(['workspace file changes of s1']) + } finally { + controller.abort() + await feed.settle() + } + }) + + it('shares one stream among the followers of a session and opens another per session', async () => { + const { remote, follow } = harness() + follow(S1, '/w/a.txt') + follow(S1, '/w/b.txt') + await settle() + expect(remote.opened.map(o => o.sessionId)).toEqual([S1]) + follow(S2, '/w/a.txt') + await settle() + expect(remote.opened.map(o => o.sessionId)).toEqual([S1, S2]) + }) + + it('disposes the session stream when its last follower leaves and reopens for the next', async () => { + const { remote, follow } = harness() + const a = follow(S1, '/w/a.txt') + const b = follow(S1, '/w/b.txt') + await settle() + a.controller.abort() + await settle() + expect(remote.disposed).toEqual([]) + b.controller.abort() + await settle() + expect(remote.disposed).toEqual(['workspace file changes of s1']) + expect(remote.opened[0]!.source.aborted).toBe(true) + follow(S1, '/w/c.txt') + await settle() + expect(remote.opened).toHaveLength(2) + }) + + it('opens the next stream of a session only after the previous dispose settled, and settle() waits for it', async () => { + const { remote, feed, follow } = harness() + let release!: () => void + remote.disposeGate = new Promise((resolve) => { release = resolve }) + const a = follow(S1, '/w/a.txt') + await settle() + a.controller.abort() + await settle() + expect(remote.disposed).toEqual(['workspace file changes of s1']) + // The next follower registers at once, but its Host stream waits for the close. + follow(S1, '/w/b.txt') + await settle() + expect(remote.opened).toHaveLength(1) + let settled = false + void feed.settle().then(() => { settled = true }) + await settle() + expect(settled).toBe(false) + release() + await settle() + expect(remote.opened).toHaveLength(2) + expect(settled).toBe(true) + // Nothing left closing: settle() resolves at once. + await feed.settle() + }) + + it('keeps waiting for the newest close when two closes of one session overlap', async () => { + const { remote, feed, follow } = harness() + let release!: () => void + remote.disposeGate = new Promise((resolve) => { release = resolve }) + const a = follow(S1, '/w/a.txt') + await settle() + a.controller.abort() + await settle() + // The second feed waits for the first close, then its only follower leaves too. + const b = follow(S1, '/w/b.txt') + await settle() + b.controller.abort() + await settle() + let settled = false + void feed.settle().then(() => { settled = true }) + release() + await settle() + await settle() + expect(settled).toBe(true) + expect(remote.disposed).toHaveLength(2) + follow(S1, '/w/c.txt') + await settle() + expect(remote.opened).toHaveLength(3) + }) + + it('treats a dispose that rejects as settled, so the next stream still opens', async () => { + const { remote, feed, follow } = harness() + // Rejected only once dispose() has taken the gate, so the rejection always has a handler. + let fail!: (error: Error) => void + remote.disposeGate = new Promise((_resolve, reject) => { fail = reject }) + const a = follow(S1, '/w/a.txt') + await settle() + a.controller.abort() + await settle() + follow(S1, '/w/b.txt') + await settle() + expect(remote.opened).toHaveLength(1) + fail(new Error('carrier gone')) + await settle() + expect(remote.opened).toHaveLength(2) + await feed.settle() + }) +}) + +describe('ChangeFeed — delivery', () => { + it('routes a frame to the followers of its path, whichever separator the Host spells', async () => { + const { remote, follow } = harness() + // A stat and a change frame may spell the same Host path with different separators. + const mine = follow(S1, 'C:/w/a b.txt') + const twin = follow(S1, 'C:/w/a b.txt') + const other = follow(S1, 'C:/w/other.txt') + await settle() + const source = remote.opened[0]!.source + source.push({ kind: 'change', change: { absolutePath: 'C:/w/a b.txt', version: 'v1' } }) + source.push({ kind: 'change', change: { absolutePath: 'C:\\w\\a b.txt', absent: true } }) + await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } }) + await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'absent' } }) + await expect(twin.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } }) + await expect(peek(other.it)).resolves.toBe('silent') + // One follower of a path leaving does not silence the other. + mine.controller.abort() + await settle() + source.push({ kind: 'change', change: { absolutePath: 'C:/w/a b.txt', version: 'v2' } }) + await expect(twin.it.next()).resolves.toEqual({ done: false, value: { kind: 'absent' } }) + await expect(twin.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v2' } }) + }) + + it('queues frames reported before the consumer starts pulling', async () => { + const { remote, follow } = harness() + const mine = follow(S1, '/w/a.txt') + await settle() + remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: '/w/a.txt', version: 'v1' } }) + await settle() + await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } }) + }) + + it('keeps sessions apart', async () => { + const { remote, follow } = harness() + const one = follow(S1, '/w/a.txt') + const two = follow(S2, '/w/a.txt') + await settle() + remote.opened[1]!.source.push({ kind: 'change', change: { absolutePath: '/w/a.txt', version: 'v2' } }) + await expect(two.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v2' } }) + await expect(peek(one.it)).resolves.toBe('silent') + }) + + it('hands a stat request to the followers of that path only', async () => { + const { feed, follow } = harness() + const mine = follow(S1, '/w/a.txt') + const other = follow(S1, '/w/b.txt') + await settle() + feed.requestRestat(S1, '/w/a.txt') + feed.requestRestat(S2, '/w/a.txt') + feed.requestRestat(S1, '/w/nobody.txt') + await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'restat' } }) + await expect(peek(other.it)).resolves.toBe('silent') + }) +}) + +describe('ChangeFeed — a follower ends', () => { + it('ends every follower and disposes the session stream for an unknown wire frame kind', async () => { + const { remote, feed } = harness() + const controller = new AbortController() + const first = feed.follow(S1, 'first-resource', controller.signal) + const second = feed.follow(S1, 'second-resource', controller.signal) + const firstIterator = first[Symbol.asyncIterator]() + const secondIterator = second[Symbol.asyncIterator]() + try { + await expect(Promise.all([first.ready, second.ready])).resolves.toEqual([true, true]) + const endings = Promise.all([firstIterator.next(), secondIterator.next()]) + const source = remote.opened[0]!.source + // The Remote double supplies decoded wire data, including an unknown protocol tag. + const wireFrame: unknown = JSON.parse('{"kind":"future-frame"}') + source.push(wireFrame as WorkspaceFileWatchFrame) + await expect(endings).resolves.toEqual([ + { done: true, value: undefined }, + { done: true, value: undefined }, + ]) + await feed.settle() + expect(source.aborted).toBe(true) + expect(remote.disposed).toEqual(['workspace file changes of s1']) + expect(remote.opened).toHaveLength(1) + } finally { + controller.abort() + await Promise.all([firstIterator.return?.(), secondIterator.return?.()]) + await feed.settle() + } + }) + + it('ends on its signal and drops nothing queued before it', async () => { + const { remote, follow } = harness() + const mine = follow(S1, '/w/a.txt') + await settle() + remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: '/w/a.txt', version: 'v1' } }) + await settle() + mine.controller.abort() + await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } }) + await expect(mine.it.next()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('is empty when the signal is already aborted, without opening a stream', async () => { + const { remote, feed } = harness() + const controller = new AbortController() + controller.abort() + const it = feed.follow(S1, '/w/a.txt', controller.signal)[Symbol.asyncIterator]() + await expect(it.next()).resolves.toEqual({ done: true, value: undefined }) + await settle() + expect(remote.opened).toEqual([]) + }) + + it('unregisters when the consumer breaks out after a notice', async () => { + const { remote, follow } = harness() + const mine = follow(S1, '/w/a.txt') + await settle() + remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: '/w/a.txt', version: 'v1' } }) + await mine.it.next() + await mine.it.return?.() + await settle() + expect(remote.disposed).toEqual(['workspace file changes of s1']) + }) + + it('ends every follower when the Host closes the session stream', async () => { + const { remote, follow } = harness() + const a = follow(S1, '/w/a.txt') + const b = follow(S1, '/w/b.txt') + await settle() + remote.opened[0]!.source.end() + await expect(a.it.next()).resolves.toEqual({ done: true, value: undefined }) + await expect(b.it.next()).resolves.toEqual({ done: true, value: undefined }) + // The next follower starts a fresh stream rather than joining the dead one. + follow(S1, '/w/c.txt') + await settle() + expect(remote.opened).toHaveLength(2) + }) + + it('ends every follower when the session stream fails', async () => { + const { remote, follow } = harness() + const a = follow(S1, '/w/a.txt') + await settle() + remote.opened[0]!.source.fail(new Error('carrier gone for good')) + await expect(a.it.next()).resolves.toEqual({ done: true, value: undefined }) + }) +}) diff --git a/packages/api/workspace-files/tests/changes.spec.ts b/packages/api/workspace-files/tests/changes.spec.ts new file mode 100644 index 0000000000..452dbaabee --- /dev/null +++ b/packages/api/workspace-files/tests/changes.spec.ts @@ -0,0 +1,289 @@ +/** The `changes` stream: driven by `fs/observed`, filtered by the workspace root, ended by its signal. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import type { FsObservation } from '@deepseek-ai/dsh-fs' +import { FsVersion } from '@deepseek-ai/dsh-fs' +import { WorkspaceFiles } from '../src/index.ts' +import type { WorkspaceFileWatchFrame } from '../src/types.ts' +import { agent, openWorkspace, type Harness } from './harness.ts' + +let harness: Harness +const closeStreams: Array<() => Promise> = [] + +beforeEach(async () => { + harness = await openWorkspace('dsh-workspace-files-changes-') +}) + +afterEach(async () => { + try { + for (const close of closeStreams.splice(0)) await close() + await harness.dispose() + } finally { + vi.restoreAllMocks() + } +}) + +/** Emit one observation for `path` the way a tool does after touching it. */ +async function observe(path: string, observation: FsObservation): Promise { + const target = await harness.ctx.fs.resolve(path) + harness.ctx.emit('fs/observed', target, observation, undefined) + return harness.ctx.fs.processPath(target) +} + +const present = (version: string): FsObservation => ({ kind: 'present', version: FsVersion(version) }) + +/** Own one generation through abort and iterator completion, including failed assertions. */ +function open( + service: WorkspaceFiles, + controller = new AbortController(), +): { next(): Promise>; controller: AbortController } { + const iterator = service.changes(agent, controller.signal)[Symbol.asyncIterator]() + closeStreams.push(async () => { + controller.abort() + await iterator.return?.() + }) + return { next: () => iterator.next(), controller } +} + +describe('workspaceFiles.changes — frames', () => { + it('acknowledges the resolved root before draining observations queued during root resolution', async () => { + const service = harness.endpoint() + const fs = harness.ctx.fs + const original = fs.resolve.bind(fs) + const root = await original(harness.workspace) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + vi.spyOn(fs, 'resolve').mockImplementation(async (path, opts) => { + if (path !== harness.workspace) return original(path, opts) + entered.resolve(opts?.signal) + await release.promise + opts?.signal?.throwIfAborted() + return root + }) + const stream = open(service) + const first = stream.next() + let acknowledged = false + void first.then(() => { acknowledged = true }) + try { + expect(await entered.promise).toBe(stream.controller.signal) + const a = await observe(join(harness.workspace, 'early-a.txt'), present('a1')) + await observe(join(harness.outside, 'secret.txt'), present('outside')) + const b = await observe(join(harness.workspace, 'early-b.txt'), { kind: 'absent' }) + expect(acknowledged).toBe(false) + release.resolve(undefined) + await expect(first).resolves.toEqual({ done: false, value: { kind: 'ready' } }) + await expect(stream.next()).resolves.toEqual({ + done: false, value: { kind: 'change', change: { absolutePath: a, version: 'a1' } }, + }) + await expect(stream.next()).resolves.toEqual({ + done: false, value: { kind: 'change', change: { absolutePath: b, absent: true } }, + }) + } finally { + release.resolve(undefined) + } + }) + + it('reports a present observation inside the workspace as its absolute path and version', async () => { + const stream = open(harness.endpoint()) + await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } }) + const pending = stream.next() + const absolutePath = await observe(join(harness.workspace, 'a.txt'), present('v1')) + expect(await pending).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v1' } } }) + expect(absolutePath).toBe(harness.ctx.fs.processPath(await harness.ctx.fs.resolve(join(harness.workspace, 'a.txt')))) + }) + + it('reports an absent observation as absent', async () => { + const stream = open(harness.endpoint()) + await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } }) + const pending = stream.next() + const absolutePath = await observe(join(harness.workspace, 'gone.txt'), { kind: 'absent' }) + expect(await pending).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, absent: true } } }) + }) + + it('drops observations outside the workspace root', async () => { + const stream = open(harness.endpoint()) + await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } }) + const pending = stream.next() + await observe(join(harness.outside, 'secret.txt'), present('v1')) + const absolutePath = await observe(join(harness.workspace, 'seen.txt'), present('v2')) + expect(await pending).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v2' } } }) + }) + + it('queues observations made faster than they are pulled, in emission order', async () => { + const stream = open(harness.endpoint()) + await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } }) + const first = stream.next() + const a = await observe(join(harness.workspace, 'a.txt'), present('a1')) + const b = await observe(join(harness.workspace, 'b.txt'), present('b1')) + expect(await first).toEqual({ done: false, value: { kind: 'change', change: { absolutePath: a, version: 'a1' } } }) + expect(await stream.next()).toEqual({ done: false, value: { kind: 'change', change: { absolutePath: b, version: 'b1' } } }) + }) + + it('serves every open generation independently', async () => { + const service = harness.endpoint() + const one = open(service) + const two = open(service) + await expect(one.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } }) + await expect(two.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } }) + const firstOfOne = one.next() + const firstOfTwo = two.next() + const absolutePath = await observe(join(harness.workspace, 'a.txt'), present('v1')) + expect(await firstOfOne).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v1' } } }) + expect(await firstOfTwo).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v1' } } }) + }) +}) + +describe('workspaceFiles.changes — ending', () => { + it('does not acknowledge a subscription disposed while its workspace root is resolving', async () => { + const fs = harness.ctx.fs + const original = fs.resolve.bind(fs) + const root = await original(harness.workspace) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + vi.spyOn(fs, 'resolve').mockImplementation(async (path, opts) => { + if (path !== harness.workspace) return original(path, opts) + entered.resolve(undefined) + await release.promise + return root + }) + let service: WorkspaceFiles | undefined + const fiber = await harness.ctx.plugin(Object.assign((ctx: Context) => { + service = new WorkspaceFiles(ctx, { maxBytes: 1, maxLines: 1, maxEntries: 1 }) + }, { inject: ['fs', 'sandboxPolicy'] })) + try { + if (service === undefined) throw new Error('plugin body did not run') + const stream = open(service) + const first = stream.next() + await entered.promise + await fiber.dispose() + release.resolve(undefined) + await expect(first).resolves.toEqual({ done: true, value: undefined }) + } finally { + release.resolve(undefined) + await fiber.dispose() + } + }) + + it('refuses an already-aborted signal', async () => { + const controller = new AbortController() + controller.abort() + const stream = open(harness.endpoint(), controller) + await expect(stream.next()).rejects.toThrow() + }) + + it('ends when its signal aborts while idle', async () => { + const stream = open(harness.endpoint()) + await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } }) + const first = stream.next() + // A delivered frame proves the generation is past setup and waiting idle. + await observe(join(harness.workspace, 'a.txt'), present('v1')) + expect((await first).done).toBe(false) + const pending = stream.next() + stream.controller.abort() + expect(await pending).toEqual({ done: true, value: undefined }) + }) + + it('drops queued observations when cancelled after ready but before the next pull', async () => { + const stream = open(harness.endpoint()) + await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } }) + await observe(join(harness.workspace, 'queued-before-abort.txt'), present('v1')) + stream.controller.abort() + await expect(stream.next()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('ends when its signal aborts during setup, without delivering anything', async () => { + const stream = open(harness.endpoint()) + const pending = stream.next() + stream.controller.abort() + await observe(join(harness.workspace, 'a.txt'), present('v1')) + expect(await pending).toEqual({ done: true, value: undefined }) + }) + + it('ends when its signal aborts while the root resolves, resolving under that signal', async () => { + const fs = harness.ctx.fs + const original = fs.resolve.bind(fs) + let release: () => void = () => {} + const gate = new Promise((resolve) => { release = resolve }) + // A backend that checks the signal after its round-trip, as a remote one does. + const spy = vi.spyOn(fs, 'resolve').mockImplementation(async (path, opts) => { + await gate + opts?.signal?.throwIfAborted() + return original(path, opts) + }) + const stream = open(harness.endpoint()) + const pending = stream.next() + stream.controller.abort() + release() + expect(await pending).toEqual({ done: true, value: undefined }) + expect(spy).toHaveBeenCalledWith(expect.any(String), { signal: stream.controller.signal }) + spy.mockRestore() + }) + + it('ends when its signal aborts between the root resolving and the first pull', async () => { + const fs = harness.ctx.fs + const original = fs.resolve.bind(fs) + const controller = new AbortController() + // The abort lands after the backend answered and before the drain installs its listener. + const spy = vi.spyOn(fs, 'resolve').mockImplementation(async (path, opts) => { + const target = await original(path, opts) + controller.abort() + return target + }) + const stream = open(harness.endpoint(), controller) + expect(await stream.next()).toEqual({ done: true, value: undefined }) + spy.mockRestore() + }) + + it('surfaces a root that fails to resolve', async () => { + const spy = vi.spyOn(harness.ctx.fs, 'resolve').mockRejectedValue(new Error('no such root')) + const stream = open(harness.endpoint()) + await expect(stream.next()).rejects.toThrow('no such root') + spy.mockRestore() + }) + + it('stops delivering to a generation the consumer returned from', async () => { + const service = harness.endpoint() + const controller = new AbortController() + const iterator = service.changes(agent, controller.signal)[Symbol.asyncIterator]() + closeStreams.push(async () => { + controller.abort() + await iterator.return?.() + }) + await expect(iterator.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } }) + const first = iterator.next() + const absolutePath = await observe(join(harness.workspace, 'a.txt'), present('v1')) + expect(await first).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v1' } } }) + expect(await iterator.return?.(undefined)).toEqual({ done: true, value: undefined }) + // A later observation reaches no follower: the set is empty again, so the + // second generation opened here is the only one that sees it. + const stream = open(service) + await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } }) + const pending = stream.next() + const later = await observe(join(harness.workspace, 'b.txt'), present('v2')) + expect(await pending).toEqual({ done: false, value: { kind: 'change', change: { absolutePath: later, version: 'v2' } } }) + }) + + it('ends every open generation when the owning fiber is disposed', async () => { + let service: WorkspaceFiles | undefined + const fiber = await harness.ctx.plugin(Object.assign((ctx: Context) => { + service = new WorkspaceFiles(ctx, { maxBytes: 1, maxLines: 1, maxEntries: 1 }) + }, { inject: ['fs', 'sandboxPolicy'] })) + if (service === undefined) throw new Error('plugin body did not run') + const stream = open(service) + await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } }) + const first = stream.next() + await observe(join(harness.workspace, 'a.txt'), present('v0')) + expect((await first).done).toBe(false) + const pending = stream.next() + await fiber.dispose() + expect(await pending).toEqual({ done: true, value: undefined }) + // The listener left with the fiber: a fresh generation on a live service + // proves the root context still observes while the disposed one is silent. + const live = open(harness.endpoint()) + await expect(live.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } }) + const next = live.next() + const absolutePath = await observe(join(harness.workspace, 'a.txt'), present('v1')) + expect(await next).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v1' } } }) + }) +}) diff --git a/packages/api/workspace-files/tests/fake-remote.client.ts b/packages/api/workspace-files/tests/fake-remote.client.ts new file mode 100644 index 0000000000..c34ca31256 --- /dev/null +++ b/packages/api/workspace-files/tests/fake-remote.client.ts @@ -0,0 +1,178 @@ +/** + * The Remote slice, scripted: stats answered by the spec, one push source per + * opened `changes` generation, and a supervisor that runs one generation and + * classifies its end the way the real one does. + */ +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceFileWatchFrame, WorkspaceFileStat } from '../src/types.ts' +import type { SupervisedStream, SupervisedStreamOptions, WorkspaceFilesRemote } from '../src/client/remote.ts' + +/** One scripted Host `changes` generation: frames pushed by the spec, ended by abort. */ +export class Source implements AsyncIterable { + private readonly queue: Array< + { kind: 'value'; value: T; delivered?: () => void } | { kind: 'end' } | { kind: 'fail'; error: unknown } + > = [] + private wake: (() => void) | undefined + aborted = false + + constructor(signal: AbortSignal) { + this.aborted = signal.aborted + signal.addEventListener('abort', () => { + this.aborted = true + this.wake?.() + }, { once: true }) + } + + push(value: T): void { + this.queue.push({ kind: 'value', value }) + this.wake?.() + } + + /** Resolve after the consumer processes this frame and asks for the next one. */ + deliver(value: T): Promise { + return new Promise((resolve) => { + this.queue.push({ kind: 'value', value, delivered: resolve }) + this.wake?.() + }) + } + + end(): void { + this.queue.push({ kind: 'end' }) + this.wake?.() + } + + fail(error: unknown): void { + this.queue.push({ kind: 'fail', error }) + this.wake?.() + } + + async *[Symbol.asyncIterator](): AsyncIterator { + while (true) { + if (this.aborted) return + const next = this.queue.shift() + if (next === undefined) { + await new Promise((resolve) => { this.wake = resolve }) + this.wake = undefined + continue + } + if (next.kind === 'value') { + yield next.value + next.delivered?.() + continue + } + if (next.kind === 'end') return + throw next.error + } + } +} + +/** One `stat` call awaiting the spec's answer. */ +export interface PendingStat { + readonly sessionId: SessionId + readonly path: string + readonly signal: AbortSignal | undefined + resolve(result: RemoteResult): void +} + +/** One opened Host watch whose acknowledgement and changes the spec controls. */ +interface OpenedWatch { + readonly sessionId: SessionId + readonly source: Source +} + +/** The scripted Remote: every stat waits for the spec, every session stream is a {@link Source}. */ +export class FakeRemote implements WorkspaceFilesRemote { + readonly calls: Array<'changes' | 'accept' | 'stat'> = [] + readonly opened: OpenedWatch[] = [] + readonly disposed: string[] = [] + readonly stats: PendingStat[] = [] + private readonly statWaiters = new Map void>>() + private readonly watchWaiters = new Map void>>() + /** False lets a spec keep the Host subscription unacknowledged. */ + autoReady = true + /** When set, every stream dispose waits for it before settling. */ + disposeGate: Promise | undefined + + /** Wait for an indexed stat request without advancing or assuming scheduler timing. */ + waitForStat(index: number): Promise { + const stat = this.stats[index] + if (stat !== undefined) return Promise.resolve(stat) + return new Promise((resolve) => { + const waiters = this.statWaiters.get(index) ?? [] + waiters.push(resolve) + this.statWaiters.set(index, waiters) + }) + } + + /** Wait until the Client calls changes, independently of the Host acknowledgement. */ + waitForChanges(index: number): Promise { + const watch = this.opened[index] + if (watch !== undefined) return Promise.resolve(watch) + return new Promise((resolve) => { + const waiters = this.watchWaiters.get(index) ?? [] + waiters.push(resolve) + this.watchWaiters.set(index, waiters) + }) + } + + $stream(options: SupervisedStreamOptions): SupervisedStream { + const controller = new AbortController() + const disposed = this.disposed + const calls = this.calls + const done = Promise.withResolvers() + return { + async *[Symbol.asyncIterator]() { + try { + let accepted = false + for await (const value of options.open(controller.signal)) { + if (controller.signal.aborted) return + yield { value, accept: () => { accepted = true; calls.push('accept') } } + } + if (controller.signal.aborted) return + throw options.ended(accepted) + } finally { + done.resolve(undefined) + } + }, + dispose: async () => { + disposed.push(options.name) + controller.abort(new Error('disposed')) + await this.disposeGate + await done.promise + }, + } + } + + readonly workspaceFiles = { + stat: (sessionId: SessionId, path: string, signal?: AbortSignal): Promise> => + new Promise((resolve) => { + this.calls.push('stat') + const index = this.stats.length + const stat = { sessionId, path, signal, resolve } + this.stats.push(stat) + for (const waiter of this.statWaiters.get(index) ?? []) waiter(stat) + this.statWaiters.delete(index) + }), + changes: (sessionId: SessionId, signal?: AbortSignal): AsyncIterable => { + this.calls.push('changes') + if (signal === undefined) throw new Error('the feed must hand its signal to the Host stream') + const source = new Source(signal) + const watch = { sessionId, source } + const index = this.opened.length + this.opened.push(watch) + for (const waiter of this.watchWaiters.get(index) ?? []) waiter(watch) + this.watchWaiters.delete(index) + if (this.autoReady) source.push({ kind: 'ready' }) + return source + }, + } +} + +/** Let queued microtasks and background pumps settle. */ +export const settle = (): Promise => new Promise((resolve) => { setTimeout(resolve, 0) }) + +/** The next item, or `'silent'` when none arrives within a tick. */ +export async function peek(it: AsyncIterator): Promise | 'silent'> { + return Promise.race([it.next(), settle().then(() => 'silent' as const)]) +} diff --git a/packages/api/workspace-files/tests/harness.ts b/packages/api/workspace-files/tests/harness.ts new file mode 100644 index 0000000000..2b1a5ece77 --- /dev/null +++ b/packages/api/workspace-files/tests/harness.ts @@ -0,0 +1,94 @@ +/** + * Shared fixture: a real local backend over a temp workspace beside a sibling + * directory outside it, and a sandbox policy whose only job is naming the root. + * + * The real backend, not a mocked `ctx.fs`, because the gates under test are + * only meaningful against a real filesystem: a symlink that leaves the + * workspace, a file whose byte size exceeds the cap, and bytes that are not + * text. A fake provider would let a string-prefix containment check pass this + * file, which is exactly the defect the gate exists to prevent. + */ +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { WorkspaceFiles, type Config } from '../src/index.ts' + +/** The Agent shape the service reads: only its session reaches the policy. */ +export const agent = { id: 'a-test', session: { id: 's-test' } } as unknown as Agent + +export const signal = (): AbortSignal => new AbortController().signal + +/** One temp workspace and the context serving it. */ +export interface Harness { + readonly workspace: string + readonly outside: string + readonly ctx: Context + /** + * The service under test, at the given caps. One per test: the service key is + * global to the Context, so a second call with caps is a defect in the test. + */ + endpoint(caps?: Partial): WorkspaceFiles + dispose(): Promise +} + +/** + * Create the workspace, its outside sibling, and a context with the local + * backend rooted at the workspace. + * @param prefix - temp directory prefix naming the suite. + * @returns the harness; dispose it in `afterEach`. + */ +export async function openWorkspace(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)) + const workspace = join(root, 'workspace') + const outside = join(root, 'outside') + await mkdir(workspace, { recursive: true }) + await mkdir(outside, { recursive: true }) + const ctx = new Context() + const fiber = await ctx.plugin(LocalFileSystem, { cwd: workspace }) + // The policy is the service's only source for the workspace root, so the + // fake supplies exactly that and nothing else. + ctx.provide('sandboxPolicy', { resolve: () => ({ mode: 'workspace-write', workspaceRoot: workspace }) } as never) + let service: WorkspaceFiles | undefined + return { + workspace, + outside, + ctx, + endpoint: (caps) => { + if (service !== undefined) { + if (caps !== undefined) throw new Error('the harness serves one WorkspaceFiles per test; hoist the endpoint') + return service + } + service = new WorkspaceFiles(ctx, { + maxBytes: caps?.maxBytes ?? 1024 * 1024, + maxLines: caps?.maxLines ?? 5000, + maxEntries: caps?.maxEntries ?? 2000, + }) + return service + }, + dispose: async () => { + await fiber.dispose() + await rm(root, { recursive: true, force: true }) + }, + } +} + +/** + * Await an operation expected to fail with a Remote error. + * @param operation - the call under test. + * @returns the Remote failure's code and details. + */ +export async function failureOf(operation: Promise): Promise<{ code: string; details: unknown }> { + try { + await operation + } catch (error: unknown) { + const failure = remoteErrorOf(error) + // A non-Remote throw is a defect in the service, not an expected outcome. + if (failure === undefined) throw error + return { code: failure.code, details: failure.details } + } + throw new Error('expected the operation to fail') +} diff --git a/packages/api/workspace-files/tests/host-path.client.spec.ts b/packages/api/workspace-files/tests/host-path.client.spec.ts new file mode 100644 index 0000000000..306222f6d7 --- /dev/null +++ b/packages/api/workspace-files/tests/host-path.client.spec.ts @@ -0,0 +1,260 @@ +/** Host-resolved file identities across pending stats, retries, reloads, and disposal. */ +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import { describe, expect, it, onTestFinished } from 'vitest' +import { ChangeFeed } from '../src/client/change-feed.ts' +import { createFileResourceProvider, type SessionLookup } from '../src/client/provider.ts' +import { FakeRemote, settle } from './fake-remote.client.ts' + +const SESSION = 'host-only' as SessionId +const RELATIVE = 'linked/a b.txt' +const ADDRESS = sessionFileAddress(SESSION, RELATIVE) +const CANONICAL = '/host/canonical/a b.txt' + +function harness(sessions: SessionLookup = { current: () => SESSION }) { + const remote = new FakeRemote() + const changes = new ChangeFeed(remote) + const provider = createFileResourceProvider(remote, changes, sessions) + const open = (address = ADDRESS) => { + const controller = new AbortController() + const iterator = provider.open(address, { signal: controller.signal })[Symbol.asyncIterator]() + onTestFinished(async () => { + controller.abort() + for (const request of remote.stats) { + request.resolve({ ok: false, error: new RemoteError('gateway/internal', 'test ended', {}) }) + } + await iterator.return?.() + await changes.settle() + }) + return { iterator, controller, reload: () => { provider.reload!(address) } } + } + return { remote, changes, open } +} + +describe('Host-resolved file paths', () => { + it('accepts Host ready before submitting the unmodified relative path without a Client Session summary', async () => { + const { remote, open } = harness({ current: () => { throw new Error('session addresses do not read current') } }) + const { iterator } = open() + const first = iterator.next() + const request = await remote.waitForStat(0) + expect(remote.calls).toEqual(['changes', 'accept', 'stat']) + expect(request).toMatchObject({ sessionId: SESSION, path: RELATIVE }) + request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } }) + await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3, changed: false } } }) + }) + + it('does not stat an opened changes stream until the Host acknowledges its subscription', async () => { + const { remote, open } = harness() + remote.autoReady = false + const { iterator } = open() + const first = iterator.next() + const { source } = await remote.waitForChanges(0) + await settle() + expect(remote.calls).toEqual(['changes']) + expect(remote.stats).toEqual([]) + + await source.deliver({ kind: 'ready' }) + const request = await remote.waitForStat(0) + expect(remote.calls).toEqual(['changes', 'accept', 'stat']) + request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0' } }) + await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0', changed: false } } }) + }) + + it.each(['abort', 'end', 'failure'] as const)('settles %s before Host ready without sending a stat or leaving a stream', async (ending) => { + const { remote, changes, open } = harness() + remote.autoReady = false + const { iterator, controller } = open() + const first = iterator.next() + const { source } = await remote.waitForChanges(0) + switch (ending) { + case 'abort': controller.abort(); break + case 'end': source.end(); break + case 'failure': source.fail(new Error('workspace root unavailable')); break + default: throw new Error(`Unexpected stream ending: ${ending satisfies never}`) + } + await expect(first).resolves.toEqual({ done: true, value: undefined }) + await changes.settle() + expect(remote.calls).toEqual(['changes']) + expect(remote.stats).toEqual([]) + expect(remote.disposed).toEqual([`workspace file changes of ${SESSION}`]) + expect(source.aborted).toBe(true) + }) + + it('accepts another generation ready without resetting metadata or re-statting existing files', async () => { + const { remote, open } = harness() + const { iterator } = open() + const first = iterator.next() + const request = await remote.waitForStat(0) + request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } }) + await first + const next = iterator.next() + const source = remote.opened[0]!.source + await source.deliver({ kind: 'ready' }) + await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } }) + expect(remote.stats).toHaveLength(1) + expect(remote.calls).toEqual(['changes', 'accept', 'stat', 'accept']) + await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3, changed: true } } }) + }) + + it.each([ + ['session', ADDRESS, RELATIVE], + ['absolute', absoluteFileAddress('/shortcut/a.txt'), '/shortcut/a.txt'], + ])('filters queued and live %s changes using the Host canonical path, not the input path', async (_, address, path) => { + const { remote, open } = harness() + const { iterator } = open(address) + const first = iterator.next() + const request = await remote.waitForStat(0) + expect(request).toMatchObject({ sessionId: SESSION, path }) + const source = remote.opened[0]!.source + // Delivery is acknowledged while stat is still unresolved, before a key can be bound. + await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other-before-stat' } }) + await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } }) + request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } }) + await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0', changed: false } } }) + await expect(iterator.next()).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3, changed: true } } }) + + await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other-after-stat' } }) + await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v2' } }) + await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2', changed: true } } }) + expect(remote.stats).toHaveLength(1) + }) + + it.each(['reload', 'write'] as const)('recovers an initial failed stat through %s and filters the retry backlog after binding', async (trigger) => { + const { remote, open } = harness() + const { iterator, reload } = open() + const first = iterator.next() + const request = await remote.waitForStat(0) + const error = new RemoteError('workspace-file/not-found', 'missing', { path: RELATIVE }) + request.resolve({ ok: false, error }) + await expect(first).resolves.toEqual({ done: false, value: { ok: false, error } }) + const source = remote.opened[0]!.source + const retried = iterator.next() + if (trigger === 'reload') reload() + else await source.deliver({ kind: 'change', change: { absolutePath: '/unknown-key-trigger.txt', version: 'trigger' } }) + const retry = await remote.waitForStat(1) + expect(retry).toMatchObject({ sessionId: SESSION, path: RELATIVE }) + await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other' } }) + await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v3' } }) + retry.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v2', bytes: 5 } }) + await expect(retried).resolves.toEqual({ + done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v2', bytes: 5, changed: trigger === 'write' } }, + }) + await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v3', changed: true } } }) + expect(remote.stats).toHaveLength(2) + }) + + it('reloads session and absolute addresses bound to the same Host key without re-statting other files', async () => { + const { remote, open } = harness() + const session = open() + const absolute = open(absoluteFileAddress('/shortcut/a.txt')) + const other = open(sessionFileAddress(SESSION, 'other.txt')) + const firsts = Promise.all([session.iterator.next(), absolute.iterator.next(), other.iterator.next()]) + await remote.waitForStat(2) + for (const request of remote.stats) { + request.resolve({ ok: true, value: { + absolutePath: request.path === 'other.txt' ? '/host/other.txt' : CANONICAL, + version: 'v0', bytes: 3, + } }) + } + await firsts + const reloaded = Promise.all([session.iterator.next(), absolute.iterator.next()]) + const otherChange = other.iterator.next() + session.reload() + await remote.waitForStat(4) + expect(remote.stats.slice(3).map(request => request.path)).toEqual([RELATIVE, '/shortcut/a.txt']) + for (const request of remote.stats.slice(3)) { + request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 4 } }) + } + const value = { done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 4, changed: false } } } + await expect(reloaded).resolves.toEqual([value, value]) + await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: '/host/other.txt', version: 'other-v1' } }) + await expect(otherChange).resolves.toMatchObject({ value: { ok: true, value: { version: 'other-v1', changed: true } } }) + expect(remote.stats).toHaveLength(5) + expect(remote.opened).toHaveLength(1) + }) + + it('binds a changed canonical path on reload before filtering writes received during that stat', async () => { + const { remote, open } = harness() + const { iterator, reload } = open() + const first = iterator.next() + const request = await remote.waitForStat(0) + request.resolve({ ok: true, value: { absolutePath: '/host/old-target.txt', version: 'v0' } }) + await first + reload() + const reloaded = iterator.next() + const retry = await remote.waitForStat(1) + const source = remote.opened[0]!.source + await source.deliver({ kind: 'change', change: { absolutePath: '/host/old-target.txt', version: 'old-target-write' } }) + await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v2' } }) + retry.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v1' } }) + await expect(reloaded).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: false } } }) + await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2', changed: true } } }) + expect(remote.stats).toHaveLength(2) + }) + + it('drops a late stat after abort and waits for the session stream disposal', async () => { + const { remote, changes, open } = harness() + const { iterator, controller } = open() + const first = iterator.next() + const request = await remote.waitForStat(0) + await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } }) + const gate = Promise.withResolvers() + remote.disposeGate = gate.promise + try { + controller.abort() + request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0' } }) + await expect(first).resolves.toEqual({ done: true, value: undefined }) + expect(remote.opened[0]!.source.aborted).toBe(true) + let settled = false + const closing = changes.settle().then(() => { settled = true }) + await settle() + expect(settled).toBe(false) + gate.resolve(undefined) + await closing + expect(settled).toBe(true) + expect(remote.disposed).toEqual([`workspace file changes of ${SESSION}`]) + } finally { + gate.resolve(undefined) + } + }) + + it('aborts while waiting for a predecessor to close without sending a stat', async () => { + const { remote, changes, open } = harness() + const previous = open() + const first = previous.iterator.next() + const request = await remote.waitForStat(0) + request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0' } }) + await first + const gate = Promise.withResolvers() + remote.disposeGate = gate.promise + try { + previous.controller.abort() + const next = open() + const pending = next.iterator.next() + next.controller.abort() + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + expect(remote.stats).toHaveLength(1) + gate.resolve(undefined) + await changes.settle() + expect(remote.disposed).toHaveLength(2) + } finally { + gate.resolve(undefined) + } + }) + + it.each([true, false])('releases an unconsumed notification follower after the first stat (success: %s)', async (ok) => { + const { remote, changes, open } = harness() + const { iterator } = open() + const first = iterator.next() + const request = await remote.waitForStat(0) + request.resolve(ok + ? { ok: true, value: { absolutePath: CANONICAL, version: 'v0' } } + : { ok: false, error: new RemoteError('workspace-file/not-found', 'missing', { path: RELATIVE }) }) + await first + await iterator.return?.() + await changes.settle() + expect(remote.opened[0]!.source.aborted).toBe(true) + expect(remote.disposed).toEqual([`workspace file changes of ${SESSION}`]) + }) +}) diff --git a/packages/api/workspace-files/tests/list.spec.ts b/packages/api/workspace-files/tests/list.spec.ts new file mode 100644 index 0000000000..cb6fc4538f --- /dev/null +++ b/packages/api/workspace-files/tests/list.spec.ts @@ -0,0 +1,119 @@ +/** The `list` endpoint: the same containment gates as `read`, plus the entry cap. */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, symlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts' + +let harness: Harness +let workspace: string +let outside: string + +beforeEach(async () => { + harness = await openWorkspace('dsh-workspace-files-list-') + workspace = harness.workspace + outside = harness.outside +}) + +afterEach(async () => { + await harness.dispose() +}) + +const endpoint = (caps?: { maxEntries?: number }): ReturnType => harness.endpoint(caps) + +describe('workspaceFiles.list — the happy path', () => { + it('lists the root as the empty workspace path, with types and file sizes', async () => { + await mkdir(join(workspace, 'src')) + await writeFile(join(workspace, 'notes.txt'), 'hello', 'utf8') + await writeFile(join(workspace, '.hidden'), '', 'utf8') + const listing = await endpoint().list(agent, '.', signal()) + expect(listing.path).toBe('') + expect(listing.truncated).toBe(false) + expect(listing.entries).toEqual([ + { name: '.hidden', type: 'file', size: 0 }, + { name: 'notes.txt', type: 'file', size: 5 }, + { name: 'src', type: 'directory' }, + ]) + }) + + it('accepts the absolute workspace root and reports the same empty path', async () => { + const listing = await endpoint().list(agent, workspace, signal()) + expect(listing.path).toBe('') + expect(listing.entries).toEqual([]) + }) + + it('reports a nested directory as its `/`-joined path relative to the root, decoded', async () => { + await mkdir(join(workspace, 'src', 'my dir', '子目录'), { recursive: true }) + await writeFile(join(workspace, 'src', 'my dir', '子目录', 'a.ts'), '', 'utf8') + const listing = await endpoint().list(agent, 'src/my dir/子目录', signal()) + expect(listing.path).toBe('src/my dir/子目录') + expect(listing.entries.map(entry => entry.name)).toEqual(['a.ts']) + }) + + it('reports a symlink child as what it points to, and a dangling one as other', async () => { + await writeFile(join(workspace, 'real.txt'), 'x', 'utf8') + await mkdir(join(workspace, 'dir')) + await symlink(join(workspace, 'real.txt'), join(workspace, 'to-file')) + await symlink(join(workspace, 'dir'), join(workspace, 'to-dir')) + await symlink(join(workspace, 'missing'), join(workspace, 'dangling')) + const listing = await endpoint().list(agent, '.', signal()) + expect(listing.entries).toEqual([ + { name: 'dangling', type: 'other' }, + { name: 'dir', type: 'directory' }, + { name: 'real.txt', type: 'file', size: 1 }, + { name: 'to-dir', type: 'directory' }, + { name: 'to-file', type: 'file', size: 1 }, + ]) + }) +}) + +describe('workspaceFiles.list — the entry cap', () => { + it('cuts at the cap in name order and says so', async () => { + for (const name of ['a', 'b', 'c', 'd', 'e']) await writeFile(join(workspace, name), '', 'utf8') + const listing = await endpoint({ maxEntries: 2 }).list(agent, '.', signal()) + expect(listing.entries.map(entry => entry.name)).toEqual(['a', 'b']) + expect(listing.truncated).toBe(true) + }) + + it('does not report a cut at exactly the cap', async () => { + for (const name of ['a', 'b']) await writeFile(join(workspace, name), '', 'utf8') + const listing = await endpoint({ maxEntries: 2 }).list(agent, '.', signal()) + expect(listing.entries).toHaveLength(2) + expect(listing.truncated).toBe(false) + }) +}) + +describe('workspaceFiles.list — gates', () => { + it('rejects an absolute directory outside the workspace', async () => { + const failure = await failureOf(endpoint().list(agent, outside, signal())) + expect(failure.code).toBe('workspace-file/outside-workspace') + }) + + it('rejects a traversal that climbs out of the workspace', async () => { + const failure = await failureOf(endpoint().list(agent, '..', signal())) + expect(failure.code).toBe('workspace-file/outside-workspace') + }) + + it('rejects a symlinked directory before following it, wherever it points', async () => { + await symlink(outside, join(workspace, 'escape')) + const failure = await failureOf(endpoint().list(agent, 'escape', signal())) + expect(failure.code).toBe('workspace-file/not-directory') + expect(failure.details).toMatchObject({ kind: 'symlink' }) + }) + + it('rejects a file, which has no children to list', async () => { + await writeFile(join(workspace, 'notes.txt'), 'hello', 'utf8') + const failure = await failureOf(endpoint().list(agent, 'notes.txt', signal())) + expect(failure.code).toBe('workspace-file/not-directory') + expect(failure.details).toMatchObject({ path: 'notes.txt', kind: 'file' }) + }) + + it('reports a missing path as not found', async () => { + const failure = await failureOf(endpoint().list(agent, 'nope', signal())) + expect(failure.code).toBe('workspace-file/not-found') + }) + + it('refuses an empty path as a bad request', async () => { + const failure = await failureOf(endpoint().list(agent, '', signal())) + expect(failure.code).toBe('gateway/bad-request') + }) +}) diff --git a/packages/api/workspace-files/tests/provider.client.spec.ts b/packages/api/workspace-files/tests/provider.client.spec.ts new file mode 100644 index 0000000000..539a3888af --- /dev/null +++ b/packages/api/workspace-files/tests/provider.client.spec.ts @@ -0,0 +1,363 @@ +/** + * The `file` provider's frame stream: how the two address scopes resolve to a + * Host call and a change-feed key, the opening stat, the write flag that + * carries no content, the reload and disappearance that stat again, failures + * as frames, and the life bounded by the signal. + */ +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import type { WorkspaceFileStat } from '../src/types.ts' +import { describe, expect, it, onTestFinished } from 'vitest' +import { ChangeFeed } from '../src/client/change-feed.ts' +import { createFileResourceProvider } from '../src/client/provider.ts' +import type { SessionLookup } from '../src/client/provider.ts' +import { FakeRemote, peek, settle } from './fake-remote.client.ts' + +const S1 = 's1' as SessionId +const S2 = 's2' as SessionId +/** The relative path the address carries, and the absolute path the Host's frames spell for it under S1's root. */ +const REL_PATH = 'a b.txt' +const HOST_PATH = '/w/a b.txt' +const ADDRESS = sessionFileAddress(S1, REL_PATH) +/** A file outside every workspace root, addressed absolutely. */ +const ABS_PATH = '/etc/hosts' +const ABS_ADDRESS = absoluteFileAddress(ABS_PATH) + +/** Only the current Session is available to the provider; there are no Client roots. */ +function sessionsWith(current: SessionId | undefined): SessionLookup { + return { current: () => current } +} + +const stat = (version: string, bytes: number): WorkspaceFileStat => ({ absolutePath: HOST_PATH, version, bytes }) +const notFound = (): RemoteFailure => new RemoteError('workspace-file/not-found', 'no such file', { path: REL_PATH }) + +function opened(address = ADDRESS, sessions = sessionsWith(S1)) { + const remote = new FakeRemote() + const changes = new ChangeFeed(remote) + const provider = createFileResourceProvider(remote, changes, sessions) + const controller = new AbortController() + const it = provider.open(address, { signal: controller.signal })[Symbol.asyncIterator]() + const reload = (): void => { provider.reload!(address) } + onTestFinished(async () => { + controller.abort() + for (const request of remote.stats) { + request.resolve({ ok: false, error: new RemoteError('gateway/internal', 'test ended', {}) }) + } + await it.return?.() + await changes.settle() + }) + return { remote, provider, changes, controller, it, reload } +} + +/** Open, answer the opening stat, and hand back the bench once the first frame is out. */ +async function live(version = 'v0', bytes = 3) { + const bench = opened() + const first = bench.it.next() + await settle() + bench.remote.stats[0]!.resolve({ ok: true, value: stat(version, bytes) }) + await first + return bench +} + +describe('file provider — the address', () => { + it.each([ + ['another scope', 'dsh-resource://file/shared/x/w/a.txt'], + ['no path', 'dsh-resource://file/session/s1'], + ['an absolute address with no path', 'dsh-resource://file/absolute/'], + ['another resource type', 'dsh-resource://terminal/session/s1/1'], + ['the retired file:// grammar', 'file://sessions/s1/w/a.txt'], + ['a bare file URL', 'file:///w/a.txt'], + ['another protocol', 'sidebar:guide'], + ])('yields one unsupported-address failure and ends for %s, touching no Remote', async (_, address) => { + const { remote, it } = opened(address) + const first = await it.next() + expect(first.done).toBe(false) + expect(first.value).toMatchObject({ ok: false, error: { code: 'workspace-file/unsupported-address', details: { address } } }) + await expect(it.next()).resolves.toEqual({ done: true, value: undefined }) + expect(remote.stats).toEqual([]) + expect(remote.opened).toEqual([]) + }) + + it('rejects an absolute address with no current Session without touching the Remote', async () => { + const address = ABS_ADDRESS + const { remote, it } = opened(address, sessionsWith(undefined)) + const first = await it.next() + expect(first.done).toBe(false) + expect(first.value).toMatchObject({ ok: false, error: { code: 'workspace-file/unknown-workspace', details: { address } } }) + await expect(it.next()).resolves.toEqual({ done: true, value: undefined }) + expect(remote.stats).toEqual([]) + expect(remote.opened).toEqual([]) + }) + + it('reloads nothing for an address it does not resolve', async () => { + for (const bench of [opened('dsh-resource://file/shared/x/w/a.txt'), opened(ABS_ADDRESS, sessionsWith(undefined))]) { + bench.reload() + await settle() + expect(bench.remote.stats).toEqual([]) + } + }) + + it('hands the Host a session address\'s relative path and follows the stat absolute path', async () => { + const { remote, it } = opened() + const first = it.next() + await settle() + expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: REL_PATH }) + remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) }) + await first + // The Host's frame names the file absolutely; the follower keyed by the resolved path receives it. + remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) + await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) + }) + + it('reads an absolute address through the Session on screen, with the absolute path as both Host path and follow key', async () => { + const { remote, it, reload } = opened(ABS_ADDRESS) + const first = it.next() + await settle() + expect(remote.opened.map(o => o.sessionId)).toEqual([S1]) + expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: ABS_PATH }) + remote.stats[0]!.resolve({ ok: true, value: { absolutePath: ABS_PATH, version: 'v0', bytes: 3 } }) + await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: ABS_PATH, version: 'v0', bytes: 3, changed: false } } }) + remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: ABS_PATH, version: 'v1' } }) + await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) + // A reload stats the same absolute path again. + reload() + const next = it.next() + await settle() + expect(remote.stats[1]).toMatchObject({ sessionId: S1, path: ABS_PATH }) + remote.stats[1]!.resolve({ ok: true, value: { absolutePath: ABS_PATH, version: 'v1', bytes: 4 } }) + await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: ABS_PATH, version: 'v1', bytes: 4, changed: false } } }) + }) + + it('opens one change stream per session named by the addresses', async () => { + const remote = new FakeRemote() + const provider = createFileResourceProvider(remote, new ChangeFeed(remote), sessionsWith(S1)) + const signal = new AbortController().signal + void provider.open(sessionFileAddress(S1, 'a.txt'), { signal })[Symbol.asyncIterator]().next() + void provider.open(sessionFileAddress(S2, 'a.txt'), { signal })[Symbol.asyncIterator]().next() + await settle() + expect(remote.opened.map(o => o.sessionId)).toEqual([S1, S2]) + expect(remote.stats.map(pending => pending.sessionId)).toEqual([S1, S2]) + }) +}) + +describe('file provider — the opening stat', () => { + it('stats the decoded relative path in the session the address names and yields its metadata unflagged', async () => { + const { remote, it, controller } = opened() + const first = it.next() + await settle() + expect(remote.stats).toHaveLength(1) + expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: REL_PATH, signal: controller.signal }) + remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) }) + await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0', bytes: 3, changed: false } } }) + }) + + it('omits bytes when the backend reports none', async () => { + const { remote, it } = opened() + const first = it.next() + await settle() + remote.stats[0]!.resolve({ ok: true, value: { absolutePath: HOST_PATH, version: 'v0' } }) + await expect(first).resolves.toStrictEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0', changed: false } } }) + }) + + it('yields the Host failure as a frame and keeps following the address', async () => { + const { remote, it } = opened() + const first = it.next() + await settle() + const error = notFound() + remote.stats[0]!.resolve({ ok: false, error }) + await expect(first).resolves.toEqual({ done: false, value: { ok: false, error } }) + const source = remote.opened[0]!.source + // Still gone: no stat, no frame; the pull stays open for what comes next. + source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } }) + const pending = it.next() + await expect(Promise.race([pending, settle().then(() => 'silent' as const)])).resolves.toBe('silent') + expect(remote.stats).toHaveLength(1) + // The agent creates the file: the write stats again and the value goes live, flagged as changed. + source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) + await settle() + remote.stats[1]!.resolve({ ok: true, value: stat('v1', 5) }) + await expect(pending).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 5, changed: true } } }) + }) + + it('lets a reload retry a failed opening stat', async () => { + const { remote, it, reload } = opened() + const first = it.next() + await settle() + remote.stats[0]!.resolve({ ok: false, error: notFound() }) + await first + reload() + const next = it.next() + await settle() + remote.stats[1]!.resolve({ ok: true, value: stat('v0', 3) }) + await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0', bytes: 3, changed: false } } }) + }) + + it('ends without a frame when aborted during the stat', async () => { + const { remote, it, controller } = opened() + const first = it.next() + await settle() + controller.abort() + remote.stats[0]!.resolve({ ok: false, error: new RemoteError('gateway/internal', 'aborted', {}) }) + await expect(first).resolves.toEqual({ done: true, value: undefined }) + }) + + it('shares one change stream between two files of a session', async () => { + const remote = new FakeRemote() + const provider = createFileResourceProvider(remote, new ChangeFeed(remote), sessionsWith(S1)) + const signal = new AbortController().signal + void provider.open(sessionFileAddress(S1, 'a.txt'), { signal })[Symbol.asyncIterator]().next() + void provider.open(sessionFileAddress(S1, 'b.txt'), { signal })[Symbol.asyncIterator]().next() + await settle() + expect(remote.opened).toHaveLength(1) + expect(remote.stats).toHaveLength(2) + }) +}) + +describe('file provider — Host writes', () => { + it('flags a write with its version and keeps the byte count', async () => { + const { remote, it } = await live() + remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) + await expect(it.next()).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 3, changed: true } } }) + expect(remote.stats).toHaveLength(1) + }) + + it('ignores a frame carrying the version it already holds', async () => { + const { remote, it } = await live('v0') + const source = remote.opened[0]!.source + source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v0' } }) + // The pull outlives the silent tick: the frame that finally answers it is v1. + const pending = it.next() + await expect(Promise.race([pending, settle().then(() => 'silent' as const)])).resolves.toBe('silent') + source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) + await expect(pending).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) + }) + + it('does not lose a write reported during the opening stat', async () => { + const { remote, it } = opened() + const first = it.next() + await settle() + remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) + await settle() + remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) }) + await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0', changed: false } } }) + await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) + }) +}) + +describe('file provider — a reported disappearance', () => { + it('stats again and, when the file is still there, yields its fresh metadata flagged', async () => { + const { remote, it } = await live('v0', 3) + remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } }) + const next = it.next() + await settle() + expect(remote.stats).toHaveLength(2) + remote.stats[1]!.resolve({ ok: true, value: stat('v2', 9) }) + await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v2', bytes: 9, changed: true } } }) + }) + + it('yields the not-found frame and keeps following, so a later write stats again and brings the file back flagged', async () => { + const { remote, it } = await live('v0', 3) + const source = remote.opened[0]!.source + source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } }) + const next = it.next() + await settle() + const error = notFound() + remote.stats[1]!.resolve({ ok: false, error }) + await expect(next).resolves.toEqual({ done: false, value: { ok: false, error } }) + source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v3' } }) + const back = it.next() + await settle() + remote.stats[2]!.resolve({ ok: true, value: stat('v3', 8) }) + await expect(back).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v3', bytes: 8, changed: true } } }) + }) +}) + +describe('file provider — reload', () => { + it('stats again and clears the flag', async () => { + const { remote, it, reload } = await live('v0', 3) + remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) + await it.next() + reload() + const next = it.next() + await settle() + expect(remote.stats).toHaveLength(2) + expect(remote.stats[1]).toMatchObject({ sessionId: S1, path: REL_PATH }) + remote.stats[1]!.resolve({ ok: true, value: stat('v1', 7) }) + await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 7, changed: false } } }) + }) + + it('yields a failed stat as a frame and keeps the stream open', async () => { + const { remote, it, reload } = await live() + reload() + const next = it.next() + await settle() + const error = notFound() + remote.stats[1]!.resolve({ ok: false, error }) + await expect(next).resolves.toEqual({ done: false, value: { ok: false, error } }) + await expect(peek(it)).resolves.toBe('silent') + }) + + it('ends quietly when aborted during the stat', async () => { + const { remote, it, reload, controller } = await live() + reload() + const next = it.next() + await settle() + controller.abort() + remote.stats[1]!.resolve({ ok: false, error: new RemoteError('gateway/internal', 'aborted', {}) }) + await expect(next).resolves.toEqual({ done: true, value: undefined }) + }) + + it('re-stats every record of the path on one record\'s reload: a session record and an absolute record of one file share it', async () => { + const remote = new FakeRemote() + const provider = createFileResourceProvider(remote, new ChangeFeed(remote), sessionsWith(S1)) + const signal = new AbortController().signal + const session = provider.open(ADDRESS, { signal })[Symbol.asyncIterator]() + const absolute = provider.open(absoluteFileAddress(HOST_PATH), { signal })[Symbol.asyncIterator]() + const firsts = Promise.all([session.next(), absolute.next()]) + await settle() + expect(remote.stats.map(pending => [pending.sessionId, pending.path])).toEqual([[S1, REL_PATH], [S1, HOST_PATH]]) + remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) }) + remote.stats[1]!.resolve({ ok: true, value: stat('v0', 3) }) + await firsts + expect(remote.opened).toHaveLength(1) + // One Host write flags both records: they follow one path. + remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) + await expect(session.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) + await expect(absolute.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) + // A reload on the session record re-stats both and clears both flags: + // delivery is per path, not per record. + provider.reload!(ADDRESS) + const nexts = Promise.all([session.next(), absolute.next()]) + await settle() + expect(remote.stats.slice(2).map(pending => pending.path)).toEqual([REL_PATH, HOST_PATH]) + remote.stats[2]!.resolve({ ok: true, value: stat('v1', 3) }) + remote.stats[3]!.resolve({ ok: true, value: stat('v1', 3) }) + const cleared = { done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 3, changed: false } } } + await expect(nexts).resolves.toEqual([cleared, cleared]) + }) + + it('is a no-op for a file nobody has open', async () => { + const { remote, reload } = opened() + reload() + await settle() + expect(remote.stats).toHaveLength(0) + }) +}) + +describe('file provider — the end', () => { + it('ends when its signal aborts and releases the session stream', async () => { + const { remote, it, controller } = await live() + controller.abort() + await expect(it.next()).resolves.toEqual({ done: true, value: undefined }) + await settle() + expect(remote.disposed).toEqual(['workspace file changes of s1']) + }) + + it('ends when the Host closes the session stream', async () => { + const { remote, it } = await live() + remote.opened[0]!.source.end() + await expect(it.next()).resolves.toEqual({ done: true, value: undefined }) + }) +}) diff --git a/packages/api/workspace-files/tests/read-bytes.spec.ts b/packages/api/workspace-files/tests/read-bytes.spec.ts new file mode 100644 index 0000000000..8785349795 --- /dev/null +++ b/packages/api/workspace-files/tests/read-bytes.spec.ts @@ -0,0 +1,146 @@ +/** The `readBytes` endpoint: the byte window it cuts, its defaults and cap, and the gates it shares with `read`. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { FsVersion } from '@deepseek-ai/dsh-fs' +import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts' + +let harness: Harness +let workspace: string + +beforeEach(async () => { + harness = await openWorkspace('dsh-workspace-files-read-bytes-') + workspace = harness.workspace +}) + +afterEach(async () => { + await harness.dispose() +}) + +const endpoint = (caps?: { maxBytes?: number }): ReturnType => harness.endpoint(caps) + +/** 256 bytes, each equal to its offset: the window's content names its position. */ +const RAMP = Buffer.from(Array.from({ length: 256 }, (_, i) => i)) + +const decode = (data: string): Buffer => Buffer.from(data, 'base64') + +describe('workspaceFiles.readBytes — the window', () => { + it('returns the whole file as one window by default, with its absolute path, version, and size', async () => { + await writeFile(join(workspace, 'ramp.bin'), RAMP) + const result = await endpoint().readBytes(agent, 'ramp.bin', {}, signal()) + expect(decode(result.data).equals(RAMP)).toBe(true) + expect(result).toMatchObject({ offset: 0, eof: true, bytes: 256 }) + expect(result.absolutePath.endsWith('ramp.bin')).toBe(true) + expect(result.version.length).toBeGreaterThan(0) + }) + + it('cuts the requested window and reports that more follows', async () => { + await writeFile(join(workspace, 'ramp.bin'), RAMP) + const result = await endpoint().readBytes(agent, 'ramp.bin', { offset: 16, length: 8 }, signal()) + expect([...decode(result.data)]).toEqual([16, 17, 18, 19, 20, 21, 22, 23]) + expect(result).toMatchObject({ offset: 16, eof: false, bytes: 256 }) + }) + + it('reads a window of a file far above the byte cap', async () => { + await writeFile(join(workspace, 'huge.bin'), Buffer.alloc(200_000, 7)) + const result = await endpoint({ maxBytes: 1024 }).readBytes(agent, 'huge.bin', { offset: 199_000, length: 1024 }, signal()) + expect(decode(result.data)).toHaveLength(1000) + expect(result).toMatchObject({ eof: true, bytes: 200_000 }) + }) + + it('infers eof from a short window when the backend reports no size', async () => { + await writeFile(join(workspace, 'ramp.bin'), RAMP) + vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ version: FsVersion('v-sizeless'), type: 'file' }) + const full = await endpoint().readBytes(agent, 'ramp.bin', { offset: 0, length: 256 }, signal()) + expect(full.eof).toBe(false) + const short = await endpoint().readBytes(agent, 'ramp.bin', { offset: 250, length: 10 }, signal()) + expect(short).toMatchObject({ eof: true }) + expect(short.bytes).toBeUndefined() + }) + + it('reports eof on the window that holds the last byte, whether or not the length is reached', async () => { + await writeFile(join(workspace, 'ramp.bin'), RAMP) + const exact = await endpoint().readBytes(agent, 'ramp.bin', { offset: 248, length: 8 }, signal()) + expect(exact.eof).toBe(true) + expect(decode(exact.data)).toHaveLength(8) + const short = await endpoint().readBytes(agent, 'ramp.bin', { offset: 250, length: 100 }, signal()) + expect(short.eof).toBe(true) + expect([...decode(short.data)]).toEqual([250, 251, 252, 253, 254, 255]) + }) + + it('returns an empty eof window for an offset at or past the end', async () => { + await writeFile(join(workspace, 'ramp.bin'), RAMP) + const result = await endpoint().readBytes(agent, 'ramp.bin', { offset: 300, length: 8 }, signal()) + expect(result).toMatchObject({ data: '', offset: 300, eof: true, bytes: 256 }) + }) + + it('returns an empty eof window for an empty file', async () => { + await writeFile(join(workspace, 'empty.bin'), Buffer.alloc(0)) + const result = await endpoint().readBytes(agent, 'empty.bin', {}, signal()) + expect(result).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 }) + }) + + it('carries bytes a text read would refuse: NUL and invalid UTF-8 round-trip through base64', async () => { + const raw = Buffer.from([0, 0xff, 0xfe, 0x80, 0x41, 0]) + await writeFile(join(workspace, 'blob.bin'), raw) + const result = await endpoint().readBytes(agent, 'blob.bin', {}, signal()) + expect(decode(result.data).equals(raw)).toBe(true) + }) + + it('names the version a stat of the same file reports', async () => { + await writeFile(join(workspace, 'ramp.bin'), RAMP) + const stat = await endpoint().stat(agent, 'ramp.bin', signal()) + const result = await endpoint().readBytes(agent, 'ramp.bin', {}, signal()) + expect(result.version).toBe(stat.version) + }) +}) + +describe('workspaceFiles.readBytes — defaults and cap', () => { + it('defaults the length to the configured byte cap', async () => { + await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64)) + const result = await endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', {}, signal()) + expect(decode(result.data)).toHaveLength(64) + expect(result.eof).toBe(true) + }) + + it('refuses a window longer than the cap as too-large rather than shortening it', async () => { + await writeFile(join(workspace, 'ramp.bin'), RAMP) + const failure = await failureOf(endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', { length: 65 }, signal())) + expect(failure.code).toBe('workspace-file/too-large') + expect(failure.details).toMatchObject({ limit: 64 }) + }) + + it('accepts a window exactly at the cap', async () => { + await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64)) + const result = await endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', { length: 64 }, signal()) + expect(decode(result.data)).toHaveLength(64) + }) + + it('refuses a negative or fractional offset and a non-positive length as bad requests', async () => { + await writeFile(join(workspace, 'ramp.bin'), RAMP) + // Beyond-safe integers and a window whose end overflows are refused too: they cannot index a file. + const ranges = [ + { offset: -1 }, { offset: 1.5 }, { length: 0 }, { length: 2.5 }, + { offset: 2 ** 53 }, { offset: Number.MAX_SAFE_INTEGER, length: 2 }, + ] + for (const range of ranges) { + const failure = await failureOf(endpoint().readBytes(agent, 'ramp.bin', range, signal())) + expect(failure.code).toBe('gateway/bad-request') + } + }) +}) + +describe('workspaceFiles.readBytes — the gates it shares with read', () => { + it('rejects a directory, a missing path, and an empty path', async () => { + await mkdir(join(workspace, 'dir')) + expect((await failureOf(endpoint().readBytes(agent, 'dir', {}, signal()))).code).toBe('workspace-file/not-regular-file') + expect((await failureOf(endpoint().readBytes(agent, 'missing.bin', {}, signal()))).code).toBe('workspace-file/not-found') + expect((await failureOf(endpoint().readBytes(agent, '', {}, signal()))).code).toBe('gateway/bad-request') + }) + + it('rejects an absolute path outside the workspace', async () => { + await writeFile(join(harness.outside, 'secret.bin'), RAMP) + const failure = await failureOf(endpoint().readBytes(agent, join(harness.outside, 'secret.bin'), {}, signal())) + expect(failure.code).toBe('workspace-file/outside-workspace') + }) +}) diff --git a/packages/api/workspace-files/tests/read.spec.ts b/packages/api/workspace-files/tests/read.spec.ts new file mode 100644 index 0000000000..2ee3fa95b3 --- /dev/null +++ b/packages/api/workspace-files/tests/read.spec.ts @@ -0,0 +1,264 @@ +/** The `read` endpoint: its four gates and the line window it cuts. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, rm, symlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { FsError } from '@deepseek-ai/dsh-fs' +import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts' + +let harness: Harness +let workspace: string +let outside: string + +beforeEach(async () => { + harness = await openWorkspace('dsh-workspace-files-read-') + workspace = harness.workspace + outside = harness.outside +}) + +afterEach(async () => { + await harness.dispose() +}) + +const endpoint = (caps?: { maxBytes?: number; maxLines?: number }): ReturnType => + harness.endpoint(caps) + +/** Twenty lines, `line 1` through `line 20`, terminated by a final newline. */ +async function twentyLines(): Promise { + await writeFile(join(workspace, 'long.txt'), `${Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join('\n')}\n`, 'utf8') +} + +/** A file whose second line carries a NUL byte past the backend's 8 KiB binary sample. */ +async function lateNul(): Promise { + await writeFile(join(workspace, 'late-nul.txt'), Buffer.concat([ + Buffer.from(`${'a'.repeat(9000)}\nb`, 'utf8'), + Buffer.from([0]), + Buffer.from('c\n', 'utf8'), + ])) +} + +describe('workspaceFiles.read — the happy path', () => { + it('returns the whole file as one page with its absolute path, version, and byte size', async () => { + await writeFile(join(workspace, 'notes.txt'), 'hello\nworld\n', 'utf8') + const result = await endpoint().read(agent, 'notes.txt', {}, signal()) + expect(result.text).toBe('hello\nworld') + expect(result.offset).toBe(1) + expect(result.lines).toBe(2) + expect(result.eof).toBe(true) + expect(result.bytes).toBe(12) + expect(result.version.length).toBeGreaterThan(0) + expect(result.absolutePath.endsWith('notes.txt')).toBe(true) + }) + + it('reads a nested path relative to the workspace root, not to any backend cwd', async () => { + await mkdir(join(workspace, 'src', 'deep'), { recursive: true }) + await writeFile(join(workspace, 'src', 'deep', 'a.ts'), 'export {}\n', 'utf8') + const result = await endpoint().read(agent, 'src/deep/a.ts', {}, signal()) + expect(result.text).toBe('export {}') + }) + + it('returns an empty page for an empty file', async () => { + await writeFile(join(workspace, 'empty.txt'), '', 'utf8') + const result = await endpoint().read(agent, 'empty.txt', {}, signal()) + expect(result).toMatchObject({ text: '', lines: 0, eof: true, bytes: 0 }) + }) + + it('accepts multi-byte UTF-8 and counts the file bytes, not its characters', async () => { + await writeFile(join(workspace, 'zh.txt'), '侧栏', 'utf8') + const result = await endpoint().read(agent, 'zh.txt', {}, signal()) + expect(result.text).toBe('侧栏') + expect(result.bytes).toBe(6) + }) +}) + +describe('workspaceFiles.read — the line window', () => { + it('cuts the requested lines and reports that more follow', async () => { + await twentyLines() + const result = await endpoint().read(agent, 'long.txt', { offset: 6, limit: 3 }, signal()) + expect(result).toMatchObject({ offset: 6, text: 'line 6\nline 7\nline 8', lines: 3, eof: false }) + }) + + it('reports eof on the page that holds the last line, whether or not the limit is reached', async () => { + await twentyLines() + const service = endpoint() + const exact = await service.read(agent, 'long.txt', { offset: 16, limit: 5 }, signal()) + expect(exact).toMatchObject({ text: 'line 16\nline 17\nline 18\nline 19\nline 20', lines: 5, eof: true }) + const beyond = await service.read(agent, 'long.txt', { offset: 19, limit: 10 }, signal()) + expect(beyond).toMatchObject({ text: 'line 19\nline 20', lines: 2, eof: true }) + }) + + it('treats a final newline as the last line terminator, not as an empty line after it', async () => { + await writeFile(join(workspace, 'two.txt'), 'a\nb\n', 'utf8') + await writeFile(join(workspace, 'three.txt'), 'a\nb\n\n', 'utf8') + const service = endpoint() + expect(await service.read(agent, 'two.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: true }) + expect(await service.read(agent, 'three.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: false }) + // The third line is empty, not absent: `lines` tells it from a page past the end. + expect(await service.read(agent, 'three.txt', { offset: 3 }, signal())).toMatchObject({ text: '', lines: 1, eof: true }) + }) + + it('returns an empty eof page for an offset past the last line', async () => { + await twentyLines() + const result = await endpoint().read(agent, 'long.txt', { offset: 21 }, signal()) + expect(result).toMatchObject({ offset: 21, text: '', lines: 0, eof: true }) + }) + + it('defaults the limit to the configured page size', async () => { + await twentyLines() + const result = await endpoint({ maxLines: 5 }).read(agent, 'long.txt', {}, signal()) + expect(result.text.split('\n')).toHaveLength(5) + expect(result.eof).toBe(false) + }) + + it('refuses a limit above the configured page size and a non-positive-integer window', async () => { + await twentyLines() + const service = endpoint({ maxLines: 5 }) + for (const range of [{ limit: 6 }, { offset: 0 }, { limit: 1.5 }, { offset: -3 }]) { + const failure = await failureOf(service.read(agent, 'long.txt', range, signal())) + expect(failure.code).toBe('gateway/bad-request') + } + }) + + it('keeps carriage returns: the page is the file text, not a rendering of it', async () => { + await writeFile(join(workspace, 'crlf.txt'), 'a\r\nb\r\n', 'utf8') + const result = await endpoint().read(agent, 'crlf.txt', {}, signal()) + expect(result.text).toBe('a\r\nb\r') + }) +}) + +describe('workspaceFiles.read — gate 1 and 2: authorization by containment', () => { + it('rejects an absolute path outside the workspace', async () => { + await writeFile(join(outside, 'secret.txt'), 'no', 'utf8') + const failure = await failureOf(endpoint().read(agent, join(outside, 'secret.txt'), {}, signal())) + expect(failure.code).toBe('workspace-file/outside-workspace') + }) + + it('rejects a traversal that climbs out of the workspace', async () => { + await writeFile(join(outside, 'secret.txt'), 'no', 'utf8') + const failure = await failureOf(endpoint().read(agent, '../outside/secret.txt', {}, signal())) + expect(failure.code).toBe('workspace-file/outside-workspace') + }) + + it('rejects a symlink that points out of the workspace — the case a prefix test cannot see', async () => { + await writeFile(join(outside, 'secret.txt'), 'no', 'utf8') + // The path itself is inside the workspace and would pass any string + // comparison; only lstat (before the follow) or realpath containment catches it. + await symlink(join(outside, 'secret.txt'), join(workspace, 'link.txt')) + const failure = await failureOf(endpoint().read(agent, 'link.txt', {}, signal())) + expect(failure.code).toBe('workspace-file/not-regular-file') + expect(failure.details).toMatchObject({ kind: 'symlink' }) + }) + + it('rejects a symlink even when it points back inside the workspace', async () => { + await writeFile(join(workspace, 'real.txt'), 'fine', 'utf8') + await symlink(join(workspace, 'real.txt'), join(workspace, 'alias.txt')) + const failure = await failureOf(endpoint().read(agent, 'alias.txt', {}, signal())) + expect(failure.code).toBe('workspace-file/not-regular-file') + }) + + it('rejects a directory, which has no text to return', async () => { + await mkdir(join(workspace, 'src'), { recursive: true }) + const failure = await failureOf(endpoint().read(agent, 'src', {}, signal())) + expect(failure.code).toBe('workspace-file/not-regular-file') + expect(failure.details).toMatchObject({ kind: 'directory' }) + }) + + it('reports a missing path as not found', async () => { + const failure = await failureOf(endpoint().read(agent, 'nope.txt', {}, signal())) + expect(failure.code).toBe('workspace-file/not-found') + }) + + it('refuses an empty path as a bad request', async () => { + const failure = await failureOf(endpoint().read(agent, '', {}, signal())) + expect(failure.code).toBe('gateway/bad-request') + }) +}) + +describe('workspaceFiles.read — gate 3: the page byte cap', () => { + it('fails a page above the cap rather than returning it shortened', async () => { + await writeFile(join(workspace, 'big.txt'), 'x'.repeat(4096), 'utf8') + const failure = await failureOf(endpoint({ maxBytes: 1024 }).read(agent, 'big.txt', {}, signal())) + expect(failure.code).toBe('workspace-file/too-large') + expect(failure.details).toMatchObject({ limit: 1024 }) + }) + + it('accepts a page exactly at the cap, because the cap is inclusive', async () => { + await writeFile(join(workspace, 'exact.txt'), `${'x'.repeat(31)}\n${'y'.repeat(32)}\n`, 'utf8') + const result = await endpoint({ maxBytes: 64 }).read(agent, 'exact.txt', {}, signal()) + expect(result.text).toHaveLength(64) + }) + + it('counts the newlines between the page lines against the cap', async () => { + await writeFile(join(workspace, 'exact.txt'), `${'x'.repeat(31)}\n${'y'.repeat(32)}\n`, 'utf8') + const failure = await failureOf(endpoint({ maxBytes: 63 }).read(agent, 'exact.txt', {}, signal())) + expect(failure.code).toBe('workspace-file/too-large') + }) + + it('caps the page, not the file: a small window of a file far above the cap reads', async () => { + await writeFile(join(workspace, 'huge.txt'), Array.from({ length: 2000 }, (_, i) => `row ${i} ${'z'.repeat(100)}`).join('\n'), 'utf8') + const result = await endpoint({ maxBytes: 1024 }).read(agent, 'huge.txt', { offset: 1990, limit: 3 }, signal()) + expect(result.text.split('\n')).toHaveLength(3) + expect(result.eof).toBe(false) + expect(result.bytes).toBeGreaterThan(200_000) + }) +}) + +describe('workspaceFiles.read — gate 4: text only', () => { + it('rejects bytes that are not valid UTF-8', async () => { + await writeFile(join(workspace, 'bin.dat'), Buffer.from([0xff, 0xfe, 0xfd])) + const failure = await failureOf(endpoint().read(agent, 'bin.dat', {}, signal())) + expect(failure.code).toBe('workspace-file/not-text') + }) + + it('rejects a page that carries NUL bytes, wherever in the file the page lies', async () => { + await writeFile(join(workspace, 'nul.dat'), Buffer.from([0x61, 0x00, 0x62])) + const service = endpoint() + expect((await failureOf(service.read(agent, 'nul.dat', {}, signal()))).code).toBe('workspace-file/not-text') + // Past the backend's own binary sample, so only the page scan can see it. + await lateNul() + expect((await failureOf(service.read(agent, 'late-nul.txt', { offset: 2 }, signal()))).code).toBe('workspace-file/not-text') + }) + + it('reads a page that ends before a NUL byte, because detection is per page', async () => { + await lateNul() + const result = await endpoint().read(agent, 'late-nul.txt', { limit: 1 }, signal()) + expect(result.text).toHaveLength(9000) + expect(result.eof).toBe(false) + }) +}) + +describe('workspaceFiles.read — the file changing under its gate', () => { + /** Run `mutate` after the path gate has looked, so what follows sees a different filesystem. */ + function afterGate(mutate: () => Promise): void { + const fs = harness.ctx.fs + const lstat = fs.lstat.bind(fs) + vi.spyOn(fs, 'lstat').mockImplementation(async (path, opts, signal) => { + const entry = await lstat(path, opts, signal) + await mutate() + return entry + }) + } + + it('reports a file deleted after the gate as not found, not as an internal failure', async () => { + await writeFile(join(workspace, 'fleeting.txt'), 'x', 'utf8') + afterGate(() => rm(join(workspace, 'fleeting.txt'))) + const failure = await failureOf(endpoint().read(agent, 'fleeting.txt', {}, signal())) + expect(failure.code).toBe('workspace-file/not-found') + }) + + it('reports a file replaced by a directory after the gate as not a regular file', async () => { + await writeFile(join(workspace, 'fleeting.txt'), 'x', 'utf8') + afterGate(async () => { + await rm(join(workspace, 'fleeting.txt')) + await mkdir(join(workspace, 'fleeting.txt')) + }) + const failure = await failureOf(endpoint().read(agent, 'fleeting.txt', {}, signal())) + expect(failure.code).toBe('workspace-file/not-regular-file') + expect(failure.details).toMatchObject({ kind: 'directory' }) + }) + + it('passes any other backend failure through unchanged', async () => { + await writeFile(join(workspace, 'notes.txt'), 'x', 'utf8') + vi.spyOn(harness.ctx.fs, 'streamText').mockRejectedValue(new FsError('disk unreadable', 'FS_IO_ERROR')) + await expect(endpoint().read(agent, 'notes.txt', {}, signal())).rejects.toMatchObject({ code: 'FS_IO_ERROR' }) + }) +}) diff --git a/packages/api/workspace-files/tests/stat.spec.ts b/packages/api/workspace-files/tests/stat.spec.ts new file mode 100644 index 0000000000..7badc565f0 --- /dev/null +++ b/packages/api/workspace-files/tests/stat.spec.ts @@ -0,0 +1,79 @@ +/** The `stat` endpoint: the same gates as `read`, answering identity and freshness without content. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, symlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { FsVersion } from '@deepseek-ai/dsh-fs' +import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts' + +let harness: Harness + +beforeEach(async () => { + harness = await openWorkspace('dsh-workspace-files-stat-') +}) + +afterEach(async () => { + await harness.dispose() +}) + +describe('workspaceFiles.stat', () => { + it('returns the absolute path, a version, and the byte size', async () => { + await writeFile(join(harness.workspace, 'notes.txt'), 'hello\n', 'utf8') + const result = await harness.endpoint().stat(agent, 'notes.txt', signal()) + expect(result.absolutePath).toBe(harness.ctx.fs.processPath(await harness.ctx.fs.resolve(join(harness.workspace, 'notes.txt')))) + expect(result.version.length).toBeGreaterThan(0) + expect(result.bytes).toBe(6) + }) + + it('answers with the version a read of the same file reports, and a new one after a write', async () => { + const path = join(harness.workspace, 'notes.txt') + await writeFile(path, 'one\n', 'utf8') + const endpoint = harness.endpoint() + const before = await endpoint.stat(agent, 'notes.txt', signal()) + const page = await endpoint.read(agent, 'notes.txt', {}, signal()) + expect(page.version).toBe(before.version) + await writeFile(path, 'one\ntwo\n', 'utf8') + const after = await endpoint.stat(agent, 'notes.txt', signal()) + expect(after.version).not.toBe(before.version) + expect(after.bytes).toBe(8) + }) + + it('rejects under a signal the caller already aborted, before any path resolves', async () => { + const controller = new AbortController() + controller.abort() + await expect(harness.endpoint().stat(agent, 'notes.txt', controller.signal)).rejects.toThrow() + }) + + it('resolves the workspace root and then the confined target under the caller\'s signal', async () => { + await writeFile(join(harness.workspace, 'notes.txt'), 'hello\n', 'utf8') + const fs = harness.ctx.fs + const original = fs.resolve.bind(fs) + const spy = vi.spyOn(fs, 'resolve').mockImplementation((path, opts) => original(path, opts)) + const controller = new AbortController() + await harness.endpoint().stat(agent, 'notes.txt', controller.signal) + expect(spy.mock.calls.map(([, opts]) => opts?.signal)).toEqual([controller.signal, controller.signal]) + spy.mockRestore() + }) + + it('omits bytes when the backend reports no size', async () => { + await writeFile(join(harness.workspace, 'notes.txt'), 'hello\n', 'utf8') + vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ version: FsVersion('v-sizeless'), type: 'file' }) + const result = await harness.endpoint().stat(agent, 'notes.txt', signal()) + expect(result).toEqual({ absolutePath: result.absolutePath, version: 'v-sizeless' }) + }) + + it('applies the read gates: symlink, directory, outside, missing, empty', async () => { + await writeFile(join(harness.outside, 'secret.txt'), 'no', 'utf8') + await symlink(join(harness.outside, 'secret.txt'), join(harness.workspace, 'link.txt')) + await mkdir(join(harness.workspace, 'src')) + const endpoint = harness.endpoint() + expect(await failureOf(endpoint.stat(agent, 'link.txt', signal()))).toMatchObject({ + code: 'workspace-file/not-regular-file', + details: { kind: 'symlink' }, + }) + expect((await failureOf(endpoint.stat(agent, 'src', signal()))).details).toMatchObject({ kind: 'directory' }) + expect((await failureOf(endpoint.stat(agent, join(harness.outside, 'secret.txt'), signal()))).code) + .toBe('workspace-file/outside-workspace') + expect((await failureOf(endpoint.stat(agent, 'nope.txt', signal()))).code).toBe('workspace-file/not-found') + expect((await failureOf(endpoint.stat(agent, '', signal()))).code).toBe('gateway/bad-request') + }) +}) diff --git a/packages/api/workspace-files/tsconfig.client.json b/packages/api/workspace-files/tsconfig.client.json new file mode 100644 index 0000000000..43e935dc8f --- /dev/null +++ b/packages/api/workspace-files/tsconfig.client.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/client/change-feed.ts", + "src/client/provider.ts", + "src/client/remote.ts", + "src/client/types.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../gateway/tsconfig.client.json" }, + { "path": "../session-controller/tsconfig.client.json" }, + { "path": "../../core/session" }, + { "path": "../../client/resources" }, + { "path": "../../client/ui-slots" }, + { "path": "../../util/workspace-path" }, + { + "path": "../../typert/protocol" + } + ] +} diff --git a/packages/api/workspace-files/tsconfig.host.json b/packages/api/workspace-files/tsconfig.host.json new file mode 100644 index 0000000000..c26a622e87 --- /dev/null +++ b/packages/api/workspace-files/tsconfig.host.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/types.ts", + "src/changes.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../fs/fs" + }, + { + "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../util/deque" + } + ] +} diff --git a/packages/api/workspace-files/tsconfig.json b/packages/api/workspace-files/tsconfig.json new file mode 100644 index 0000000000..2eca820546 --- /dev/null +++ b/packages/api/workspace-files/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.host.json" + }, + { + "path": "./tsconfig.client.json" + } + ] +} diff --git a/packages/api/workspace-files/tsdown.config.ts b/packages/api/workspace-files/tsdown.config.ts new file mode 100644 index 0000000000..6bf04a7409 --- /dev/null +++ b/packages/api/workspace-files/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-api-workspace-files', + ['lib/types/index.js'], + { hostPhase: true }, +) diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index cc3054e20e..33dc0d9ed4 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index bd49889f69..ab6dc09a2d 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 7a5663cdce..5fd15cc279 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -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/boot/app-boot/README.md -README.md: e6afceaebf7fc94f5a6d708639de128cb9005d72 -README.zh.md: a98be1fd5d0624f971af5d0ce5a81fdc0cafab54 +README.md: 086424009c9f7157f687a00cbb50cdc80a210e11 +README.zh.md: 4d0e45dd6be961d22d638134541c80501edec505 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index e6afceaebf..086424009c 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -47,7 +47,7 @@ With that entry point, success looks like a running app with every plugin active Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot owns profile loading, JSON validation, and resolved runtime data. -A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/` and combines installable bundles, its own `cordis.patch.yml`, and `patchReload: live | startup`; omitted reload policy keeps the historical `live` default for custom profiles. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh plugin` creates custom profiles, and a missing bundle or one without a patch declaration fails startup loudly. +A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/` and combines installable bundles, its own `cordis.patch.yml`, and `patchReload: live | startup`; omitted reload policy keeps the historical `live` default for custom profiles. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh plugin` creates custom profiles, and a missing bundle or one without a patch declaration fails startup loudly. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup. Your machine-local preferences also live in the Harness home: diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index a98be1fd5d..4d0e45dd6b 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -47,7 +47,7 @@ const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHO Profile 与 bundle 的声明类型从 [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.zh.md) 导入。App-boot 负责 profile 加载、JSON 校验和解析后的运行时数据。 -profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/`,由可安装 bundle、自身 `cordis.patch.yml` 与 `patchReload: live | startup` 组成;自定义 profile 省略 reload 策略时保留历史 `live` 默认值。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立 bundle,其他模板保留 base 加模式 bundle 的栈。`dsh plugin` 创建自定义 profile;缺失 bundle 或未声明 patch 的 bundle 会让启动明确失败。 +profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/`,由可安装 bundle、自身 `cordis.patch.yml` 与 `patchReload: live | startup` 组成;自定义 profile 省略 reload 策略时保留历史 `live` 默认值。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立 bundle,其他模板保留 base 加模式 bundle 的栈。`dsh plugin` 创建自定义 profile;缺失 bundle 或未声明 patch 的 bundle 会让启动明确失败。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。 你的机器本地偏好同样位于 harness home 中: diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index bfb9e205c0..24ab9fab88 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 634aaee577..ef891cef0d 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -34,6 +34,7 @@ export { healProfilesModuleFallback, initProfile, loadProfile, + loadProfileDirectory, PROFILE_PATCH_FILENAME, PROFILE_TEMPLATES, PROFILES_DIR, diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 3c97bab40c..64f65878ec 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -761,6 +761,48 @@ export function resolveBundleDir( ) } +/** + * Load an already initialized profile directory without resolving it through + * the shared Harness home. This is used by application-owned profiles whose + * package project and lifecycle belong to that application. + * @param binName - the diagnostic prefix on thrown errors. + * @param dir - absolute profile package directory. + * @param installAnchor - absolute path of the owning dsh app's package.json. + * @param options - `userLayer: false` skips reading `cordis.patch.yml`. + * @returns the resolved bundle layers and optional user patch layer. + */ +export function loadProfileDirectory( + binName: string, + dir: string, + installAnchor: string, + options: { userLayer?: boolean } = {}, +): Profile { + const manifest = readProfileManifest(binName, dir) + const bundles = manifest.dsh?.profile?.bundles ?? [] + const rawPatchReload: unknown = manifest.dsh?.profile?.patchReload + if (rawPatchReload !== undefined && rawPatchReload !== 'live' && rawPatchReload !== 'startup') { + throw new Error( + `${binName}: profile manifest ${join(dir, 'package.json')} dsh.profile.patchReload must be "live" or "startup"`, + ) + } + const patchReload = rawPatchReload ?? DEFAULT_PROFILE_PATCH_RELOAD + const layers = bundles.map((packageName): ProfileLayer => { + const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir) + const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest + const declared = bundleManifest.dsh?.bundle?.patch + if (declared === undefined) { + throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`) + } + const patchPath = join(packageDir, declared) + return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) } + }) + const patchPath = join(dir, PROFILE_PATCH_FILENAME) + const patches = options.userLayer !== false && existsSync(patchPath) + ? loadOverlayPatches(binName, patchPath) + : [] + return { name: basename(dir), dir, layers, patchPath, patches, patchReload } +} + /** * Load a profile: resolve every `dsh.profile.bundles` entry to its patch * layer and parse the profile's own patch file. A listed bundle without a @@ -789,31 +831,8 @@ export function loadProfile( } initProfile(dir, template.bundles, template.patchReload) } - const manifest = normalizeShippedProfile(name, dir, readProfileManifest(binName, dir)) - // A hand-written profile manifest may omit the dsh section entirely. - const bundles = manifest.dsh?.profile?.bundles ?? [] - const rawPatchReload: unknown = manifest.dsh?.profile?.patchReload - if (rawPatchReload !== undefined && rawPatchReload !== 'live' && rawPatchReload !== 'startup') { - throw new Error( - `${binName}: profile manifest ${join(dir, 'package.json')} dsh.profile.patchReload must be "live" or "startup"`, - ) - } - const patchReload = rawPatchReload ?? DEFAULT_PROFILE_PATCH_RELOAD - const layers = bundles.map((packageName): ProfileLayer => { - const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir) - const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest - const declared = bundleManifest.dsh?.bundle?.patch - if (declared === undefined) { - throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`) - } - const patchPath = join(packageDir, declared) - return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) } - }) - const patchPath = join(dir, PROFILE_PATCH_FILENAME) - const patches = options.userLayer !== false && existsSync(patchPath) - ? loadOverlayPatches(binName, patchPath) - : [] - return { name, dir, layers, patchPath, patches, patchReload } + normalizeShippedProfile(name, dir, readProfileManifest(binName, dir)) + return loadProfileDirectory(binName, dir, installAnchor, options) } /** diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 10d1d41b8a..3fa0cf97b4 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -17,6 +17,7 @@ import { healProfilesModuleFallback, initProfile, loadProfile, + loadProfileDirectory, PROFILE_PATCH_FILENAME, PROFILE_TEMPLATES, readProfileManifest, @@ -163,6 +164,16 @@ describe('resolveBundleDir', () => { }) describe('loadProfile', () => { + it('loads an explicitly owned profile directory outside CLI discovery', () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const dir = join(tmp(), 'managed', 'desktop') + initProfile(dir, ['bundle-a']) + const profile = loadProfileDirectory('managed app', dir, anchor) + expect(profile.dir).toBe(dir) + expect(profile.name).toBe('desktop') + expect(profile.layers.map(layer => layer.packageName)).toEqual(['bundle-a']) + }) + it('resolves each dsh.profile.bundles entry to its patch layer in order, plus the user layer', () => { const anchor = stageInstallation({ 'bundle-a': { patch: '- insert:\n - id: a\n name: pkg-a\n' }, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 128196ef6d..4221cd9e48 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index d06d20ab94..2b50add8d2 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 61131898c4..d9faf89f9e 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index c027747926..31a817873a 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, @@ -59,6 +59,8 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^" } diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index b380537720..e590b69b83 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -2,12 +2,14 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, AssistantStreamFrame, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import AgentDefaultModelConfig from '@deepseek-ai/dsh-agent-default-model' import { LlmAttemptId, createAssistantMessage, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' import { apply, Config, internals } from '../src/index.ts' const originalInternals = { ...internals } @@ -82,6 +84,7 @@ async function bench(script: Script): Promise<{ let err = '' const order: string[] = [] await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentDefaultModelConfig, { provider: 'test-provider', model: 'test-model' }) ctx.agents.setFactory({ @@ -89,16 +92,15 @@ async function bench(script: Script): Promise<{ const session = ctx.sessions.create(options.sessionId, { ...options.meta === undefined ? {} : { meta: options.meta }, }) + const inbox = createInboxStub() let idle = Promise.resolve() - const agent = {} as Agent - const agentCtx = ownerCtx.extend({ agent }) - Object.assign(agent, { + const agent: Agent = { id: session.id, options: options.agentOptions ?? {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox, status: 'idle', - ctx: agentCtx, + ctx: ownerCtx, cancel: () => {}, runMaintenance: () => Promise.reject(new Error('not used')), send: () => {}, @@ -109,7 +111,11 @@ async function bench(script: Script): Promise<{ steer: () => {}, inject: () => {}, whenIdle: () => idle, - } satisfies Partial) + } + const agentCtx = ownerCtx.extend({ agent }) + Object.assign(agent, { + ctx: agentCtx, + }) await options.setup?.(agentCtx) script.before?.(session) ctx.agents.register(agent) @@ -158,6 +164,25 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) + it('ignores durable inbox events before the first owned turn', async () => { + const test = await bench({ + afterPrompt(session, message) { + session.append('agent/inbox/spliced', { + target: 'next-turn', + start: 0, + inserted: [message], + }) + appendTurn(session, 1, message, 'answer after inbox activity', true) + }, + }) + expect(await test.run()).toMatchObject({ + code: 0, + out: 'answer after inbox activity\n', + err: '', + }) + await test.ctx.fiber.dispose() + }) + it('waits for asynchronously appended events instead of racing Agent idleness', async () => { const test = await bench({ afterPrompt: async (session, message) => { @@ -336,16 +361,20 @@ describe('headless runner', () => { }) it('fails when an event below the captured Session length cannot be read', async () => { + let capturedLength = 0 const test = await bench({ afterPrompt(session, message) { appendTurn(session, 1, message, 'unreachable', true) + capturedLength = session.seq Object.defineProperty(session, 'eventAt', { value: () => undefined }) }, }) - expect(await test.run()).toMatchObject({ + const result = await test.run() + expect(capturedLength).toBeGreaterThan(0) + expect(result).toMatchObject({ code: 1, out: '', - err: 'dsh: headless summary cannot read seq 0 below captured length 7\n', + err: `dsh: headless summary cannot read seq 0 below captured length ${String(capturedLength)}\n`, }) await test.ctx.fiber.dispose() }) diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 6a5c36a451..ee19f5c454 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index b0dbc597d2..4db08a3064 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, editor, and JSONL sessions", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 982575eab2..346abe5347 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -105,6 +105,11 @@ - id: session-controller name: '@deepseek-ai/dsh-api-session-controller' + # Workspace file service: bounded read, directory listing, and the + # agent-write change feed inside the session workspace root. + - id: workspace-files + name: '@deepseek-ai/dsh-api-workspace-files' + # Configuration-surface reads and writes over Typert Remote. Each method # reports an actionable error when its settings-domain provider is absent. - id: settings-controller @@ -208,9 +213,27 @@ - id: ui-session name: '@deepseek-ai/dsh-client-ui-session' + # Unified resource model: protocol providers behind the useResource hook. + - id: resources + name: '@deepseek-ai/dsh-client-resources' + - id: ui-sidebar name: '@deepseek-ai/dsh-client-ui-sidebar' + # The right Sidebar: one docking surface per session over ui-dockkit. + - id: ui-sidebar-right + name: '@deepseek-ai/dsh-client-ui-sidebar-right' + + + # The right Sidebar's first outside tab type: workspace text files, read + # through the bounded workspaceFiles endpoint. + - id: ui-sidebar-textpreview + name: '@deepseek-ai/dsh-client-ui-sidebar-textpreview' + + # The right Sidebar's workspace file tree tab type. + - id: ui-sidebar-files + name: '@deepseek-ai/dsh-client-ui-sidebar-files' + - id: ui-settings name: '@deepseek-ai/dsh-client-ui-settings' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 62b2f93159..76076b6219 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, @@ -40,91 +40,96 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", - "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-settings-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-files": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-resources": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", - "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-approval": "workspace:^", + "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-brand-official": "workspace:^", - "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-cordis": "workspace:^", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^", - "@deepseek-ai/dsh-client-ui-open-in-app": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", - "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", + "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-model-selection": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", + "@deepseek-ai/dsh-client-ui-open-in-app": "workspace:^", "@deepseek-ai/dsh-client-ui-permission-presets": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", + "@deepseek-ai/dsh-client-ui-reference": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-schedule": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", - "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-files": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-textpreview": "workspace:^", "@deepseek-ai/dsh-client-ui-skill": "workspace:^", - "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-client-ui-reference": "workspace:^", "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", - "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", - "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", + "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", + "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:^", "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-web-frontend": "workspace:^", - "@deepseek-ai/dsh-host-frontend-static": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-file-reference-local": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", + "@deepseek-ai/dsh-host-frontend-static": "workspace:^", "@deepseek-ai/dsh-host-open-in-app": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-file-reference-local": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-log-export": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-session-turn-outline": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-settings-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-web-frontend": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "commander": "^15.0.0", "open": "^11.0.0" }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-shell-env": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-system-prompt": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-shell-env": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-system-prompt": "workspace:^" } } diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index cc6a249a74..0d1633b902 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -74,7 +74,7 @@ Client business code may statically read `process.env.DSH_CLIENT_*`; every refer A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, and static Client libraries; `PRELOADED_CLIENT_EXTERNALS` is reserved for dynamic rows whose factories must arrive before shell boot and is empty when no such row exists. -1. **Baseline externals are implicit for every dynamic bundle.** Do not repeat React, Cordis, `client/store`, `ui-primitives`, or `ui-slots` in package manifests. +1. **Baseline externals are implicit for every dynamic bundle.** Do not repeat React, Cordis, `client/store`, `ui-primitives`, `ui-slots`, or `ui-dockkit` in package manifests. 2. **`dsh.client.external` is not a feature-plugin dependency mechanism.** Only infrastructure, transport, or generated assembly may add a package-specific non-baseline value request whose dynamic row must be materialized through the module table. Declare the exact import specifier; only a trailing `/client` aliases the package row. 3. **Silence means a private copy.** Ordinary third-party implementation libraries may be bundled independently. A value reached only through `import type` is erased and creates no request. 4. **A request has two possible suppliers.** A dynamic package supplies its own row; `PLATFORM_MODULES` supplies an exact static-table key. There is no `dsh.client.provide` alias protocol. diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 2e7f63c51f..85dc4fcffd 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -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/README.md -README.md: 75eda4a46afb21287daa72a7c94fc9cc25aa32d8 -README.zh.md: 808ee9d2a52ae67dac456b58dc1f382ae85ab260 +README.md: aec7edcb1e15d174544a9abaf99a4dc784034f2a +README.zh.md: e4f069e1afef4973ebc8fdcc507a720c7a02be79 diff --git a/packages/client/README.md b/packages/client/README.md index 75eda4a46a..aec7edcb1e 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -42,6 +42,8 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-attachment/`](ui-attachment/README.md) | Registers composer and message-image attachment presentation | — | | [`ui-layout/`](ui-layout/README.md) | Arranges the main application regions | — | | [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation | — | +| [`resources/`](resources/README.md) | Unified resource model: protocol providers behind the `useResource` session standard hook | `ctx.resources` | +| [`ui-sidebar-files/`](ui-sidebar-files/README.md) | Right-Sidebar workspace file tree tab type | — | | [`ui-brand-official/`](ui-brand-official/README.md) | Fills the generic browser-brand slots with the official name and marks | — | | [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces | — | | [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 808ee9d2a5..e4f069e1af 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -42,6 +42,8 @@ kind: "package-group" | [`ui-attachment/`](ui-attachment/README.zh.md) | 注册输入框与消息图片的附件呈现 | — | | [`ui-layout/`](ui-layout/README.zh.md) | 排列应用的主要区域 | — | | [`ui-sidebar/`](ui-sidebar/README.zh.md) | 展示工作区与会话导航 | — | +| [`resources/`](resources/README.zh.md) | 统一资源模型:`useResource` 会话标准 hook 背后的协议提供者 | `ctx.resources` | +| [`ui-sidebar-files/`](ui-sidebar-files/README.zh.md) | 右侧 Sidebar 的工作区文件树 tab 类型 | — | | [`ui-brand-official/`](ui-brand-official/README.zh.md) | 用官方名称与标记填充通用浏览器品牌 slot | — | | [`ui-workspace/`](ui-workspace/README.zh.md) | 提供工作区选择与创建界面 | — | | [`ui-conversation/`](ui-conversation/README.zh.md) | 展示当前对话及其输入界面 | — | diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index b5b4eab8db..27b847754e 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -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/connection/README.md -README.md: 4273c523039ddbc7b0644250800425c555c4c067 -README.zh.md: b416659b38a9f63d25842f7926a19eb78842e505 +README.md: 865c894403d2177e15085d0616289cf8950b555d +README.zh.md: 3d299a4be7b3490298554982eed6d99fbba9718c diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 4273c52303..865c894403 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -25,7 +25,7 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con ## Use this package -The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, Host/Origin checks, and exact `GET`/`HEAD`/`POST` route registry. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). +The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). ----- diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index b416659b38..3d299a4be7 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -浏览器通过 HTTP POST 执行 Remote 一元调用。API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证、Host/Origin 校验与精确 `GET`/`HEAD`/`POST` 路由注册表。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 +浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 ----- diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 9808c40fde..7272c047a5 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Authenticated RPC transport, generation lifecycle, and browser fixture", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 932ec1da3e..e9440fee7f 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1750,8 +1750,20 @@ export interface FixtureOptions { dropSessionCreateResponse?: boolean /** Order of the two successful create frames. */ createFrameOrder?: 'session-first' | 'workspace-first' + /** Announce one Agent write to `notes/demo.txt` shortly after a `workspaceFiles/changes` stream opens. */ + fileChanges?: boolean } +/** File observation payload, mirrored so this Client fixture names no Host package. */ +type FixtureWorkspaceFileChange = + | { readonly absolutePath: string; readonly version: string } + | { readonly absolutePath: string; readonly absent: true } + +/** Host subscription acknowledgement followed by file observations. */ +type FixtureWorkspaceFileWatchFrame = + | { readonly kind: 'ready' } + | { readonly kind: 'change'; readonly change: FixtureWorkspaceFileChange } + /** Inbox pump shared by both stream generators (FrameQueue pattern: ONE abort listener hung * outside the loop — a per-iteration {once:true} listener never fires for non-final rounds and * piles up for the stream's lifetime). breakNow force-ends the stream without the @@ -2367,6 +2379,157 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, } + /** + * Workspace text reads under `?fixture`. + * + * The sample content is deliberately more than one shape: the panel's states + * (text, oversized, unreadable) are only demonstrable if the fixture can + * produce each of them, and a preview that can only ever succeed hides its + * own failure rendering. + */ + // The Session workspace the replayed conversation writes into: `list` walks a + // fixed tree under it so the file-tree tab has directories, files, one entry + // of neither kind, and one cut listing to draw. + const WORKSPACE_FILES_ROOT = '/tmp/fixture' + type FixtureWorkspaceEntry = { name: string; type: 'file' | 'directory' | 'other'; size?: number } + const workspaceFileTree = new Map([ + ['', [ + { name: '.gitignore', type: 'file', size: 24 }, + { name: 'dev.sock', type: 'other' }, + { name: 'notes', type: 'directory' }, + { name: 'package.json', type: 'file', size: 512 }, + { name: 'README.md', type: 'file', size: 640 }, + { name: 'src', type: 'directory' }, + ]], + ['notes', [ + { name: 'demo.txt', type: 'file', size: 14 }, + { name: 'new-demo.txt', type: 'file', size: 14 }, + ]], + ['src', [ + { name: 'config.ts', type: 'file', size: 211 }, + { name: 'index.ts', type: 'file', size: 88 }, + { name: 'lib', type: 'directory' }, + ]], + ['src/lib', Array.from({ length: 24 }, (_, index) => ({ + name: `module-${String(index + 1).padStart(2, '0')}.ts`, + type: 'file' as const, + size: 96 + index, + }))], + ]) + /** Resolve a `list` argument to its workspace-relative path, or undefined when it leaves the root. */ + const workspaceFilePath = (path: string): string | undefined => { + const segments: string[] = [] + for (const segment of (path.startsWith('/') ? path : `${WORKSPACE_FILES_ROOT}/${path}`).split('/')) { + if (segment === '' || segment === '.') continue + if (segment === '..') { + segments.pop() + continue + } + segments.push(segment) + } + const absolute = `/${segments.join('/')}` + if (absolute !== WORKSPACE_FILES_ROOT && !absolute.startsWith(`${WORKSPACE_FILES_ROOT}/`)) return undefined + return absolute.slice(WORKSPACE_FILES_ROOT.length + 1) + } + // Page cap mirrored from the Host default so an over-limit request fails here too. + const WORKSPACE_FILE_PAGE_LINES = 5000 + /** + * Sample text for any readable path: a heading plus two lines of copy. The + * replayed conversation's `demo` files, and any `huge` path, run past two + * default pages so paging can be exercised without a real workspace. + */ + const workspaceFileLines = (path: string): string[] => { + const name = path.slice(path.lastIndexOf('/') + 1) + const head = [`# ${name}`, '', 'fixture 模式下的示例文本,用于验收侧栏的文本预览。', '真实构建从工作区读取同名文件。'] + return path.includes('demo') || path.includes('huge') + ? [...head, ...Array.from({ length: 12_000 }, (_, index) => `第 ${index + 5} 行:用于验收分页与滚动的长文本样本。`)] + : head + } + const workspaceFileRemotes = { + list(path: string): ConnectionRpcResult<{ + path: string + entries: readonly FixtureWorkspaceEntry[] + truncated: boolean + }> { + if (path.length === 0) { + return { ok: false, error: { code: 'gateway/bad-request', message: 'path is required', details: {} } } + } + const relative = workspaceFilePath(path) + if (relative === undefined) { + return { + ok: false, + error: { code: 'workspace-file/outside-workspace', message: `${path} is outside the workspace`, details: { path } }, + } + } + const entries = workspaceFileTree.get(relative) + if (entries === undefined) { + const cut = relative.lastIndexOf('/') + const name = relative.slice(cut + 1) + const sibling = workspaceFileTree.get(cut === -1 ? '' : relative.slice(0, cut))?.find(entry => entry.name === name) + if (sibling === undefined) { + return { ok: false, error: { code: 'workspace-file/not-found', message: `no entry at ${path}`, details: { path } } } + } + return { + ok: false, + error: { + code: 'workspace-file/not-directory', + message: `${path} is a ${sibling.type}`, + details: { path, kind: sibling.type === 'file' ? 'file' : 'other' }, + }, + } + } + return { ok: true, value: { path: relative, entries, truncated: relative === 'src/lib' } } + }, + read(path: string, range: { offset?: number; limit?: number }): ConnectionRpcResult<{ + absolutePath: string + version: string + bytes: number + offset: number + text: string + lines: number + eof: boolean + }> { + const located = workspaceFileRemotes.stat(path) + if (!located.ok) return located + const offset = range.offset ?? 1 + const limit = range.limit ?? WORKSPACE_FILE_PAGE_LINES + if (!Number.isInteger(offset) || offset < 1 || !Number.isInteger(limit) || limit < 1 || limit > WORKSPACE_FILE_PAGE_LINES) { + return { ok: false, error: { code: 'gateway/bad-request', message: 'offset and limit must be positive integers within the page cap', details: {} } } + } + const lines = workspaceFileLines(path) + const page = lines.slice(offset - 1, offset - 1 + limit) + return { + ok: true, + value: { + ...located.value, + offset, + text: page.join('\n'), + lines: page.length, + eof: offset - 1 + limit >= lines.length, + }, + } + }, + stat(path: string): ConnectionRpcResult<{ absolutePath: string; version: string; bytes: number }> { + if (path.length === 0) { + return { ok: false, error: { code: 'gateway/bad-request', message: 'path is required', details: {} } } + } + if (path.endsWith('.png') || path.endsWith('.bin')) { + return { + ok: false, + error: { code: 'workspace-file/not-text', message: `${path} is not UTF-8 text`, details: { path } }, + } + } + return { + ok: true, + value: { + absolutePath: path.startsWith('/') ? path : `/${path}`, + version: 'fx-v1', + bytes: new TextEncoder().encode(workspaceFileLines(path).join('\n')).byteLength, + }, + } + }, + } + /** * Canonical fixture implementation of the generated Directory Picker Remote * contract. The pick is deterministic — the keyless lanes drive the full @@ -3274,6 +3437,27 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } } + async function* openWorkspaceFileChanges(signal: AbortSignal): AsyncGenerator { + signal.throwIfAborted() + const conn = new FxInbox() + const breakNow = (): void => { conn.breakNow() } + streamBreakers.add(breakNow) + // Opt-in only: an unprompted frame would age every preview of demo.txt + // into its changed state on a timer the assembled snapshots cannot see. + const announce = options.fileChanges + ? setTimeout(() => { + conn.push({ kind: 'change', change: { absolutePath: `${WORKSPACE_FILES_ROOT}/notes/demo.txt`, version: 'fx-demo-v2' } }) + }, 1000) + : undefined + try { + yield { kind: 'ready' } + yield* conn.drain(signal) + } finally { + if (announce !== undefined) clearTimeout(announce) + streamBreakers.delete(breakNow) + } + } + async function* openRemoteEvents( signal: AbortSignal, ): AsyncGenerator { @@ -3557,6 +3741,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { line?: string query?: string path?: string + range?: { offset?: number; limit?: number } name?: string images?: readonly unknown[] // A goal ref and a credential reference name share this wire field name. @@ -3638,6 +3823,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { case 'session/openWorkspacePath': { return sessionOk({ opened: true as const }) } + case 'workspaceFiles/read': { + return Promise.resolve(workspaceFileRemotes.read(args.path ?? '', args.range ?? {})) + } + case 'workspaceFiles/stat': { + return Promise.resolve(workspaceFileRemotes.stat(args.path ?? '')) + } + case 'workspaceFiles/list': { + return Promise.resolve(workspaceFileRemotes.list(args.path ?? '')) + } case 'session/canOpenWorkspacePath': return Promise.resolve({ ok: true, value: true }) case 'session/modelCatalog': return Promise.resolve({ ok: true, @@ -3740,6 +3934,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { case 'session/control': return openControl(signal) case 'session/follow': return openFollow(args.request as FixtureFollowRequest, signal) case 'workspace/follow': return openWorkspace(signal) + case 'workspaceFiles/changes': return openWorkspaceFileChanges(signal) default: throw new Error(`fixture connection stream endpoint ${JSON.stringify(endpoint)} is unavailable`) } @@ -3766,5 +3961,6 @@ function fixtureOptionsFromLocation(): FixtureOptions { failWorkspaceAttach: query.get('fixtureAttach') === 'fail', dropSessionCreateResponse: query.get('fixtureSessionCreate') === 'drop-response', createFrameOrder: query.get('fixtureFrames') === 'workspace-first' ? 'workspace-first' : 'session-first', + fileChanges: query.get('fixtureFileChanges') === 'demo', } } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 469e2df5ab..98a4354760 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -66,7 +66,7 @@ function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): voi } /** Services required before providing Connection. */ -export const inject = ['webServer', 'credentials'] +export const inject = ['credentials'] /** Browser authentication, request limits, and connection recovery configuration. */ export interface ConnectionConfig { @@ -95,9 +95,9 @@ export const Config: z = z.object({ }) /** - * Mounts the API gateway under the browser transport prefix. Every request on - * the prefix passes the Host/Origin browser-trust fence and persistent browser - * authentication before dispatch. + * Provides carrier-neutral RPC and Fetch registries. When `webServer` is + * present, the plugin also mounts the `/api` browser transport with Host/Origin + * checks and persistent browser authentication. * @param ctx - Host plugin context. * @param config - resolved plugin config (schema defaults applied). */ @@ -116,24 +116,27 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise { - table.push({ kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: recovery }) + ctx.inject(['webServer'], (webCtx) => { + assertImageBodyCapacity(webCtx, maxRequestBodyBytes) + webCtx.on('webserver/index-inject', (table) => { + table.push({ kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: recovery }) + }) + const fetchHandler = connection.createSharedFetchHandler(API_PATH) + const route: WebRoute = { + kind: 'prefix', + path: API_PATH, + handler: async (req, res) => { + const rejection = connection.requestRejection(req) + if (rejection !== undefined) { + res.writeHead(rejection) + res.end(rejection === 401 ? 'unauthorized' : 'forbidden') + return + } + await bridge(req, res, fetchHandler, maxRequestBodyBytes) + }, + } + webCtx.effect(() => webCtx.webServer.register(route), 'client-connection: /api route') }) - const fetchHandler = connection.createSharedFetchHandler(API_PATH) - const route: WebRoute = { - kind: 'prefix', - path: API_PATH, - handler: async (req, res) => { - const rejection = connection.requestRejection(req) - if (rejection !== undefined) { - res.writeHead(rejection) - res.end(rejection === 401 ? 'unauthorized' : 'forbidden') - return - } - await bridge(req, res, fetchHandler, maxRequestBodyBytes) - }, - } - ctx.effect(() => ctx.webServer.register(route), 'client-connection: /api route') ctx.inject(['attachments'], (attachmentCtx) => { assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes) }) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index b51f75a420..5c681f3c00 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -118,6 +118,15 @@ function browserCookie(connection: HostConnectionHandle, authority: string): str } describe('connection node half', () => { + it('provides the carrier-neutral service without a Web server', async () => { + const ctx = new Context() + provideBrowserCredentials(ctx) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(ctx.get('connection')).toBeInstanceOf(Object) + await fiber.dispose() + }) + it('injects validated browser recovery timing and withdraws it on disposal', async () => { const { ctx, dispose } = await mounted({ recovery: { generationReadyTimeoutMs: 25_000 } }) try { diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json index 89d0b7ca76..f1fc6cac73 100644 --- a/packages/client/file-upload/package.json +++ b/packages/client/file-upload/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-file-upload", "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index e3758ac8b5..548165f045 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 382f74ece8..e07156c47d 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index 44ddb31e7b..f10adf664e 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' @@ -9,6 +10,9 @@ import { LanguageRow } from '../src/client/LanguageRow.tsx' import type { LanguageRowComponentProps } from '../src/client/LanguageRow.tsx' import { createLanguageRowStore } from '../src/client/settings-store.ts' +// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + afterEach(cleanup) const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] @@ -37,6 +41,7 @@ function mount(active = 'en') { const props: LanguageRowComponentProps = { useSessions: emptySessions(), useSessionPendingInteraction, + useResource, useWorkspaces: emptyWorkspaces(), useStore: bindSnapshotSelector(store), actions: store.actions, diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 16df1c0dca..cba7fe01a0 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/README.i18n.yaml @@ -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/modules/README.md -README.md: 7bfa39d183bb29f3f9481bb39280c5521786031a -README.zh.md: 64dc5644f535722b464c867616b18ff557e921be +README.md: 246293de32483adba0ee93d2d9bdbe8b8dbcd5fa +README.zh.md: 759eb5b94e63c7108013ed9b223393ba405adc33 diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index 7bfa39d183..246293de32 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-modules` turns a plugin package's `dsh.client` declaration into a loadable browser bundle: the host half scans enabled Loader entries, composes the boot graph, and serves each bundle over `/plugins`, and the browser half loads those bundles lazily on demand. Plugin bundles execute lazily — running a bundle only registers a factory, and module side effects run at materialization — so nothing runs until a plugin is first used. Everything here is browser-kernel machinery; the model never sees it. +`dsh-client-modules` turns a plugin package's `dsh.client` declaration into a loadable browser bundle: the host half scans enabled Loader entries and composes the boot graph, an available Web carrier serves each bundle over `/plugins`, and a shell-owned carrier dispatches the same exact bundle responses through `fetchBundle()`. The browser half loads those bundles lazily on demand. Plugin bundles execute lazily — running a bundle only registers a factory, and module side effects run at materialization — so nothing runs until a plugin is first used. Everything here is browser-kernel machinery; the model never sees it. ## Table of Contents @@ -71,13 +71,13 @@ The node half snapshots each client bundle and available source map before publi ### Boot manifest injection -The host taps the index render and injects, into ``: the `window.__ModuleLoader__` queue facade, advisory preloads for every application combo, the parser-blocking bootstrap combo scripts, then the boot graph before the shell reads it. The facade's `create()` materializes the modules bundle, delegates construction to its `createClientModuleSystem` export, and leaves the same facade in live-registration mode. +The host contributes structured index rows that inject, into ``: the `window.__ModuleLoader__` queue facade, advisory preloads for every application combo, the parser-blocking bootstrap combo scripts, then the boot graph before the shell reads it. A Web carrier renders those rows into its index response; a shell-owned carrier can render the same rows without a Web server. The facade's `create()` materializes the modules bundle, delegates construction to its `createClientModuleSystem` export, and leaves the same facade in live-registration mode. ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Node half: `ClientModuleRegistry`, scan, artifact snapshots, combo routes, index tap | +| [`src/index.ts`](src/index.ts) | Node half: `ClientModuleRegistry`, scan, artifact snapshots, optional combo route, structured index rows | | [`src/client/index.ts`](src/client/index.ts) | Browser half: bootstrap export, `ctx.modules` enrollment | | [`src/client/system.ts`](src/client/system.ts) | `ClientModuleSystem`: load/materialize/invalidate machinery | | [`src/client/manifest.ts`](src/client/manifest.ts) | Wire types and boot-manifest parsing | diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index 64dc5644f5..759eb5b94e 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-modules` 把插件包的 `dsh.client` 声明变成可加载的浏览器 bundle:宿主半侧扫描已启用的 Loader 条目、组合启动图,并通过 `/plugins` 提供每个 bundle;浏览器半侧按需惰性加载这些 bundle。插件 bundle 惰性执行——运行 bundle 只注册 factory,模块副作用在物化时运行——因此插件首次被使用之前什么都不会运行。这里的一切都是浏览器内核机制;模型永远看不到它。 +`dsh-client-modules` 把插件包的 `dsh.client` 声明变成可加载的浏览器 bundle:宿主半侧扫描已启用的 Loader 条目并组合启动图,可用的 Web 载体通过 `/plugins` 提供每个 bundle,由 shell 持有的载体则通过 `fetchBundle()` 分派完全相同的 bundle 响应。浏览器半侧按需惰性加载这些 bundle。插件 bundle 惰性执行——运行 bundle 只注册 factory,模块副作用在物化时运行——因此插件首次被使用之前什么都不会运行。这里的一切都是浏览器内核机制;模型永远看不到它。 ## 目录 @@ -71,13 +71,13 @@ node 半侧会在发布前快照每个客户端 bundle 及其现有 source map ### 启动清单注入 -宿主 tap 索引渲染,并向 `` 注入:`window.__ModuleLoader__` queue facade、每个 application combo 的提示性 preload、阻塞 parser 的 bootstrap combo 脚本,然后才是外壳读取前的启动图。facade 的 `create()` 物化 modules bundle、把构造委托给其 `createClientModuleSystem` 导出,并让同一 facade 进入 live registration 模式。 +宿主贡献结构化 index 行,并向 `` 注入:`window.__ModuleLoader__` queue facade、每个 application combo 的提示性 preload、阻塞 parser 的 bootstrap combo 脚本,然后才是外壳读取前的启动图。Web 载体把这些行渲染进 index 响应;由 shell 持有的载体则可以在没有 Web server 时渲染同一批行。facade 的 `create()` 物化 modules bundle、把构造委托给其 `createClientModuleSystem` 导出,并让同一 facade 进入 live registration 模式。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | node 半侧:`ClientModuleRegistry`、扫描、产物快照、combo 路由、索引 tap | +| [`src/index.ts`](src/index.ts) | node 半侧:`ClientModuleRegistry`、扫描、产物快照、可选 combo 路由、结构化 index 行 | | [`src/client/index.ts`](src/client/index.ts) | 浏览器半侧:bootstrap 导出、`ctx.modules` 登记 | | [`src/client/system.ts`](src/client/system.ts) | `ClientModuleSystem`:加载/物化/失效机制 | | [`src/client/manifest.ts`](src/client/manifest.ts) | 协议类型与启动清单解析 | diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 8d52321929..27eddaa12b 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index e8c6770c95..c823657be5 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -516,7 +516,7 @@ window.__ModuleLoader__={ * boot activation audit reports it). */ export class ClientModuleRegistry extends Service { - static inject = ['webServer', 'loader'] + static inject = ['loader'] private readonly table = new Map() private readonly sources = new Map() @@ -537,7 +537,7 @@ export class ClientModuleRegistry extends Service { /** * Build the service: subscribe, seed, and run the activation flush. - * @param ctx - plugin context carrying webServer and loader. + * @param ctx - plugin context carrying Loader and an optional Web carrier. */ constructor(ctx: Context) { super(ctx, 'clientModules') @@ -567,10 +567,14 @@ export class ClientModuleRegistry extends Service { throw new ClientPackageCompositionError(failures) } - ctx.effect( - () => ctx.webServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }), - 'client-modules: bundle route', - ) + const registerWebCarrier = (webCtx: Context): void => { + webCtx.effect( + () => webCtx.webServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }), + 'client-modules: bundle route', + ) + } + if (ctx.get('webServer') === undefined) ctx.inject(['webServer'], registerWebCarrier) + else registerWebCarrier(ctx) ctx.on('webserver/index-inject', (table) => { table.push(...bootInjections(this.composed)) }) @@ -593,6 +597,22 @@ export class ClientModuleRegistry extends Service { return this.table.get(id)?.meta.clientPath } + /** + * Serve an advertised revisioned bundle or source map without a Web server. + * Unknown URLs return 404, unsupported methods return 405, and `HEAD` + * returns the same immutable headers without a body. + * @param request - shell-carrier request for a `/plugins` resource. + * @returns the exact response also exposed by the optional Web route. + */ + fetchBundle(request: Request): Response { + const resource = this.bundleResource(request.method, request.url) + const body = resource.body === undefined ? null : Uint8Array.from(resource.body) + return new Response(body, { + status: resource.status, + ...(resource.headers === undefined ? {} : { headers: resource.headers }), + }) + } + /** * Filesystem baseline captured before an entry's current bytes were read. * HMR compares it with the live files when installing a watch, so a write @@ -984,28 +1004,32 @@ export class ClientModuleRegistry extends Service { this.notifyGraphChanged() } - private readonly serveBundle = (req: IncomingMessage, res: ServerResponse): void => { - if (req.method !== 'GET' && req.method !== 'HEAD') { - res.writeHead(405) - res.end() - return - } - /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */ - const requestUrl = new URL(req.url ?? '/', 'http://x') + private bundleResource(method: string | undefined, url: string): { + status: number + headers?: Record + body?: Buffer + } { + if (method !== 'GET' && method !== 'HEAD') return { status: 405 } + const requestUrl = new URL(url, 'http://x') const resourceUrl = `${requestUrl.pathname}${requestUrl.search}` const response = this.responses.get(resourceUrl) ?? this.previousBatchResponses.get(resourceUrl) if (response !== undefined) { - res.writeHead(200, { - 'content-type': response.contentType, - 'cache-control': IMMUTABLE_CACHE, - }) - res.end(req.method === 'HEAD' ? undefined : response.body) - return + return { + status: 200, + headers: { 'content-type': response.contentType, 'cache-control': IMMUTABLE_CACHE }, + ...(method === 'HEAD' ? {} : { body: response.body }), + } } // Anything else under /plugins (including unadvertised combinations and // /plugins/events when the HMR row is absent) is an unknown resource. - res.writeHead(404) - res.end() + return { status: 404 } + } + + private readonly serveBundle = (req: IncomingMessage, res: ServerResponse): void => { + /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */ + const response = this.bundleResource(req.method, req.url ?? '/') + res.writeHead(response.status, response.headers) + res.end(response.body) } } diff --git a/packages/client/modules/tests/node-half.client.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts index 5553c94c8e..4f06360ff6 100644 --- a/packages/client/modules/tests/node-half.client.spec.ts +++ b/packages/client/modules/tests/node-half.client.spec.ts @@ -628,6 +628,10 @@ describe('client bundle activation', () => { expect(batchScript.status).toBe(200) expect(batchScript.headers?.['cache-control']).toBe('public, max-age=31536000, immutable') expect(batchScript.body.toString('utf8')).toContain(`//# sourceMappingURL=${mapUrl(batch.url)}`) + const shellResponse = service.fetchBundle(new Request(`dsh-app://app${batch.url}`)) + expect(shellResponse.status).toBe(200) + expect(shellResponse.headers.get('cache-control')).toBe('public, max-age=31536000, immutable') + expect(await shellResponse.text()).toBe(batchScript.body.toString('utf8')) expect((await routeRequest(route, batch.url, 'HEAD')).body).toHaveLength(0) expect((await routeRequest(route, batch.url, 'POST')).status).toBe(405) const batchMap = await routeRequest(route, mapUrl(batch.url)) diff --git a/packages/client/resources/README.i18n.yaml b/packages/client/resources/README.i18n.yaml new file mode 100644 index 0000000000..50ca50a65e --- /dev/null +++ b/packages/client/resources/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/resources/README.md +README.md: 2bc3d03bc5c58d45f9a0955aa73185be87b2bc6c +README.zh.md: 43238fd5ff3794204f8d6d989e5d771131b8c489 diff --git a/packages/client/resources/README.md b/packages/client/resources/README.md new file mode 100644 index 0000000000..2bc3d03bc5 --- /dev/null +++ b/packages/client/resources/README.md @@ -0,0 +1,108 @@ +--- +description: "Client resource model: protocol-registered providers turn URL addresses into live values that any slot component reads through the useResource standard hook." +kind: "package-reference" +--- +# @deepseek-ai/dsh-client-resources + +English | [中文](README.zh.md) + +## Summary + +The resource model of the web client. A resource is one address, and a resource address is a `dsh-resource:///…` URL whose host is the protocol key; the protocol's owning client package registers a provider that turns an address into a value stream, and any slot component reads that stream through the `useResource` global standard hook. A protocol that needs a scope encodes it in the path (`dsh-resource://file/session//`); the model knows only addresses, and an address under any other scheme (`sidebar://guide`) names no resource. Use it when a component needs live data it only knows by address (a tab record, a link, a mention) and the data's owner is another client plugin. + +## Table of Contents + +- [Use this package](#use-this-package) + - [Read a resource](#read-a-resource) + - [Provide a protocol](#provide-a-protocol) + - [Hold a resource open](#hold-a-resource-open) +- [Understand the implementation](#understand-the-implementation) + - [Lifecycle](#lifecycle) + - [Failures](#failures) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Nothing needs configuration to mount: the plugin provides `ctx.resources` and contributes the `resource` root keyed hook through `ctx.slots.provideRoot`, so every slot component receives it whatever its scope. + + +### Read a resource + +Every slot component receives `useResource` in its props. `useResource

(address)` names the protocol as the type argument and returns `{ status, value, failure, reload }`: `none` when no provider is registered for the address's protocol (or the address is not a `dsh-resource://` URL), `loading` while the provider has not yielded, `live` with the latest `ok` frame's value, and `failed` when the latest frame reported a failure, with that failure beside the last value. `reload()` asks the provider for a fresh value and is a no-op without one. Subscribing through the hook is what holds the resource open; a component that mounts while another holder keeps the resource alive reads the latest value at once. + + +### Provide a protocol + +The protocol's owning client package declares its value type in `ResourceProtocolMap` and registers one provider as an owned effect. `open` yields `RemoteResult` frames: the current content first and one frame per later change, with a failure as an `ok: false` frame rather than a throw; it must stop when `signal` aborts. `reload` is optional: + +```ts ignore-check +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface ResourceProtocolMap { note: NoteView } +} + +export const inject = ['resources'] + +export function apply(ctx) { + ctx.effect(() => ctx.resources.register<'note'>({ + protocol: 'note', + async *open(address, { signal }) { + yield await readNote(address, signal) + for await (const change of followNote(address, signal)) yield change + }, + reload(address) { requestReread(address) }, + }), 'my-notes: note resource provider') +} +``` + +A protocol has exactly one provider; a second registration throws. Registering a provider while addresses of its protocol are already held opens them; disposing it ends their streams and returns them to `none`. + + +### Hold a resource open + +`ctx.resources.pin(address, signal)` keeps a resource open without subscribing, until `signal` aborts. The right Sidebar pins every open tab's address for the tab record's lifetime, so switching tabs unmounts the body without closing its stream and switching back reads the latest value. `ctx.resources.source(address)` is the bare observable behind the hook, for callers outside React. + + +## Understand the implementation + + +### Lifecycle + +One record per address holds a snapshot store, a holder count (hook subscribers plus pins), and the running stream's `AbortController`. The first holder opens the provider's stream; every later holder shares it; the last holder's release aborts the stream and resets the snapshot to idle (`loading` with a provider, `none` without). Records are kept for the page lifetime so `source()` stays reference-stable across React's render-then-subscribe window and a StrictMode remount. `reload` is one function per record and never changes. + + +### Failures + +A failure is a frame, not a throw: a provider yields `{ ok: false, error }` and the resource turns `failed` with that error beside the last value; the next `ok` frame clears it. A stream that ends on its own keeps its last state. Frames that arrive after the release that aborted the stream are dropped, and the iterator is returned. A throw inside a provider's stream is a programming error and is not caught. + + +## Model Experience + +None, as this package moves values between browser plugins and registers nothing model-facing. + +#### KV Cache effect + +None; resource streams do not assemble model requests. + +## Known Limitations and Deferred Work + + + +- **Records live for the page lifetime** — an address's record stays in the registry after its last holder leaves; only its state is discarded. Memory grows with the number of distinct addresses ever read, not with reads. +- **Providers own abort compliance** — the registry drops what a released stream still yields, but a provider that ignores `signal` keeps working until its next frame. + + +### Dev Note + +

+Working context for maintainers — click to expand + +None. + +
+ +**Runtime invariant:** No companion is published. Provider ownership and holder counts have one owner, the registry, with no independent runtime source to compare against; registration disposal and the open/close lifecycle are asserted by behavior specs. diff --git a/packages/client/resources/README.zh.md b/packages/client/resources/README.zh.md new file mode 100644 index 0000000000..43238fd5ff --- /dev/null +++ b/packages/client/resources/README.zh.md @@ -0,0 +1,108 @@ +--- +description: "客户端资源模型:按协议注册的提供方把 URL 地址变成活数据,任何 slot 组件都通过 useResource 标准 hook 读取。" +kind: "package-reference" +--- +# @deepseek-ai/dsh-client-resources + +[English](README.md) | 中文 + +## 概述 + +Web 客户端的资源模型。一份资源是一个地址,资源地址是 `dsh-resource:///…` 形式的 URL,host 即协议键;协议所属的客户端包注册一个提供方把地址变成值的流,任何 slot 组件通过 `useResource` 全局标准 hook 读取这条流。需要作用域的协议把它编进路径(`dsh-resource://file/session//<绝对路径>`);模型本身只认地址,其它 scheme 的地址(`sidebar://guide`)不指向资源。当组件需要的活数据只以地址形式可知(tab 记录、链接、提及),而数据的拥有者是另一个客户端插件时,请使用它。 + +## 目录 + +- [使用本包](#use-this-package) + - [读取资源](#read-a-resource) + - [提供协议](#provide-a-protocol) + - [钉住资源](#hold-a-resource-open) +- [理解实现](#understand-the-implementation) + - [生命周期](#lifecycle) + - [失败](#failures) +- [模型体验](#model-experience) +- [已知限制与暂缓事项](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +挂载无需任何配置:插件提供 `ctx.resources`,并通过 `ctx.slots.provideRoot` 贡献 `resource` 根 keyed hook,因此每个 slot 组件不论作用域都能收到它。 + + +### 读取资源 + +每个 slot 组件都在 props 上收到 `useResource`。`useResource

(address)` 以类型参数命名协议,返回 `{ status, value, failure, reload }`:地址协议没有提供方(或地址不是 `dsh-resource://` URL)时为 `none`,提供方尚未产出值时为 `loading`,`live` 携带最新一个 `ok` 帧的值,`failed` 表示最新一帧报告了失败,失败放在最后一个值旁。`reload()` 请提供方给一个新值,没有提供方时是空操作。通过 hook 订阅就是钉住资源的方式;另一个持有者让资源保持存活时,新挂载的组件立刻读到最新值。 + + +### 提供协议 + +协议所属的客户端包在 `ResourceProtocolMap` 声明其值类型,并以自有 effect 注册一个提供方。`open` 产出 `RemoteResult` 帧:先是当前内容,之后每次变化一帧,失败以 `ok: false` 帧而非抛错表达;必须在 `signal` 中止时停止。`reload` 可选: + +```ts ignore-check +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface ResourceProtocolMap { note: NoteView } +} + +export const inject = ['resources'] + +export function apply(ctx) { + ctx.effect(() => ctx.resources.register<'note'>({ + protocol: 'note', + async *open(address, { signal }) { + yield await readNote(address, signal) + for await (const change of followNote(address, signal)) yield change + }, + reload(address) { requestReread(address) }, + }), 'my-notes: note resource provider') +} +``` + +一个协议恰有一个提供方;第二次注册会抛错。提供方注册时若其协议的地址已被持有,则立即开流;提供方 dispose 时结束这些流并让它们回到 `none`。 + + +### 钉住资源 + +`ctx.resources.pin(address, signal)` 在不订阅的情况下让资源保持打开,直到 `signal` 中止。右侧 Sidebar 在 tab 记录的存续期内钉住每个已打开 tab 的地址,因此切换 tab 卸载正文不会关闭其流,切回时读到最新值。`ctx.resources.source(address)` 是 hook 背后的裸 observable,供 React 之外的调用方使用。 + + +## 理解实现 + + +### 生命周期 + +每个地址一条记录,持有一个快照 store、一个持有者计数(hook 订阅者加 pin)与运行中流的 `AbortController`。第一个持有者打开提供方的流;之后的持有者共享它;最后一个持有者释放时中止流并把快照重置为空闲(有提供方为 `loading`,没有为 `none`)。记录在页面存续期内保留,使 `source()` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定。`reload` 每条记录一个函数,永不变化。 + + +### 失败 + +失败是帧而非抛错:提供方产出 `{ ok: false, error }`,资源变为 `failed` 并把该错误放在最后一个值旁;下一个 `ok` 帧将其清除。自行结束的流保持其最后状态。在中止流的那次释放之后到达的帧都被丢弃,并归还迭代器。提供方流内的抛错是编程错误,不会被捕获。 + + +## 模型体验 + +无,因为本包在浏览器插件之间搬运值,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无;资源流不会组装模型请求。 + +## 已知限制与暂缓事项 + + + +- **记录在页面存续期内保留**——地址的记录在最后一个持有者离开后仍留在注册表中,只丢弃其状态。内存随读取过的不同地址数增长,而非随读取次数增长。 +- **中止合规由提供方负责**——注册表会丢弃已释放的流仍产出的帧,但忽略 `signal` 的提供方会一直工作到它的下一帧。 + + +### 开发备注 + +

+维护者工作上下文——点击展开 + +无。 + +
+ +**运行时不变式:** 不发布伴生入口。提供方归属与持有者计数只有注册表这一个拥有者,没有可供比对的独立运行时来源;注册的 dispose 与打开/关闭生命周期由行为测试断言。 diff --git a/packages/client/resources/package.json b/packages/client/resources/package.json new file mode 100644 index 0000000000..cf240c9af8 --- /dev/null +++ b/packages/client/resources/package.json @@ -0,0 +1,57 @@ +{ + "name": "@deepseek-ai/dsh-client-resources", + "description": "Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook", + "version": "0.1.3-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/resources" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-ui-renderer" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/resources/src/client/contract.ts b/packages/client/resources/src/client/contract.ts new file mode 100644 index 0000000000..c297c172b0 --- /dev/null +++ b/packages/client/resources/src/client/contract.ts @@ -0,0 +1,122 @@ +/** + * The resource model's published face. + * + * A resource is one address, and a resource address is a + * `dsh-resource:///…` URL: the host names the protocol. The protocol's + * owning client package registers one {@link ResourceProvider} that turns an + * address into a frame stream, and any slot component reads that stream through + * {@link UseResource}. A protocol that needs a scope (a session, a workspace) + * encodes it in the path, as `dsh-resource://file/session//` does; the model itself knows only addresses. Addresses under any other + * scheme (`sidebar://guide`) are navigation addresses and name no resource. + * `ResourceProtocolMap` (declared + * in ui-slots) is the declaration-merged roster of protocol to value type, so a + * consumer names the protocol as a type argument and receives the owner's value + * type without importing the owner's runtime. + */ +import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { ResourceProtocolMap } from '@deepseek-ai/dsh-client-ui-slots' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface GlobalStandardProps { + /** Live value of one address, resolved through the provider registered for its protocol. */ + useResource: UseResource + } +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Resource model: protocol providers, pins, and per-address live sources. */ + resources: Resources + } +} + +/** Every protocol some client package has declared. */ +export type ResourceProtocol = Extract + +/** + * Where one resource stands. `none`: no provider is registered for the + * address's protocol, or the address is not a resource address. `loading`: a provider is open and has not yielded yet. + * `live`: `value` is the latest `ok` frame's value. `failed`: the latest frame + * reported a failure. + */ +export type ResourceStatus = 'none' | 'loading' | 'live' | 'failed' + +/** One address's current state, as `useResource` returns it. */ +export interface ResourceSnapshot { + readonly status: ResourceStatus + /** The latest `ok` frame's value; kept through a later failure frame, absent before the first. */ + readonly value: Value | undefined + /** The latest frame's failure; present only while `status` is `failed`. */ + readonly failure: RemoteFailure | undefined + /** Ask the provider for a fresh frame; a no-op when its protocol has no provider or no `reload`. */ + readonly reload: () => void +} + +/** + * Global standard hook: the current state of one address, typed by the + * protocol named as the type argument. Present on every slot component's + * props, whatever its scope. + */ +export type UseResource =

( + address: string, +) => ResourceSnapshot + +/** What a provider's `open` receives beside the address. */ +export interface ResourceOpenContext { + /** Aborted when the last subscriber or pin releases the resource; the stream must end. */ + readonly signal: AbortSignal +} + +/** One protocol's provider, registered through `ctx.resources.register`. */ +export interface ResourceProvider

{ + /** The URL scheme this provider serves. */ + readonly protocol: P + /** + * Open one frame stream for an address. The first frame is the current + * content and every later frame one change. An `ok` frame replaces the value; + * a failure frame marks the resource `failed` with its error and keeps the + * last value. Ending the stream keeps the last state. A failure is always a + * frame: a throw inside the stream is a programming error and is not caught. + * @param address - the full address, a `dsh-resource:///…` URL. + * @param ctx - the stream's abort signal. + * @returns the frame stream; it must stop once `ctx.signal` aborts. + */ + open(address: string, ctx: ResourceOpenContext): AsyncIterable> + /** + * Produce a fresh frame on the open stream. Absent when the protocol has no refresh. + * @param address - the full address, a `dsh-resource:///…` URL. + */ + reload?(address: string): void +} + +/** + * The `ctx.resources` service. One resource is one address; it stays open + * while at least one `source` subscriber or one pin holds it, and the + * provider's stream is aborted and the state discarded when the last holder + * releases. + */ +export interface Resources { + /** + * Register the provider for one protocol for the caller's lifetime. + * @param provider - the protocol's provider. + * @returns idempotent disposer, held inside the caller's own `ctx.effect`. + * @throws when the protocol already has a provider. + */ + register

(provider: ResourceProvider

): () => void + /** + * Hold one resource open without subscribing to it. + * @param address - the full address, a `dsh-resource:///…` URL. + * @param signal - aborting it releases the pin; an already-aborted signal pins nothing. + */ + pin(address: string, signal: AbortSignal): void + /** + * The live source of one resource. Reference-stable for one address while + * the resource is held; the first subscriber or pin opens the provider's + * stream, and a subscriber arriving later reads the latest value at once. + * @param address - the full address, a `dsh-resource:///…` URL. + * @returns the observable state; `getSnapshot` reads without holding the resource. + */ + source(address: string): ObservableSnapshot> +} diff --git a/packages/client/resources/src/client/index.ts b/packages/client/resources/src/client/index.ts new file mode 100644 index 0000000000..68d7a27e7d --- /dev/null +++ b/packages/client/resources/src/client/index.ts @@ -0,0 +1,41 @@ +/** + * Browser half: `ctx.resources` (protocol-registered providers, pinning, live + * sources) and the `useResource` global standard hook. + */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only service merge for ctx.slots. +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots' +import { ResourceRegistry } from './resources.ts' + +export type { + ResourceOpenContext, + ResourceProtocol, + ResourceProvider, + Resources, + ResourceSnapshot, + ResourceStatus, + UseResource, +} from './contract.ts' +export type { ResourceProtocolMap } from '@deepseek-ai/dsh-client-ui-slots' + +/** Required browser services. */ +export const inject = ['slots'] + +/** + * Client plugin body: provide `ctx.resources` and contribute the `resource` + * root keyed hook that reaches every slot component as `useResource`. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + // Built at apply's top level, never inside an effect: other plugins call + // `register()` from their own apply, and it adds an effect to this fiber. + const resources = new ResourceRegistry(ctx) + const disposeService = ctx.reflect.provide('resources', resources) + // Registered first, so it tears down last: the face outlives every provider + // that registered into it. + ctx.effect(() => () => { void disposeService() }, 'client-resources: service face') + ctx.slots.provideRoot({ + keyedHooks: { resource: address => resources.source(address) }, + } satisfies RootStandardSourceContribution) +} diff --git a/packages/client/resources/src/client/resources.ts b/packages/client/resources/src/client/resources.ts new file mode 100644 index 0000000000..5b9a75cabe --- /dev/null +++ b/packages/client/resources/src/client/resources.ts @@ -0,0 +1,217 @@ +/** + * `ctx.resources`: the provider registry and the per-address states behind + * `useResource`. + * + * A record is kept for every address ever sourced and is never dropped; what + * the last release discards is its state (the stream is aborted and the + * snapshot returns to idle). Keeping the record keeps `source()` reference-stable + * across React's render-then-subscribe window and a StrictMode remount, where a + * recreated record would make every render resubscribe and restart the stream. + */ +import type { Context } from '@deepseek-ai/cordis' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { createSnapshotStore, type ObservableSnapshot, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { + ResourceOpenContext, + ResourceProtocol, + ResourceProvider, + Resources, + ResourceSnapshot, +} from './contract.ts' + +/** A provider with its value type erased, so one map holds every protocol. */ +interface RuntimeProvider { + readonly protocol: string + open(address: string, ctx: ResourceOpenContext): AsyncIterable> + reload?(address: string): void +} + +/** One address: its state, its holders, and the running stream. */ +interface ResourceRecord { + readonly address: string + /** The address's protocol key (`dsh-resource://` host); absent when the address is not a resource address. */ + readonly protocol: string | undefined + readonly store: SnapshotStore> + readonly source: ObservableSnapshot> + readonly reload: () => void + /** Subscribers plus pins; the stream runs while this is positive. */ + holders: number + /** Present while the provider's stream runs; aborting it ends the stream. */ + controller: AbortController | undefined +} + +/** + * The one URL scheme resource addresses use: `dsh-resource:///…`, where + * the host names the protocol. Other schemes (`sidebar://…`) are navigation + * addresses and name no resource. + */ +export const RESOURCE_SCHEME = 'dsh-resource' + +/** + * The protocol key of one address: the host of a `dsh-resource://` URL, as the + * URL parser reads it (lower-cased). Any other string — another scheme, or one + * the URL parser rejects — names no protocol and is treated like an address + * whose protocol has no provider. + * @param address - the full address. + * @returns the protocol key, or `undefined` when the address is not a resource address. + */ +export function protocolOf(address: string): string | undefined { + let parsed: URL + try { + parsed = new URL(address) + } catch { + // The URL parser rejects strings without a scheme (`/a/b.txt`, `''`); + // nothing else throws here, and an unparseable address is simply not ours. + return undefined + } + if (parsed.protocol !== `${RESOURCE_SCHEME}:`) return undefined + // A non-special scheme's host is opaque to the URL parser and keeps its case. + return parsed.hostname === '' ? undefined : parsed.hostname.toLowerCase() +} + +function idle(status: 'none' | 'loading', reload: () => void): ResourceSnapshot { + return { status, value: undefined, failure: undefined, reload } +} + +/** The `ctx.resources` implementation. */ +export class ResourceRegistry implements Resources { + private readonly providers = new Map() + private readonly records = new Map() + + /** @param ctx - Context whose effects own the registered providers. */ + constructor(private readonly ctx: Context) {} + + register

(provider: ResourceProvider

): () => void { + const runtime: RuntimeProvider = provider + const { protocol } = runtime + if (this.providers.has(protocol)) { + throw new Error(`resources: protocol "${protocol}" already has a provider`) + } + const dispose = this.ctx.effect(() => { + this.providers.set(protocol, runtime) + for (const record of this.recordsOf(protocol)) this.attach(record) + return () => { + this.providers.delete(protocol) + for (const record of this.recordsOf(protocol)) this.detach(record) + } + }, `resources.register(${JSON.stringify(protocol)})`) + return () => { void dispose() } + } + + pin(address: string, signal: AbortSignal): void { + if (signal.aborted) return + const record = this.record(address) + this.hold(record) + signal.addEventListener('abort', () => { this.release(record) }, { once: true }) + } + + source(address: string): ObservableSnapshot> { + return this.record(address).source + } + + private record(address: string): ResourceRecord { + let record = this.records.get(address) + if (record === undefined) { + record = this.create(address) + this.records.set(address, record) + } + return record + } + + private create(address: string): ResourceRecord { + const protocol = protocolOf(address) + const reload = (): void => { + this.providerOf(protocol)?.reload?.(address) + } + const store = createSnapshotStore>( + idle(this.providerOf(protocol) === undefined ? 'none' : 'loading', reload), + ) + const record: ResourceRecord = { + address, + protocol, + store, + reload, + holders: 0, + controller: undefined, + source: { + getSnapshot: () => store.getSnapshot(), + subscribe: (listener) => { + const unsubscribe = store.subscribe(listener) + this.hold(record) + let active = true + return () => { + if (!active) return + active = false + unsubscribe() + this.release(record) + } + }, + }, + } + return record + } + + private providerOf(protocol: string | undefined): RuntimeProvider | undefined { + return protocol === undefined ? undefined : this.providers.get(protocol) + } + + private *recordsOf(protocol: string): Iterable { + for (const record of this.records.values()) { + if (record.protocol === protocol) yield record + } + } + + private hold(record: ResourceRecord): void { + record.holders += 1 + if (record.holders === 1) this.start(record) + } + + private release(record: ResourceRecord): void { + record.holders -= 1 + if (record.holders > 0) return + this.stop(record) + record.store.set(idle(this.providerOf(record.protocol) === undefined ? 'none' : 'loading', record.reload)) + } + + /** The provider arrived: a held record opens its stream, an idle one turns `loading`. */ + private attach(record: ResourceRecord): void { + if (record.holders > 0) { + this.start(record) + return + } + record.store.set(idle('loading', record.reload)) + } + + /** The provider left: the stream ends and the record reports `none`. */ + private detach(record: ResourceRecord): void { + this.stop(record) + record.store.set(idle('none', record.reload)) + } + + private start(record: ResourceRecord): void { + const provider = this.providerOf(record.protocol) + if (provider === undefined) return + const controller = new AbortController() + record.controller = controller + if (record.store.getSnapshot().status !== 'loading') record.store.set(idle('loading', record.reload)) + void this.consume(record, provider, controller.signal) + } + + private stop(record: ResourceRecord): void { + record.controller?.abort() + record.controller = undefined + } + + /** Failures arrive as frames; a throw inside the stream is left to surface. */ + private async consume(record: ResourceRecord, provider: RuntimeProvider, signal: AbortSignal): Promise { + const stream = provider.open(record.address, { signal }) + for await (const frame of stream) { + // A frame the provider yields after the release that aborted it belongs + // to nobody; ending the loop also returns the iterator. + if (signal.aborted) break + record.store.set(frame.ok + ? { status: 'live', value: frame.value, failure: undefined, reload: record.reload } + : { status: 'failed', value: record.store.getSnapshot().value, failure: frame.error, reload: record.reload }) + } + } +} diff --git a/packages/client/resources/src/index.ts b/packages/client/resources/src/index.ts new file mode 100644 index 0000000000..b6c7e73b8c --- /dev/null +++ b/packages/client/resources/src/index.ts @@ -0,0 +1,4 @@ +/** Pure host half; the resource model lives in the browser export. */ + +/** Host plugin body: the resource model contributes nothing to the host tree. */ +export function apply(): void {} diff --git a/packages/client/resources/tests/apply.client.spec.ts b/packages/client/resources/tests/apply.client.spec.ts new file mode 100644 index 0000000000..810ba7dbc1 --- /dev/null +++ b/packages/client/resources/tests/apply.client.spec.ts @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +/** + * The plugin's wiring: `ctx.resources` is provided, the `resource` root keyed + * hook reaches every slot component as `useResource`, and both leave with the + * fiber so a reload of the plugin registers cleanly again. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { act } from '@testing-library/react' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { apply, inject, type ResourceSnapshot, type UseResource } from '../src/client/index.ts' +import { apply as hostApply } from '../src/index.ts' +import { ResourceRegistry } from '../src/client/resources.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + 'resources.probe': { kind: 'single'; scope: 'root' } + } + interface ResourceProtocolMap { + feed: string + } +} + +const A = 'dsh-resource://feed/one' +let runtime: SlotTestRuntime | undefined + +afterEach(async () => { + await runtime?.dispose() + runtime = undefined +}) + +const settle = (): Promise => new Promise((resolve) => { setTimeout(resolve, 0) }) + +async function boot(): Promise { + const rt = await SlotTestRuntime.create() + await rt.declare({ 'resources.probe': { kind: 'single', scope: 'root' } }) + return rt +} + +describe('client-resources apply', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + + it('provides ctx.resources and hands every slot component useResource over the shared source', async () => { + runtime = await boot() + await runtime.mount({ inject: [...inject], apply }) + expect(runtime.ctx.resources).toBeInstanceOf(ResourceRegistry) + + // A root-scope component reading one address through the standard hook. + let seen: ResourceSnapshot | undefined + runtime.slots.register({ name: 'resources.probe' }, ({ useResource }: { useResource: UseResource }) => { + seen = useResource<'feed'>(A) + return null + }) + runtime.renderSlot('resources.probe', {}) + expect(seen).toMatchObject({ status: 'none', value: undefined }) + + let push: ((value: string) => void) | undefined + await act(async () => { + runtime!.ctx.effect(() => runtime!.ctx.resources.register<'feed'>({ + protocol: 'feed', + open: () => ({ + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>>((resolve) => { + push = (value) => { resolve({ done: false, value: { ok: true, value } }) } + }), + }), + }), + }), 'spec: feed provider') + }) + expect(seen).toMatchObject({ status: 'loading' }) + // Rendering the hook is what holds the address: the provider's stream is open. + expect(push).toBeDefined() + await act(async () => { push!('v1'); await settle() }) + expect(seen).toMatchObject({ status: 'live', value: 'v1' }) + expect(runtime.ctx.resources.source(A).getSnapshot()).toBe(seen) + }) + + it('withdraws both on dispose, so a remount registers again without a duplicate', async () => { + runtime = await boot() + const handle = await runtime.mount({ inject: [...inject], apply }) + await handle.dispose() + expect(runtime.ctx.get('resources')).toBeUndefined() + + // Records only whether the standard hook is present on a root-scope component's props. + let hook: UseResource | undefined + runtime.slots.register({ name: 'resources.probe' }, (props: { useResource?: UseResource }) => { + hook = props.useResource + return null + }) + runtime.renderSlot('resources.probe', {}) + expect(hook).toBeUndefined() + + await runtime.mount({ inject: [...inject], apply }) + expect(hook).toBeTypeOf('function') + }) +}) diff --git a/packages/client/resources/tests/resources.client.spec.ts b/packages/client/resources/tests/resources.client.spec.ts new file mode 100644 index 0000000000..bc04ca2d7a --- /dev/null +++ b/packages/client/resources/tests/resources.client.spec.ts @@ -0,0 +1,359 @@ +/** + * The resource lifecycle: one address opens when its first holder arrives, + * stays open across holder changes, and closes when the last one leaves. + * Providers are scripted feeds so every transition is driven by the spec, + * never by timing. + */ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { protocolOf, RESOURCE_SCHEME, ResourceRegistry } from '../src/client/resources.ts' +import type { ResourceOpenContext, ResourceProvider } from '../src/client/contract.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface ResourceProtocolMap { + feed: string + } +} + +const A = `${RESOURCE_SCHEME}://feed/one` + +/** One scripted stream: the spec pushes value frames, failure frames, or ends it. */ +interface Feed { + readonly ctx: ResourceOpenContext + push(value: string): void + fail(error: RemoteFailure): void + end(): void + /** Whether the consumer returned the iterator (its `finally` ran). */ + readonly returned: boolean +} + +type Step = { readonly kind: 'frame'; readonly frame: RemoteResult } | { readonly kind: 'end' } + +function createFeed(ctx: ResourceOpenContext): { feed: Feed; stream: AsyncIterable> } { + const steps: Step[] = [] + let wake: (() => void) | undefined + let returned = false + const notify = (): void => { wake?.(); wake = undefined } + async function* stream(): AsyncGenerator> { + try { + for (;;) { + if (steps.length === 0) await new Promise((resolve) => { wake = resolve }) + const step = steps.shift() + if (step === undefined) continue + if (step.kind === 'end') return + yield step.frame + } + } finally { + returned = true + } + } + const feed: Feed = { + ctx, + push: (value) => { steps.push({ kind: 'frame', frame: { ok: true, value } }); notify() }, + fail: (error) => { steps.push({ kind: 'frame', frame: { ok: false, error } }); notify() }, + end: () => { steps.push({ kind: 'end' }); notify() }, + get returned() { return returned }, + } + return { feed, stream: stream() } +} + +/** A `feed` provider whose every `open` is recorded and spec-driven. */ +function scriptedProvider() { + const opens: Feed[] = [] + const provider = { + protocol: 'feed' as const, + open: vi.fn((_address: string, ctx: ResourceOpenContext) => { + const { feed, stream } = createFeed(ctx) + opens.push(feed) + return stream + }), + reload: vi.fn(), + } satisfies ResourceProvider<'feed'> + return { provider, opens, last: () => opens[opens.length - 1]! } +} + +const settle = (): Promise => new Promise((resolve) => { setTimeout(resolve, 0) }) + +function bench() { + const ctx = new Context() + const registry = new ResourceRegistry(ctx) + const scripted = scriptedProvider() + const snapshot = (address = A) => registry.source(address).getSnapshot() + return { ctx, registry, ...scripted, snapshot } +} + +describe('protocolOf', () => { + it('reads the dsh-resource host, lower-cased, and reports none for any other address', () => { + expect(protocolOf('dsh-resource://file/session/s1/home/ys/b.txt')).toBe('file') + expect(protocolOf('DSH-RESOURCE://File/session/s1/a')).toBe('file') + expect(protocolOf('dsh-resource://chat/node/1')).toBe('chat') + // A navigation address is not a resource. + expect(protocolOf('sidebar://guide')).toBeUndefined() + expect(protocolOf('file://sessions/s1/a.txt')).toBeUndefined() + expect(protocolOf('dsh-resource:///no-host')).toBeUndefined() + expect(protocolOf('/a/b.txt')).toBeUndefined() + expect(protocolOf('')).toBeUndefined() + }) +}) + +describe('ResourceRegistry providers', () => { + it('owns a protocol by exactly one provider, and frees it on dispose', () => { + const b = bench() + const dispose = b.registry.register(b.provider) + expect(() => b.registry.register(scriptedProvider().provider)).toThrow('protocol "feed" already has a provider') + dispose() + dispose() + expect(() => b.registry.register(scriptedProvider().provider)).not.toThrow() + }) + + it('reports none for an address whose protocol has no provider, and for a navigation address', () => { + const b = bench() + expect(b.snapshot()).toMatchObject({ status: 'none', value: undefined, failure: undefined }) + expect(b.snapshot('sidebar://guide')).toMatchObject({ status: 'none' }) + const unsubscribe = b.registry.source(A).subscribe(() => {}) + expect(b.snapshot().status).toBe('none') + unsubscribe() + }) + + it('opens a held address when its provider arrives, and closes it when the provider leaves', async () => { + const b = bench() + const seen = vi.fn() + b.registry.source(A).subscribe(seen) + expect(b.snapshot().status).toBe('none') + + const dispose = b.registry.register(b.provider) + expect(b.snapshot().status).toBe('loading') + expect(b.provider.open).toHaveBeenCalledWith(A, { signal: expect.any(AbortSignal) as AbortSignal }) + b.last().push('v1') + await vi.waitFor(() => { expect(b.snapshot()).toMatchObject({ status: 'live', value: 'v1' }) }) + + dispose() + expect(b.last().ctx.signal.aborted).toBe(true) + expect(b.snapshot()).toMatchObject({ status: 'none', value: undefined }) + expect(seen).toHaveBeenCalled() + }) + + it('opens the arriving protocol\'s held addresses only, leaving another protocol\'s records as they were', () => { + const b = bench() + const other = `${RESOURCE_SCHEME}://other/one` + b.registry.source(other).subscribe(() => {}) + b.registry.source(A).subscribe(() => {}) + b.registry.register(b.provider) + expect(b.snapshot().status).toBe('loading') + expect(b.snapshot(other).status).toBe('none') + expect(b.provider.open).toHaveBeenCalledExactlyOnceWith(A, { signal: expect.any(AbortSignal) as AbortSignal }) + }) + + it('turns an idle, unheld address to loading when its provider arrives, without opening it', () => { + const b = bench() + expect(b.snapshot().status).toBe('none') + b.registry.register(b.provider) + expect(b.snapshot().status).toBe('loading') + expect(b.provider.open).not.toHaveBeenCalled() + }) + + it('drops a registration when the registering fiber is disposed', async () => { + const b = bench() + const fiber = b.ctx.plugin({ + apply: (child: Context) => { child.effect(() => b.registry.register(b.provider), 'spec: feed provider') }, + }) + await fiber.await() + expect(b.snapshot().status).toBe('loading') + await fiber.dispose() + expect(b.snapshot().status).toBe('none') + expect(() => b.registry.register(scriptedProvider().provider)).not.toThrow() + }) + + it('drops every registration when the registry\'s own fiber is disposed', async () => { + const root = new Context() + let registry: ResourceRegistry | undefined + const fiber = root.plugin({ apply: (child: Context) => { registry = new ResourceRegistry(child) } }) + await fiber.await() + const { provider } = scriptedProvider() + registry!.register(provider) + registry!.source(A).subscribe(() => {}) + expect(provider.open).toHaveBeenCalledTimes(1) + await fiber.dispose() + expect(registry!.source(A).getSnapshot().status).toBe('none') + }) +}) + +describe('ResourceRegistry holders', () => { + it('opens on the first subscriber only, and closes after the last one leaves', async () => { + const b = bench() + b.registry.register(b.provider) + const source = b.registry.source(A) + expect(b.provider.open).not.toHaveBeenCalled() + + const first = source.subscribe(() => {}) + const second = source.subscribe(() => {}) + expect(b.provider.open).toHaveBeenCalledTimes(1) + b.last().push('v1') + await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v1') }) + + first() + first() + expect(b.last().ctx.signal.aborted).toBe(false) + expect(source.getSnapshot().value).toBe('v1') + + second() + expect(b.last().ctx.signal.aborted).toBe(true) + expect(source.getSnapshot()).toMatchObject({ status: 'loading', value: undefined }) + }) + + it('keeps one source per address and separates addresses', () => { + const b = bench() + expect(b.registry.source(A)).toBe(b.registry.source(A)) + expect(b.registry.source(A)).not.toBe(b.registry.source(`${RESOURCE_SCHEME}://feed/two`)) + }) + + it('pins hold the address open until the signal aborts; an aborted signal pins nothing', () => { + const b = bench() + b.registry.register(b.provider) + const controller = new AbortController() + b.registry.pin(A, controller.signal) + expect(b.provider.open).toHaveBeenCalledTimes(1) + controller.abort() + expect(b.last().ctx.signal.aborted).toBe(true) + + const aborted = new AbortController() + aborted.abort() + b.registry.pin(A, aborted.signal) + expect(b.provider.open).toHaveBeenCalledTimes(1) + }) + + it('hands a remounting subscriber the latest value without reopening while a pin holds it', async () => { + const b = bench() + b.registry.register(b.provider) + const pin = new AbortController() + b.registry.pin(A, pin.signal) + const source = b.registry.source(A) + + const unsubscribe = source.subscribe(() => {}) + b.last().push('v1') + b.last().push('v2') + await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v2') }) + unsubscribe() + b.last().push('v3') + await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v3') }) + + const seen = vi.fn() + source.subscribe(seen) + expect(source.getSnapshot()).toMatchObject({ status: 'live', value: 'v3' }) + expect(b.provider.open).toHaveBeenCalledTimes(1) + expect(seen).not.toHaveBeenCalled() + }) + + it('reopens after the last holder left, as a fresh stream', async () => { + const b = bench() + b.registry.register(b.provider) + const source = b.registry.source(A) + const first = source.subscribe(() => {}) + b.last().push('v1') + await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v1') }) + first() + + const second = source.subscribe(() => {}) + expect(b.provider.open).toHaveBeenCalledTimes(2) + expect(source.getSnapshot()).toMatchObject({ status: 'loading', value: undefined }) + b.last().push('v2') + await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v2') }) + second() + }) +}) + +describe('ResourceRegistry streams', () => { + it('ignores what a released stream still yields, and returns its iterator', async () => { + const b = bench() + b.registry.register(b.provider) + const source = b.registry.source(A) + const unsubscribe = source.subscribe(() => {}) + const feed = b.last() + unsubscribe() + expect(feed.ctx.signal.aborted).toBe(true) + + feed.push('late') + await settle() + await settle() + expect(source.getSnapshot()).toMatchObject({ status: 'loading', value: undefined }) + expect(feed.returned).toBe(true) + }) + + it('keeps the last value live when the stream ends on its own', async () => { + const b = bench() + b.registry.register(b.provider) + const source = b.registry.source(A) + source.subscribe(() => {}) + b.last().push('v1') + b.last().end() + await settle() + await settle() + expect(source.getSnapshot()).toMatchObject({ status: 'live', value: 'v1' }) + }) + + it('reports a failure frame beside the last value, and the next ok frame clears it', async () => { + const b = bench() + b.registry.register(b.provider) + const source = b.registry.source(A) + source.subscribe(() => {}) + b.last().push('v1') + await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v1') }) + const failure = new RemoteError('gateway/bad-request', 'refused', {}) + b.last().fail(failure) + await vi.waitFor(() => { expect(source.getSnapshot().status).toBe('failed') }) + expect(source.getSnapshot()).toMatchObject({ value: 'v1', failure }) + b.last().push('v2') + await vi.waitFor(() => { expect(source.getSnapshot().status).toBe('live') }) + expect(source.getSnapshot()).toMatchObject({ value: 'v2', failure: undefined }) + }) + + it('reports a failure frame that arrives first with no value', async () => { + const b = bench() + b.registry.register(b.provider) + const source = b.registry.source(A) + source.subscribe(() => {}) + b.last().fail(new RemoteError('gateway/bad-request', 'refused', {})) + await vi.waitFor(() => { expect(source.getSnapshot().status).toBe('failed') }) + expect(source.getSnapshot()).toMatchObject({ value: undefined, failure: { code: 'gateway/bad-request' } }) + }) + + it('drops a failure frame that follows the release that aborted the stream', async () => { + const b = bench() + b.registry.register(b.provider) + const source = b.registry.source(A) + const unsubscribe = source.subscribe(() => {}) + const feed = b.last() + unsubscribe() + feed.fail(new RemoteError('gateway/internal', 'after abort', {})) + await settle() + await settle() + expect(source.getSnapshot()).toMatchObject({ status: 'loading', failure: undefined }) + }) +}) + +describe('ResourceRegistry reload', () => { + it('forwards reload to the protocol\'s provider, and stays a no-op without one', () => { + const b = bench() + b.snapshot().reload() + b.registry.register(b.provider) + b.snapshot().reload() + expect(b.provider.reload).toHaveBeenCalledWith(A) + + const bare = bench() + bare.registry.register({ protocol: 'feed', open: bare.provider.open }) + expect(() => { bare.snapshot().reload() }).not.toThrow() + }) + + it('keeps one reload function per address across state changes', async () => { + const b = bench() + b.registry.register(b.provider) + const source = b.registry.source(A) + const { reload } = source.getSnapshot() + source.subscribe(() => {}) + b.last().push('v1') + await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v1') }) + expect(source.getSnapshot().reload).toBe(reload) + }) +}) diff --git a/packages/client/resources/tsconfig.json b/packages/client/resources/tsconfig.json new file mode 100644 index 0000000000..30cb2fc68a --- /dev/null +++ b/packages/client/resources/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../store" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-slots" + } + ] +} diff --git a/packages/client/resources/tsdown.config.ts b/packages/client/resources/tsdown.config.ts new file mode 100644 index 0000000000..a6e81e749c --- /dev/null +++ b/packages/client/resources/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-resources', ['lib/types/index.js']) diff --git a/packages/client/store/package.json b/packages/client/store/package.json index 2ca4eb3739..acbea6850b 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 1d792dfbf7..5e414c8bf2 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index be914929fa..52653b5c10 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 1c5781690c..0b39f11322 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 595f20cb1c..7da3a878e9 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { AttachmentId } from '@deepseek-ai/dsh-attachment' @@ -9,6 +10,9 @@ import { ImageGallery, MessageImage } from '../src/MessageImage.tsx' import type { MessageImageLabels } from '../src/MessageImage.tsx' import { MessageImages } from '../src/client/MessageImages.tsx' +// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + afterEach(cleanup) const labels: MessageImageLabels = { @@ -268,6 +272,7 @@ describe('ImageGallery', () => { sessionId: 'message-images-test' as MessageImagesProps['sessionId'], useSession, useSessions, + useResource, useSessionPendingInteraction, useWorkspaces, useProjection: () => undefined, diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 9e7d5bf5c8..173f0c0eef 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 746b9fc2a6..a7b4221628 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -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-chat/README.md -README.md: fa9653f7acab47f352e59971b333c6e691f44191 -README.zh.md: f8dc1968fffc57336d6aece8fe07113d5b99893b +README.md: 5dd3a1c2a52789522629a822c8d522365fe93054 +README.zh.md: 07e8c2a9d41fa3a5d4f1a40e4570b9864822547f diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index fa9653f7ac..5dd3a1c2a5 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -1,5 +1,5 @@ --- -description: "Browser Chat target that renders Session conversation nodes, details, historical images, actions, localization, and scroll state." +description: "Browser Chat target that renders Session conversation nodes, historical images, actions, localization, and scroll state." kind: "package-reference" --- # @deepseek-ai/dsh-client-ui-chat @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. Steering classification retains only next-step Inbox IDs through persistent splice state; next-turn splices create no Chat Context. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the surface selected when the submit begins: transcript echoes render at the flow tail, steering echoes render with the pending-steering marker, and queued echoes stay out of Chat. Each echo is hidden per render once a user/steering node or queue occurrence carries its prompt `rpcId`, so the handoff is atomic. +The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. Steering classification retains only next-step Inbox IDs through persistent splice state; next-turn splices create no Chat Context. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the surface selected when the submit begins: transcript echoes render at the flow tail, steering echoes render with the pending-steering marker, and queued echoes stay out of Chat. Each echo is hidden per render once a user/steering node or queue occurrence carries its prompt `rpcId`, so the handoff is atomic. ## Table of Contents diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index f8dc1968ff..07e8c2a9d4 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -1,5 +1,5 @@ --- -description: "渲染 Session 对话节点、详情、历史图片、操作、本地化和滚动状态的浏览器 Chat target。" +description: "渲染 Session 对话节点、历史图片、操作、本地化和滚动状态的浏览器 Chat target。" kind: "package-reference" --- # @deepseek-ai/dsh-client-ui-chat @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。steering 分类通过持久 splice state 只保留 next-step Inbox ID;next-turn splice 不创建 Chat Context。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的区域:transcript 回显位于消息流末尾,steering 回显带 pending-steering 标记,queued 回显不进入 Chat。一旦 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此交接是原子的。 +Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。steering 分类通过持久 splice state 只保留 next-step Inbox ID;next-turn splice 不创建 Chat Context。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的区域:transcript 回显位于消息流末尾,steering 回显带 pending-steering 标记,queued 回显不进入 Chat。一旦 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此交接是原子的。 ## 目录 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 7b63dd130b..0d8375a8a6 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-client-ui-sidebar-right", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-ui-workspace" ], @@ -55,6 +56,7 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-files": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", @@ -65,6 +67,8 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-textpreview": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", @@ -74,14 +78,14 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-session-turn-outline": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "react": "^18.2.0", - "react-dom": "^18.2.0", - "@deepseek-ai/dsh-settings": "workspace:^" + "react-dom": "^18.2.0" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 0c7d87f344..25169bc0c8 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -3,9 +3,12 @@ import type { Context } from '@deepseek-ai/cordis' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' -import type { BoundActions, ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +// The `file` entry of `SidebarRightResourceParamsMap`, which types `{ params: { line } }` below. +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-textpreview/client' +import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' // Type-only service and declaration merges used by the apply world. import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -15,7 +18,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import type { - ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, DetailsInjected, + ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, TurnTailOwnerProps, } from './contract/slots.ts' import type { ChatSnapshot } from './contract/snapshot.ts' @@ -25,7 +28,6 @@ import { ChatView } from './chat/ChatView.tsx' import { registerChatNodeRenderers } from './chat/register-node-renderers.ts' import { StatsLine } from './chat/StatsLine.tsx' import { registerConversationNodes } from './conversation-nodes/register.ts' -import { DetailsPanel } from './details/DetailsPanel.tsx' import { en, NS, zh } from './locale.ts' import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/TranscriptViewRow.tsx' import { createChatStore } from './stores.ts' @@ -43,8 +45,8 @@ const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = { /** Services required by the Chat target and its presentation registrations. */ export const inject = [ - 'slots', 'sessions', 'uiSession', 'uiConversation', 'layout', 'locale', - 'settingsScope', 'remote', 'remote.session', + 'slots', 'sessions', 'uiSession', 'uiConversation', 'locale', + 'settingsScope', 'remote', 'remote.session', 'sidebarRight', ] /** @@ -103,7 +105,7 @@ export function apply(ctx: Context): void { 'conversation.message.images': { kind: 'single', scope: 'session' }, }, store: chatStore, - inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => { + inject: (sessionId: SessionId): ChatViewInjected => { const binding = ctx.sessions.binding(sessionId) if (binding === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`) const session = binding.session @@ -114,17 +116,25 @@ export function apply(ctx: Context): void { chatNode: key => chat.getSnapshot().nodes.source(key), chatNodeProcess: key => chat.getSnapshot().nodes.processSource(key), }, - openDetails: (target) => { - actions.select(target) - ctx.layout.openDetails() - }, fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner), - openFile: async (path) => { + // Files open in the right Sidebar, not in a desktop application: the + // content stays in the product, beside the conversation that produced + // it. A relative path, or an absolute one inside the session's + // workspace, is addressed under this session's scope, + // `dsh-resource://file/session//`; an absolute path + // elsewhere is addressed as `dsh-resource://file/absolute/` and + // read through the session on screen. Which tab type claims the + // address is the Sidebar's decision, not this call site's. + // A line travels as a navigation parameter, not as part of the + // address: the file is one piece of content whether it is opened at + // its top or at line 400, so the same tab is revealed and told where + // to land. + openFile: async (path, options) => { const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd - const result = await ctx.remote.session.openWorkspacePath({ - path: resolveWorkspacePath(cwd, path), - }) - if (!result.ok) throw new Error(`path open failed: ${result.error.message}`) + const url = fileAddressFor(sessionId, cwd, path) + if (options?.line === undefined) ctx.sidebarRight.openResource(url) + else ctx.sidebarRight.openResource(url, { params: { line: options.line } }) + await Promise.resolve() }, loadOlder: () => { void session.loadOlder() }, loadThrough: seq => session.loadThrough(seq), @@ -160,11 +170,4 @@ export function apply(ctx: Context): void { ctx.slots.inject('conversation.approval.detail', () => ctx.slots.register({ name: 'conversation.approval.detail' }, ApprovalCommand)) - ctx.slots.inject('details', () => ctx.slots.register({ - name: 'details', - locale: NS, - children: { 'conversation.details.tool': { kind: 'single', scope: 'session' } }, - store: chatStore, - inject: (): DetailsInjected => ({ closeDetails: () => { ctx.layout.closeDetails() } }), - }, DetailsPanel)) } diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index e342836fe5..b1d8d9661e 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -37,7 +37,7 @@ function turnOf(node: ChatNode | undefined): number | undefined { /** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */ export const ChatNodeSeat = memo(function ChatNodeSeat({ nodeKey, useChatNode, useChatNodeProcess, historyIncomplete, compactTranscript, - selectedCallId, cwd, openFile, inspectCall, forkAt, + cwd, openFile, inspectCall, forkAt, loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, t, }: ChatNodeSeatProps) { const node = useChatNode(nodeKey) @@ -103,7 +103,6 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ const owner = useMemo(() => node === undefined ? null : { - selectedCallId, cwd, openFile, inspectCall, @@ -113,7 +112,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ fileMentions, turnProcess, }, [ - node, selectedCallId, cwd, openFile, inspectCall, forkAt, + node, cwd, openFile, inspectCall, forkAt, loadImage, renderMessageImages, fileMentions, turnProcess, ]) if (routedNode === undefined || owner === null) return null diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 21e1f78a29..8ff8eca3e0 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -7,7 +7,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' @@ -129,11 +129,6 @@ function openFailureMessage(error: unknown, fallback: string): string { return message === '' ? fallback : message } -/** ProducedFiles opens the session workspace as `.`. */ -function isFolderOpenPath(path: string): boolean { - return path === '.' -} - /** * Prompt-RPC identities already rendered by durable material: user/steering * node sources plus queue occurrences. A submission echo whose identity @@ -246,7 +241,6 @@ export function ChatView({ const openError = useSession(s => s.openError) const hasMore = useSession(s => s.hasMore) const loadingOlder = useSession(s => s.loadingOlder) - const selectedCallId = useStore(s => s.selection?.callId) const compactTranscript = useTranscriptView(mode => mode === 'compact') const inspectCall = useCallback((callId: string) => { openView('trajectory', callId) @@ -257,10 +251,10 @@ export function ChatView({ // gesture; otherwise a cancelled in-flight refusal reopens the dialog. const fileOpenRequest = useRef(0) - const requestOpenFile = useCallback((path: string) => { + const requestOpenFile = useCallback((path: string, options?: OpenFileOptions) => { const id = ++fileOpenRequest.current setFileOpenBusy(true) - void openFile(path).then( + void (options === undefined ? openFile(path) : openFile(path, options)).then( () => { if (id !== fileOpenRequest.current) return setFileOpenError(null) @@ -272,7 +266,7 @@ export function ChatView({ path, message: openFailureMessage( error, - t(isFolderOpenPath(path) ? 'fileOpen.folderUnknown' : 'fileOpen.unknown'), + t('fileOpen.unknown'), ), }) setFileOpenBusy(false) @@ -795,7 +789,6 @@ export function ChatView({ compactTranscript={compactTranscript} useStore={useStore} actions={actions} - selectedCallId={selectedCallId} cwd={cwd} openFile={requestOpenFile} inspectCall={inspectCall} @@ -848,7 +841,6 @@ export function ChatView({ {fileOpenError !== null && ( void @@ -876,7 +867,7 @@ function FileOpenErrorDialog({ open onClose={onClose} closeLabel={t('close')} - title={t(isFolderOpenPath(path) ? 'fileOpen.folderTitle' : 'fileOpen.title')} + title={t('fileOpen.title')} description={message} footer={( <> diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index eb2ed7cf83..3fb8bcfcee 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -3,7 +3,7 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap, - MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, ToolCallBlock, TurnLocation, + MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace, KeyedSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, @@ -13,7 +13,7 @@ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { createChatStore } from '../stores.ts' -import type { ToolCallId, SelectionTarget } from './store.ts' +import type { ToolCallId } from './store.ts' import type { ChatConversationViewNode, ChatNode, ChatNodeKind } from './chat-nodes.ts' import type { ChatNodeProcessSource, ChatNodeSource, ChatSnapshot, ChatTurnProcessPresentation, @@ -30,6 +30,12 @@ export type UseChatNode = KeyedSnapshotSelectorHook +/** Where in a file an open should land. */ +export interface OpenFileOptions { + /** 1-based line to reveal; absent = the file's beginning. */ + readonly line?: number +} + /** Owner currency of the completed-Turn extension chain. */ export interface TurnTailOwnerProps { turn: TurnLocation @@ -71,9 +77,8 @@ export interface ChatNodeTurnDataInjected { /** Stable owner currency delivered to a keyed Chat renderer. */ export interface ChatNodeOwnerProps { - selectedCallId?: ToolCallId | undefined cwd?: string | undefined - openFile: (path: string) => void + openFile: (path: string, options?: OpenFileOptions) => void inspectCall: (callId: ToolCallId) => void forkAt: (seq: number) => void /** @@ -101,12 +106,6 @@ export interface TurnProcessOwnerProps { export type ChatNodeViewProps = PropsRuntime<'conversation.chat.node', Kind> & PropsLocale<'chat'> -/** Tool block rendered in the details panel. */ -export interface DetailsToolOwnerProps { - block: ToolCallBlock - cwd?: string | undefined -} - /** Command-row owner share. */ export interface CommandRowOwnerProps { node: CommandNode @@ -138,8 +137,7 @@ export interface ChatViewInjected { /** Resolve the stable Turn-process source for one Chat Node key. */ chatNodeProcess: (key: string) => ChatNodeProcessSource } - openDetails: (target: SelectionTarget) => void - openFile: (path: string) => Promise + openFile: (path: string, options?: OpenFileOptions) => Promise loadOlder: () => void /** Jump loader: page history back through seq; resolves when the window covers it. */ loadThrough: (seq: SessionSeq) => Promise @@ -163,19 +161,6 @@ export type ChatViewSlotProps = /** Full props of the durable-message image renderer. */ export type MessageImagesProps = PropsRuntime<'conversation.message.images'> & PropsLocale<'conversation'> -/** Details-panel callbacks. */ -export interface DetailsInjected { - closeDetails: () => void -} - -/** Full details-panel props. */ -export type DetailsSlotProps = - PropsRuntime<'details'> - & PropsRenderSlots<'conversation.details.tool'> - & PropsStore - & InjectFace - & PropsLocale<'chat'> - declare module '@deepseek-ai/dsh-client-ui-slots' { interface SessionStandardProps { /** Selector hook over the current Conversation binding's Chat target. */ @@ -225,11 +210,5 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * that entry. With no entries, the standard action row remains unchanged. */ 'conversation.chat.assistant-actions': { kind: 'list'; scope: 'session'; owner: AssistantActionOwnerProps } - /** - * Whole details-panel body for the selected Tool call. The component receives - * the running or settled block and optional workspace root. A registration - * replaces the shipped Tool details renderer; absence uses the raw fallback. - */ - 'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps } } } diff --git a/packages/client/ui-chat/src/client/contract/store.ts b/packages/client/ui-chat/src/client/contract/store.ts index dc7bb4463e..78a248dd54 100644 --- a/packages/client/ui-chat/src/client/contract/store.ts +++ b/packages/client/ui-chat/src/client/contract/store.ts @@ -1,16 +1,8 @@ -/** Chat-owned selection state shared by the transcript and details panel. */ +/** Chat-owned per-Session view state. */ /** Tool call identity as carried by Chat nodes. */ export type ToolCallId = string -/** Selection target for the Chat details linkage channel. */ -export interface SelectionTarget { - turnSeq: number - stepSeq?: number - callId?: ToolCallId - toolName?: string -} - /** One manually expanded Turn answer generation. */ export interface TurnProcessViewEntry { readonly turn: number @@ -19,6 +11,5 @@ export interface TurnProcessViewEntry { /** Per-Session state shared only by the Chat view and details surface. */ export interface ChatStoreState { - selection: SelectionTarget | null turnProcesses: TurnProcessViewEntry[] } diff --git a/packages/client/ui-chat/src/client/details/DetailsPanel.module.css b/packages/client/ui-chat/src/client/details/DetailsPanel.module.css deleted file mode 100644 index 85fdd8c0df..0000000000 --- a/packages/client/ui-chat/src/client/details/DetailsPanel.module.css +++ /dev/null @@ -1,95 +0,0 @@ -/* Details third column, minimal fill: header (name + close) over a - scrolling body of Input/Output code sections. Panel width/squeeze belongs - to layout; this fills whatever the column gives. */ - -.root { - display: flex; - flex-direction: column; - height: 100%; - min-width: 0; - border-left: 0.5px solid var(--dsw-alias-border-l2); - background: var(--dsw-alias-bg-base); -} - -/* figma RightSidebar header frame (I54:42735;43:36451): pad 14/12/12/12, gap 8. */ -.header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 14px 12px 12px; - border-bottom: 0.5px solid var(--dsw-alias-border-l2); -} - -/* figma I54:42735;43:41479: 14/20 wt500. */ -.title { - overflow: hidden; - font-size: 14px; - line-height: 20px; - font-weight: 500; - color: var(--dsw-alias-label-primary); - text-overflow: ellipsis; - white-space: nowrap; -} - -.close { - display: grid; - flex: none; - place-items: center; - width: 28px; - height: 28px; - border: none; - border-radius: 999px; - corner-shape: round; - background: transparent; - color: var(--dsw-alias-label-secondary); - cursor: pointer; -} - -.close:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - -.body { - flex: 1; - min-height: 0; - padding: 12px 16px; - overflow-y: auto; -} - -.empty { - padding: 8px 0; - font-size: 13px; - line-height: 20px; - color: var(--dsw-alias-label-tertiary); -} - -.section { - margin-bottom: 16px; -} - -.sectionLabel { - margin-bottom: 6px; - font-size: 12px; - line-height: 18px; - font-weight: 500; - color: var(--dsw-alias-label-secondary); -} - -/* figma Code-block (I54:42735;43:41429): r12, pad 16, mono 13/22. */ -.code { - margin: 0; - padding: 16px; - border-radius: 12px; - background: var(--dsw-alias-markdown-code-block); - font-family: var(--ds-font-family-code); - font-size: 13px; - line-height: 22px; - color: var(--dsw-alias-label-primary); - white-space: pre-wrap; - word-break: break-word; -} - -.code[data-error] { - color: var(--dsw-alias-state-error-primary); -} diff --git a/packages/client/ui-chat/src/client/details/DetailsPanel.tsx b/packages/client/ui-chat/src/client/details/DetailsPanel.tsx deleted file mode 100644 index 05c997e1fe..0000000000 --- a/packages/client/ui-chat/src/client/details/DetailsPanel.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { Fragment } from 'react' -import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives' -import { shallowEqual } from '@deepseek-ai/dsh-client-store' -import type { DetailsSlotProps } from '../contract/slots.ts' -import type { ChatSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } from '../contract/snapshot.ts' -import { findToolCall } from './tool-node-reader.ts' -import css from './DetailsPanel.module.css' - -export type DetailsPanelProps = DetailsSlotProps - -/** The snapshot-owned block reference must remain stable across unrelated frames. */ -interface CallMaterial { - name: string - argsRaw: string | null - block: ToolCallBlock -} - -function settledMaterial(node: ToolResultNode, callId: string): CallMaterial { - return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, block: node } -} - -function runningMaterial(call: RunningToolCall): CallMaterial { - return { name: call.name, argsRaw: call.argsRaw, block: call } -} - -function materialFor(s: ChatSnapshot, callId: string): CallMaterial | null { - const found = findToolCall(s, callId) - if (found === undefined) return null - return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found) -} - -function pretty(raw: string): string { - try { - return JSON.stringify(JSON.parse(raw), null, 2) - } catch { - return raw - } -} - -/** Flatten a settled result for the no-ui-tool fallback. */ -function rawResultText(block: ToolCallBlock): string { - if (!('kind' in block)) return '' - const parts = block.content.map(item => item.type === 'text' ? item.text : JSON.stringify(item, null, 2)) - if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`) - return parts.join('\n') -} - -export function DetailsPanel({ useChat, useSessions, sessionId, useStore, renderSlot, closeDetails, t }: DetailsPanelProps) { - const selection = useStore(s => s.selection) - // Session workspace root: a card model resolves omitted or relative - // tool paths against it without reading Session services. - const sessionCwd = useSessions(list => list.byId[sessionId]?.cwd) - const callId = selection?.callId - // materialFor builds a fresh wrapper; shallowEqual short-circuits on its - // stable members (result node reference rides the snapshot's structural sharing). - const material = useChat( - s => (callId === undefined ? null : materialFor(s, callId)), - (a, b) => shallowEqual(a, b)) - return ( -

-
-
- {selection === null ? t('details.title') : material?.name ?? selection.toolName ?? t('details.title')} -
- -
-
- {selection === null || callId === undefined - ?
{t('details.empty')}
- : material === null - ?
{t('details.notInWindow')}
- : ( - <> - {material.argsRaw !== null && ( -
-
{t('details.input')}
- -
- )} -
-
{t('details.output')}
- {/* Keyed by the selected call: the body owns per-call view - state (the terminal card's expand and copy), which React - would otherwise carry into the next selection because the - panel does not unmount between calls. */} - - {renderSlot('conversation.details.tool', { block: material.block, cwd: sessionCwd }, { - fallback: 'kind' in material.block - ? ( -
-                            {rawResultText(material.block)}
-                          
- ) - :
{t('details.running')}
, - })} -
-
- - )} -
-
- ) -} diff --git a/packages/client/ui-chat/src/client/details/tool-node-reader.ts b/packages/client/ui-chat/src/client/details/tool-node-reader.ts deleted file mode 100644 index 9e2488b68f..0000000000 --- a/packages/client/ui-chat/src/client/details/tool-node-reader.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { ChatNode } from '../contract/chat-nodes.ts' -import type { ChatNodeStore, ChatSnapshot, ToolCallBlock } from '../contract/snapshot.ts' - -function toolNode(node: ReturnType): ChatNode<'tool-call'> | undefined { - return node?.kind === 'tool-call' ? node as ChatNode<'tool-call'> : undefined -} - -/** - * Find any root or nested Tool lifecycle through the internal Node store. - * @param snapshot - current Conversation snapshot. - * @param callId - root or nested call identity. - * @returns current Tool lifecycle when materialized in the loaded window. - */ -export function findToolCall(snapshot: ChatSnapshot, callId: string): ToolCallBlock | undefined { - const visit = (block: ToolCallBlock): ToolCallBlock | undefined => { - if (block.callId === callId) return block - for (const child of block.subCalls) { - const found = visit(child) - if (found !== undefined) return found - } - return undefined - } - for (const node of snapshot.nodes.values()) { - const root = toolNode(node)?.data.root - if (root === undefined) continue - const found = visit(root) - if (found !== undefined) return found - } - return undefined -} diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 5cae4dccd7..450ce00fce 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -27,14 +27,13 @@ export type { FinalAssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnProcessChatData, TurnTailChatData, } from './contract/chat-nodes.ts' -export type { ChatStoreState, SelectionTarget, ToolCallId, TurnProcessViewEntry } from './contract/store.ts' +export type { ChatStoreState, ToolCallId, TurnProcessViewEntry } from './contract/store.ts' export type { TranscriptViewRowInjected, TranscriptViewRowProps } from './settings/TranscriptViewRow.tsx' export type { TranscriptViewMode } from '../chat-settings.ts' export type { AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected, ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps, - CommandRowOwnerProps, CommandRowProps, DetailsInjected, DetailsSlotProps, - DetailsToolOwnerProps, MessageImagesProps, + CommandRowOwnerProps, CommandRowProps, MessageImagesProps, OpenFileOptions, TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, } from './contract/slots.ts' export type { diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index f10a9ced20..09011977b0 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -17,13 +17,6 @@ export const zh = { 'stats.tokensPerSecond': '{throughput} tok/s', 'stats.cacheHit': '缓存命中 {percent}%', 'stats.tokens': '输入 {input} tok · 输出 {output} tok', - 'details.title': '详情', - 'details.close': '关闭详情', - 'details.empty': '点击消息流中的工具行查看详情', - 'details.notInWindow': '该调用不在当前窗口内', - 'details.input': '输入', - 'details.output': '输出', - 'details.running': '运行中…', 'chat.loadingHistory': '载入历史…', 'chat.loadError': '历史加载失败:{message}({code})', 'chat.loadOlder': '加载更早', @@ -39,8 +32,6 @@ export const zh = { 'settings.transcript.compact': 'Compact', 'fileOpen.title': '无法打开文件', 'fileOpen.unknown': '无法打开此文件', - 'fileOpen.folderTitle': '无法打开文件夹', - 'fileOpen.folderUnknown': '无法打开此文件夹', 'message.extraBlock': '附加内容块', 'message.systemPrompt': '系统提示词', 'message.contextInjection': '上下文注入', @@ -134,13 +125,6 @@ export const en = { 'stats.tokensPerSecond': '{throughput} tok/s', 'stats.cacheHit': 'Cache hit {percent}%', 'stats.tokens': 'Input {input} tok · Output {output} tok', - 'details.title': 'Details', - 'details.close': 'Close details', - 'details.empty': 'Click a tool row in the message flow to view its details', - 'details.notInWindow': 'This call is outside the current window', - 'details.input': 'Input', - 'details.output': 'Output', - 'details.running': 'Running…', 'chat.loadingHistory': 'Loading history…', 'chat.loadError': 'Failed to load history: {message} ({code})', 'chat.loadOlder': 'Load earlier', @@ -156,8 +140,6 @@ export const en = { 'settings.transcript.compact': 'Compact', 'fileOpen.title': 'Couldn’t open file', 'fileOpen.unknown': 'Couldn’t open this file', - 'fileOpen.folderTitle': 'Couldn’t open folder', - 'fileOpen.folderUnknown': 'Couldn’t open this folder', 'message.extraBlock': 'Extra content block', 'message.systemPrompt': 'System prompt', 'message.contextInjection': 'Context injection', diff --git a/packages/client/ui-chat/src/client/stores.ts b/packages/client/ui-chat/src/client/stores.ts index a0115445e2..afb99e2f5d 100644 --- a/packages/client/ui-chat/src/client/stores.ts +++ b/packages/client/ui-chat/src/client/stores.ts @@ -1,9 +1,8 @@ -/** Per-Session Chat selection store shared by the transcript and details panel. */ +/** Per-Session Chat view store. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' -import type { ChatStoreState, SelectionTarget, TurnProcessViewEntry } from './contract/store.ts' +import type { ChatStoreState, TurnProcessViewEntry } from './contract/store.ts' type ChatActions = { - select: (draft: ChatStoreState, target: SelectionTarget | null) => void setTurnProcessOpen: ( draft: ChatStoreState, turn: number, @@ -26,14 +25,13 @@ export function storedTurnProcessEntry( } /** - * Create the Chat selection store handle. + * Create the Chat view store handle. * @returns a handle instantiated once per rendered Session scope. */ export function createChatStore(): EngineStoreHandle { return defineStore({ - init: (): ChatStoreState => ({ selection: null, turnProcesses: [] }), + init: (): ChatStoreState => ({ turnProcesses: [] }), actions: { - select: (draft, target: SelectionTarget | null) => { draft.selection = target }, setTurnProcessOpen: (draft, turn, answerStep, open) => { const index = draft.turnProcesses.findIndex(entry => entry.turn === turn) if (!open) { diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index fa7cb20093..590cc5602c 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -5,7 +5,7 @@ import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { - RemoteError, SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages, + SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime' import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' @@ -13,7 +13,7 @@ import { apply as applyConversation, inject as injectConversation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { - apply as applyChat, inject as injectChat, type ChatViewInjected, type DetailsInjected, + apply as applyChat, inject as injectChat, type ChatViewInjected, } from '@deepseek-ai/dsh-client-ui-chat/client' import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import { createChatStore } from '../src/client/stores.ts' @@ -48,8 +48,10 @@ function sessionFakeFor() { async function bench() { const runtime = await SlotTestRuntime.create() runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } + const layout = { closeRightbar: vi.fn(), openRightbar: vi.fn() } runtime.ctx.provide('layout', layout as never) + const sidebarRight = { openResource: vi.fn<(address: string) => void>() } + runtime.ctx.provide('sidebarRight', sidebarRight as never) const openWorkspacePath = vi.fn( () => Promise.resolve({ ok: true, value: { opened: true } }), ) @@ -68,7 +70,6 @@ async function bench() { runtime.slots.installLocale(locale) await runtime.root.declare({ 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, }, (_props: { renderSlot?: unknown }) => null) await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) await runtime.mount({ inject: [...injectChat], apply: applyChat }) @@ -83,7 +84,7 @@ async function bench() { ) => ChatViewInjected)(id, instance.actions) return { instance, injected } } - return { runtime, layout, openWorkspacePath, session, chatViewApi } + return { runtime, layout, openWorkspacePath, sidebarRight, session, chatViewApi } } describe('Chat inject API', () => { @@ -112,28 +113,45 @@ describe('Chat inject API', () => { await b.runtime.dispose() }) - it('writes Chat selection before opening details', async () => { - const b = await bench() - const { instance, injected } = b.chatViewApi(ROOT) - injected.openDetails({ turnSeq: 2, callId: 'c1' }) - expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' }) - expect(b.layout.openDetails).toHaveBeenCalledOnce() - expect(b.runtime.storeOf('details', ROOT)).toBe(instance) - expect(b.runtime.storeOf('conversation.session', ROOT)).not.toBe(instance) - await b.runtime.dispose() - }) - - it('resolves file paths against the Session cwd and preserves failures', async () => { + it('addresses file paths under the Session\'s scope and opens them in the right Sidebar', async () => { const b = await bench() const { injected } = b.chatViewApi(ROOT) await injected.openFile('src/a.ts') - expect(b.openWorkspacePath).toHaveBeenCalledWith({ path: '/proj/src/a.ts' }) + // Files stay in the product: a relative path is handed to the Sidebar as an + // address under this session's scope, not to a desktop opener. + expect(b.sidebarRight.openResource).toHaveBeenCalledWith('dsh-resource://file/session/root-1/src/a.ts') + expect(b.openWorkspacePath).not.toHaveBeenCalled() - b.openWorkspacePath.mockResolvedValueOnce({ - ok: false, - error: new RemoteError('gateway/internal', 'xdg-open is not available', {}), - }) - await expect(injected.openFile('src/b.ts')).rejects.toThrow('path open failed: xdg-open is not available') + // An absolute path inside the session's workspace is the same session-relative address. + await injected.openFile('/proj/src/a.ts') + expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/src/a.ts') + + // A name a URL would otherwise mangle survives the round trip. + await injected.openFile('src/a b#c.ts') + expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/src/a%20b%23c.ts') + + // A line travels as the `file` type's navigation parameter, not in the address. + await injected.openFile('src/a.ts', { line: 7 }) + expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/src/a.ts', { params: { line: 7 } }) + await b.runtime.dispose() + }) + + it('keeps a relative path under the Session without a cwd, and addresses a path outside the workspace absolutely', async () => { + const b = await bench() + const NO_CWD = 'root-2' as SessionId + await b.runtime.sessions.add({ + id: NO_CWD, + summary: { title: 'N', displayTitle: 'N' }, + session: sessionFakeFor(), + }, { current: false }) + const { injected } = b.chatViewApi(NO_CWD) + // The Host resolves the relative path against the root it holds for the + // Session; the Client need not know it. + await injected.openFile('src/a.ts') + expect(b.sidebarRight.openResource).toHaveBeenCalledWith('dsh-resource://file/session/root-2/src/a.ts') + // An absolute path outside every known root carries no Session in its address. + await injected.openFile('/abs/a.ts') + expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/absolute/abs/a.ts') await b.runtime.dispose() }) @@ -149,17 +167,6 @@ describe('Chat inject API', () => { await b.runtime.dispose() }) - it('closes details while sharing selection through the Chat store', async () => { - const b = await bench() - const entry = b.runtime.slots.entries('details')[0]! - const injected = (entry.inject as unknown as () => DetailsInjected)() - expect(Object.keys(injected)).toEqual(['closeDetails']) - injected.closeDetails() - expect(b.layout.closeDetails).toHaveBeenCalledOnce() - expect(b.runtime.storeOf('details', ROOT)).toBe(b.runtime.storeOf('conversation.view', ROOT)) - await b.runtime.dispose() - }) - it('owns image loading, scroll memory, and optional closing-file mentions', async () => { const b = await bench() const { injected } = b.chatViewApi(ROOT) diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index 23d2384ec4..fc1cf43bc5 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -41,7 +41,8 @@ async function bench() { ? chatSettings.scope : stubSettingsScope().scope, } as never) - runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() } as never) + runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never) + runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never) runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID), } as never) @@ -53,7 +54,6 @@ async function bench() { runtime.slots.installLocale(locale) await runtime.root.declare({ 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, 'conversation.approval.detail': { kind: 'single', scope: 'session' }, 'settings.general.item': { kind: 'list', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) @@ -68,12 +68,12 @@ async function bench() { return { runtime, conversation, chat, chatSettings, sourceDescriptor } } -function storeOf(runtime: SlotTestRuntime, key: 'conversation.session' | 'conversation.session.header' | 'conversation.view' | 'details') { +function storeOf(runtime: SlotTestRuntime, key: 'conversation.session' | 'conversation.session.header' | 'conversation.view') { return (runtime.slots.entries(key)[0] as { store?: unknown } | undefined)?.store } describe('Chat apply wiring', () => { - it('contributes Chat View, node renderers, stats, and details', async () => { + it('contributes Chat View, node renderers, and stats', async () => { const b = await bench() const views = b.runtime.slots.entries('conversation.view') expect(views.map(row => row.options.id)).toEqual(['chat']) @@ -84,7 +84,6 @@ describe('Chat apply wiring', () => { .toEqual(['stats']) expect(b.runtime.slots.entries('settings.general.item').map(row => row.options.id)) .toEqual(['transcript-view', 'composer-enter']) - expect(b.runtime.slots.entries('details')).toHaveLength(1) await b.runtime.dispose() }) @@ -111,7 +110,6 @@ describe('Chat apply wiring', () => { const conversationStore = storeOf(b.runtime, 'conversation.session') const chatStore = storeOf(b.runtime, 'conversation.view') expect(storeOf(b.runtime, 'conversation.session.header')).toBe(conversationStore) - expect(storeOf(b.runtime, 'details')).toBe(chatStore) expect(chatStore).toBeDefined() expect(chatStore).not.toBe(conversationStore) await b.runtime.dispose() diff --git a/packages/client/ui-chat/tests/chat-store.client.spec.ts b/packages/client/ui-chat/tests/chat-store.client.spec.ts index 2be88ae9b8..fb0f330821 100644 --- a/packages/client/ui-chat/tests/chat-store.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-store.client.spec.ts @@ -2,28 +2,6 @@ import { describe, expect, it } from 'vitest' import { createChatStore } from '../src/client/stores.ts' describe('createChatStore', () => { - it('starts without a selected Chat target', () => { - const store = createChatStore().create() - expect(store.store.getSnapshot()).toEqual({ selection: null, turnProcesses: [] }) - }) - - it('selects and clears one Chat details target', () => { - const store = createChatStore().create() - store.actions.select({ turnSeq: 3, callId: 'c1', toolName: 'bash' }) - expect(store.store.getSnapshot().selection) - .toEqual({ turnSeq: 3, callId: 'c1', toolName: 'bash' }) - store.actions.select(null) - expect(store.store.getSnapshot().selection).toBeNull() - }) - - it('creates independent instances', () => { - const handle = createChatStore() - const first = handle.create() - const second = handle.create() - first.actions.select({ turnSeq: 1 }) - expect(second.store.getSnapshot().selection).toBeNull() - }) - it('stores only manually expanded Turn-process answers', () => { const store = createChatStore().create() store.actions.setTurnProcessOpen(2, 3, true) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 074ae15330..7b72d280dc 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -1,12 +1,13 @@ // @vitest-environment jsdom +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { useEffect } from 'react' import type { AssistantMessageNode, ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatSnapshot, ChatViewSlotProps, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode, - LegacyConversationSlice, ModelRetryNode, RunningToolCall, SelectionTarget, SteeringMessageNode, + LegacyConversationSlice, ModelRetryNode, RunningToolCall, SteeringMessageNode, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UseChatNodeTurnData, TranscriptViewMode, UserMessageNode, } from '@deepseek-ai/dsh-client-ui-chat/client' @@ -43,6 +44,9 @@ import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snaps import type { TurnProcessSpec } from '../src/client/contract/turn-process.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' +// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + afterEach(() => { cleanup() vi.unstubAllGlobals() @@ -245,7 +249,6 @@ function makeHarness( const useChatNodeProcess = bindKeyedSnapshotSelector( key => chatSource.source.getSnapshot().nodes.processSource(key), ) - const openDetails = vi.fn<(t: SelectionTarget) => void>() const openFile = vi.fn<(path: string) => Promise>().mockResolvedValue(undefined) const loadOlder = vi.fn() const loadThrough = vi.fn<(seq: number) => Promise>().mockResolvedValue(undefined) @@ -267,7 +270,6 @@ function makeHarness( callId: string toolName: string block: ToolCallBlock - selectedCallId: string | undefined openFile: ChatNodeOwnerProps['openFile'] inspectCall: ChatNodeOwnerProps['inspectCall'] }> = [] @@ -339,7 +341,6 @@ function makeHarness( callId: block.callId, toolName, block, - selectedCallId: nodeOwner.selectedCallId, openFile: nodeOwner.openFile, inspectCall: nodeOwner.inspectCall, } @@ -371,6 +372,7 @@ function makeHarness( useConversation: bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT)), useTrajectory: (() => { throw new Error('unused') }), useSessions: emptySessions(), + useResource, useSessionPendingInteraction: bindSnapshotSelector( createSnapshotStore(new Map()), ), @@ -392,7 +394,6 @@ function makeHarness( viewRequest: null, openView, completeViewRequest: () => {}, - openDetails, openFile, loadOlder, loadThrough, @@ -421,12 +422,11 @@ function makeHarness( } session.set(sessionUpdate) } - const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) } return { set, setSession: session.set, setChat: chatSource.set, ChatView, props, - openDetails, openFile, loadOlder, loadThrough, openView, + openFile, loadOlder, loadThrough, openView, setOutline: (value: unknown) => { outlineValue = value }, - chatScroll, forkAt, setSelection, toolOwners, + chatScroll, forkAt, toolOwners, setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) }, setNodeRenderer: (renderer: React.ComponentProps['renderSlot']) => { nodeSlotOverride = renderer @@ -564,7 +564,7 @@ describe('ChatView', () => { const afterMount = railRenders expect(afterMount).toBeGreaterThan(0) - act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) }) + act(() => { h.setTranscriptView('compact') }) expect(railRenders).toBe(afterMount) }) @@ -2083,14 +2083,6 @@ describe('ChatView', () => { expect(rowRenders).toBe(afterMount) }) - it('updates the selected call id handed to the Tool seat', () => { - const h = makeHarness({ nodes: [toolResult(3, 'a')] }) - render() - expect(h.toolOwners.at(-1)?.selectedCallId).toBeUndefined() - act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) }) - expect(h.toolOwners.at(-1)?.selectedCallId).toBe('a') - }) - it('hands running calls to a live Tool group', () => { const h = makeHarness({ runningCalls: [runningCall('r1')] }, { running: true }) const view = render() @@ -2183,7 +2175,7 @@ describe('ChatView', () => { expect(calls).toHaveLength(1) expect(calls[0]).toMatchObject({ key: 'conversation.chat.node', - owner: { node: { kind: 'tool-call' }, selectedCallId: undefined }, + owner: { node: { kind: 'tool-call' } }, entryKey: 'tool-call', }) const owner = calls[0]?.owner as RoutedChatNodeOwner @@ -2243,18 +2235,6 @@ describe('ChatView', () => { }) }) - it('names a workspace-folder Host refusal as a folder', async () => { - const openFile = vi.fn<(path: string) => Promise>() - .mockRejectedValueOnce(new Error('')) - const h = makeHarness({ nodes: [toolResult(3, 'a')] }) - h.props.openFile = openFile - render() - await act(async () => { h.toolOwners[0]!.openFile('.') }) - await waitFor(() => { - expect(screen.getByRole('dialog', { name: '无法打开文件夹' }).textContent).toContain('无法打开此文件夹') - }) - }) - it('ignores a Host refusal that settles after the dialog is dismissed', async () => { let rejectRetry!: (error: unknown) => void const openFile = vi.fn<(path: string) => Promise>() diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index 9f7d3dd062..c74cf3bd63 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -3,23 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { - SessionListState, SessionSnapshot, -} from '@deepseek-ai/dsh-api-session-controller/client' -import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { SessionProviderComponent } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' -import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { - DetailsSlotProps, DetailsToolOwnerProps, RunningToolCall, SelectionTarget, -} from '@deepseek-ai/dsh-client-ui-chat/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { createChatStore } from '../src/client/stores.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' import { StatsLine } from '../src/client/chat/StatsLine.tsx' -import { DetailsPanel } from '../src/client/details/DetailsPanel.tsx' import { zh } from '../src/client/locale.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' @@ -39,44 +25,6 @@ afterEach(() => { vi.unstubAllGlobals() }) -const SID = 's1' as SessionId - -/** Minimal framework seat for direct DetailsPanel host tests. */ -const SessionProviderStub: SessionProviderComponent = ({ children }) => children - -/** Observe the owner currency without importing the Tool details renderer. */ -function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotProps['renderSlot'] { - return (_key, owner) => { - owners?.push(owner as unknown as DetailsToolOwnerProps) - return
- } -} - -function sessionSnapshot(): SessionSnapshot { - return { - sessionId: SID, - queue: [], - pendingSubmissions: [], - running: false, - removed: false, - openState: 'open', - openError: null, - hasMore: false, - loadingOlder: false, - promptError: null, - blank: false, - subagent: null, - lastAgentError: null, - promptAttempted: true, - awaitingFirstTurn: false, - } -} - -function emptyWorkspaces() { - return createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - }) -} describe('render branch tails', () => { it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => { @@ -126,116 +74,4 @@ describe('render branch tails', () => { expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() }) - it('DetailsPanel title falls to 详情 when the selection has no toolName and no material', () => { - localStorage.clear() - const session = sessionSnapshot() - const chatSnapshot = chatSnapshotFixture() - const chat = createChatStore().create() - chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget) - const emptyList = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) - const workspaces = emptyWorkspaces() - const view = render( - { throw new Error('unused') })} - useSessions={bindSnapshotSelector(emptyList)} - useSessionPendingInteraction={bindSnapshotSelector( - createSnapshotStore(new Map()), - )} - useWorkspaces={bindSnapshotSelector(workspaces)} - useProjection={(() => undefined)} - useInput={(() => { throw new Error('unused') })} - inputActions={{ - setDraft: () => {}, - addAttachments: () => true, - removeAttachment: () => {}, - pruneAttachments: () => {}, - submit: () => {}, - }} - useStore={bindSnapshotSelector(chat)} - actions={chat.actions} - closeDetails={vi.fn()} - t={t} - />, - ) - expect(view.getByText('详情')).toBeTruthy() - expect(view.getByText('该调用不在当前窗口内')).toBeTruthy() - }) - - it('DetailsPanel passes the existing parentCallId through to the Tool details seat', () => { - localStorage.clear() - const session = sessionSnapshot() - const longText = 'x'.repeat(1_000) - const runningCalls: readonly RunningToolCall[] = [{ - callId: 'p1', name: 'run_code', argsRaw: '{}', turn: 1, step: 1, - time: 7_000, subCalls: [{ - kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1', - parentCallId: 'p1', - call: { name: 'run_code', argsRaw: '{"code":"return 1"}' }, - callTime: 8_000, - content: [], isError: false, - subCalls: [{ - kind: 'tool-result', seq: 9, time: 9_000, callId: 'p1:code:1:code:1', - parentCallId: 'p1:code:1', - call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' }, - callTime: 8_500, - content: [{ type: 'text', text: longText }], isError: false, - subCalls: [], - }], - }], - }] - const chatSnapshot = chatSnapshotFixture({ runningCalls }) - const chat = createChatStore().create() - chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget) - const emptyList = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) - const workspaces = emptyWorkspaces() - const owners: DetailsToolOwnerProps[] = [] - const view = render( - { throw new Error('unused') })} - useSessions={bindSnapshotSelector(emptyList)} - useSessionPendingInteraction={bindSnapshotSelector( - createSnapshotStore(new Map()), - )} - useWorkspaces={bindSnapshotSelector(workspaces)} - useProjection={(() => undefined)} - useInput={(() => { throw new Error('unused') })} - inputActions={{ - setDraft: () => {}, - addAttachments: () => true, - removeAttachment: () => {}, - pruneAttachments: () => {}, - submit: () => {}, - }} - useStore={bindSnapshotSelector(chat)} - actions={chat.actions} - closeDetails={vi.fn()} - t={t} - />, - ) - // Chat resolves the selected sub-call and keeps its Code Dispatch parent - // identity on the block handed to the Tool-owned details seat. - expect(view.getByText('read')).toBeTruthy() - expect(view.getByTestId('tool-details-seat')).toBeTruthy() - expect(owners).toHaveLength(1) - expect(owners[0]?.block).toMatchObject({ - callId: 'p1:code:1:code:1', - parentCallId: 'p1:code:1', - call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' }, - content: [{ type: 'text', text: longText }], - }) - }) }) diff --git a/packages/client/ui-chat/tests/selection-survival.client.spec.tsx b/packages/client/ui-chat/tests/selection-survival.client.spec.tsx deleted file mode 100644 index 3da452f418..0000000000 --- a/packages/client/ui-chat/tests/selection-survival.client.spec.tsx +++ /dev/null @@ -1,79 +0,0 @@ -// @vitest-environment jsdom -/** Exercises Chat selection through the real SlotRegistry store axis. */ -import { describe, expect, it } from 'vitest' -import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { createChatStore } from '../src/client/stores.ts' - -const sid = (value: string): SessionId => value as SessionId - -type ChatInstance = ReturnType['create']> - -async function createBench() { - const runtime = await SlotTestRuntime.create() - const chat = createChatStore() - await runtime.root.declare({ - 'conversation.view': { kind: 'list', scope: 'session' }, - 'details': { kind: 'single', scope: 'session' }, - }, (_props: PropsRenderSlots<'conversation.view' | 'details'>) => null) - runtime.slots.register({ name: 'conversation.view', id: 'chat', store: chat }, () => null) - runtime.slots.register({ name: 'details', store: chat }, () => null) - runtime.renderRoot() - return { runtime } -} - -function storeFor( - current: Awaited>, - slot: 'conversation.view' | 'details', - sessionId: SessionId, -): ChatInstance { - return current.runtime.storeOf(slot, sessionId) as ChatInstance -} - -describe('Chat selection survives on its store seat', () => { - it('shares one instance between the Chat View and details panel', async () => { - const b = await createBench() - await b.runtime.sessions.add({ id: 's1' }) - const chat = storeFor(b, 'conversation.view', sid('s1')) - const details = storeFor(b, 'details', sid('s1')) - chat.actions.select({ turnSeq: 3, callId: 'c1' }) - - expect(details).toBe(chat) - expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' }) - await b.runtime.dispose() - }) - - it('isolates Session instances and preserves identity across list projection updates', async () => { - const b = await createBench() - const oneId = sid('s1') - await b.runtime.sessions.add({ id: 's1' }) - await b.runtime.sessions.add({ id: 's2' }) - const one = storeFor(b, 'conversation.view', oneId) - const two = storeFor(b, 'conversation.view', sid('s2')) - one.actions.select({ turnSeq: 1, callId: 'a' }) - two.actions.select({ turnSeq: 9, callId: 'z' }) - - await b.runtime.sessions.updateSummary(oneId, { displayTitle: 'projected' }) - - expect(storeFor(b, 'conversation.view', oneId)).toBe(one) - expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' }) - expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' }) - await b.runtime.dispose() - }) - - it('buries selection with the Session scope', async () => { - const b = await createBench() - await b.runtime.sessions.add({ id: 's1' }) - const doomed = storeFor(b, 'conversation.view', sid('s1')) - doomed.actions.select({ turnSeq: 1 }) - - await b.runtime.sessions.remove('s1') - - await b.runtime.sessions.add({ id: 's1' }) - const reborn = storeFor(b, 'conversation.view', sid('s1')) - expect(reborn).not.toBe(doomed) - expect(reborn.store.getSnapshot()).toEqual({ selection: null, turnProcesses: [] }) - await b.runtime.dispose() - }) -}) diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index f946a28f1a..5aa8e31047 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -4,6 +4,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx' @@ -27,6 +28,9 @@ function noPendingInteraction() { return bindSnapshotSelector(createSnapshotStore(new Map())) } +// The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + function mount(mode: 'normal' | 'compact' = 'compact') { const source = createSnapshotStore(mode) const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) }) @@ -34,6 +38,7 @@ function mount(mode: 'normal' | 'compact' = 'compact') { useSessions: emptySessions(), useSessionPendingInteraction: noPendingInteraction(), useWorkspaces: emptyWorkspaces(), + useResource, useTranscriptView: bindSnapshotSelector(source), setTranscriptView, t: makeTranslate(en), diff --git a/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx b/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx index 231c1520da..c617831691 100644 --- a/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx +++ b/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx @@ -9,10 +9,8 @@ describe('Chat View type chain', () => { base: ConvViewProps, chat: ChatViewSlotProps, ): ReactNode => { - // @ts-expect-error openDetails belongs to the Chat inject face. - void base.openDetails - // @ts-expect-error openDetails accepts a SelectionTarget. - chat.openDetails('nope') + // @ts-expect-error openFile belongs to the Chat inject face. + void base.openFile // @ts-expect-error openFile accepts a path. void chat.openFile({ turnSeq: 1, callId: 'c' }) return null diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index 6ba550f2f1..b1ad52d680 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -86,6 +86,9 @@ { "path": "../ui-session" }, + { + "path": "../ui-sidebar-right" + }, { "path": "../ui-settings" }, @@ -94,6 +97,9 @@ }, { "path": "../ui-workspace" + }, + { + "path": "../ui-sidebar-textpreview" } ] } diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index 2cf32925cb..f4d557914c 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index b8f8b78f78..dd2c75dbee 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 9cc8f95758..9a1b9a62d2 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -279,6 +279,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { 'conversation.session.header.lineage': { kind: 'single', scope: 'session' }, 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, 'conversation.session.header.utilities': { kind: 'list', scope: 'session' }, + 'conversation.session.header.corner': { kind: 'single', scope: 'session' }, }, store: conversationStore, inject: (sessionId: SessionId, actions: BoundActions): ConversationSessionHeaderInjected => ({ diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 272d84b8e3..148d75c8f5 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -139,6 +139,18 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { scope: 'session' owner: ConversationHeaderActionOwnerProps } + /** + * The header's far-right corner, past the utilities' edge and into the + * header's own padding, for one control that must keep its place whether or + * not it currently shows anything. The corner reserves its width while an + * occupant is registered, so the utilities beside it never move; an + * occupant with nothing to show renders a same-size placeholder. + */ + 'conversation.session.header.corner': { + kind: 'single' + scope: 'session' + owner: ConversationHeaderCornerOwnerProps + } /** Registered Conversation target Views, rendered one at a time. */ 'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps } /** Selector-routed replacements for the current Session's resident composer. */ @@ -209,6 +221,12 @@ export interface ConversationHeaderActionOwnerProps { children?: never } +/** The header corner's occupant derives its state from standard Session props. */ +export interface ConversationHeaderCornerOwnerProps { + /** Marker field: the occupant receives no owner-specific values. */ + children?: never +} + /** Plain breadcrumb data handed to the optional lineage renderer. */ export interface ConversationHeaderLineageOwnerProps { /** Session represented by this breadcrumb title. */ @@ -375,6 +393,7 @@ export type ConversationSessionHeaderSlotProps = 'conversation.session.header.lineage' | 'conversation.session.header.actions' | 'conversation.session.header.utilities' + | 'conversation.session.header.corner' > & PropsStore & InjectFace diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index cad5ccfb44..3cbf676c88 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -51,7 +51,7 @@ export type { ComposerAttachment, ComposerAttachmentsOwnerProps, ComposerAttachmentsProps, ComposerFileAttachment, ComposerImageAttachment, DraftFileUpload, DraftFileUploads, ComposerBarInjected, ComposerBarOwnerProps, ComposerBarProps, ComposerChainProps, - ConversationHeaderActionOwnerProps, ConversationHeaderLineageOwnerProps, + ConversationHeaderActionOwnerProps, ConversationHeaderCornerOwnerProps, ConversationHeaderLineageOwnerProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionHeaderSlotProps, ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps, ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps, diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index e7d5af7840..0c7e538c1d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -146,6 +146,22 @@ display: none; } +/* The far-right corner seat reaches 16px into the header's 28px right padding, + so its control sits past the utilities' edge; it is laid out only while an + occupant is registered, and the occupant keeps its width while hidden, so the + utilities never move because of it. */ +.headerCorner { + display: flex; + flex: none; + align-items: center; + margin-left: 12px; + margin-right: -16px; +} + +.headerCorner:empty { + display: none; +} + /* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */ .tabs { position: relative; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index ea7a3fe76f..803c53fc9a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -133,6 +133,9 @@ export function ConversationSessionHeader({
{renderSlot('conversation.session.header.utilities', {})}
+
+ {renderSlot('conversation.session.header.corner', {})} +
{tabs.length > 1 && (
diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index 44022cb553..55f0774b7f 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -11,6 +12,9 @@ import type { EnterBehaviorRowProps } from '../src/client/settings/EnterBehavior import { ComposerSubmissionPolicy } from '../src/client/input/submission-policy.ts' import { en } from '../src/client/locales.ts' +// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + afterEach(() => { cleanup() localStorage.clear() @@ -38,6 +42,7 @@ function mount() { const props: EnterBehaviorRowProps = { useSessions: emptySessions(), useSessionPendingInteraction: noPendingInteraction(), + useResource, useWorkspaces: emptyWorkspaces(), useBusyEnter: bindSnapshotSelector(policy.busyEnter), setBusyEnter, diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index e5d7cd38e7..8e83ef05b8 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -8,6 +8,7 @@ // root listener routes them through the keymap commands); draft writes drive // the shell (jsdom's beforeinput lacks the ranges Lexical needs). +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { $getRoot, $isTextNode } from 'lexical' @@ -31,6 +32,9 @@ import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import { zh } from '../src/client/locales.ts' +// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + afterEach(cleanup) // jsdom implements no Range geometry at all — `Range.prototype.getBoundingClientRect` @@ -162,6 +166,7 @@ function bench(over?: BenchOptions) { useSession: bindSnapshotSelector(session), useConversation: bindSnapshotSelector(createSnapshotStore(conversationFixture())), useSessionPendingInteraction: bindSnapshotSelector(createSnapshotStore(new Map())), + useResource, useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 5224973369..ca98834999 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -5,6 +5,7 @@ * hint / pending), edit freedom, and the published currency's claim seat. * React over jsdom per the client testing discipline; the machine is real. */ +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import type { Context } from '@deepseek-ai/cordis' @@ -24,6 +25,9 @@ import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import { zh } from '../src/client/locales.ts' +// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + // jsdom implements no Range geometry (Lexical's scroll-into-view measures the // caret with one once the surface is genuinely contenteditable). Range.prototype.getBoundingClientRect = () => ({ @@ -54,6 +58,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useSessionPendingInteraction: bindSnapshotSelector( createSnapshotStore(new Map()), ), + useResource, useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 108385664f..51c7f518de 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -8,6 +8,7 @@ * itself is not a dependency of this package; the source below is the * decision-table contract at the `InputTriggerSource` boundary. */ +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' @@ -31,6 +32,9 @@ import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import { zh } from '../src/client/locales.ts' +// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + // jsdom implements no Range geometry (Lexical's scroll-into-view measures the // caret with one once the surface is genuinely contenteditable). Range.prototype.getBoundingClientRect = () => ({ @@ -143,6 +147,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo useSessionPendingInteraction: bindSnapshotSelector( createSnapshotStore(new Map()), ), + useResource, useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 07d13f9d13..b64e57aa54 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -3,6 +3,7 @@ * QueueDock rendering and operations: authoritative rows, inline editing, * collapse state, removal, QueueDock Steer, failure notices, and live retirement. */ +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { useSyncExternalStore } from 'react' @@ -22,8 +23,12 @@ import type { InputState } from '../src/client/contract/input.ts' import { zh } from '../src/client/locales.ts' import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps } from '../src/client/queue/QueueDock.tsx' +// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + afterEach(cleanup) + const SID = 's1' as SessionId const iid = (id: string): QueueItemId => id as QueueItemId @@ -74,6 +79,7 @@ function kitFor(snapshot: SessionSnapshot, injected: Partial sessionId: SID, t, useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, + useResource, useSessionPendingInteraction: bindSnapshotSelector( createSnapshotStore(new Map()), ), diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 2c20f198c5..5ace3f97e5 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ComponentProps, ReactNode } from 'react' import { act, cleanup, fireEvent, render } from '@testing-library/react' @@ -32,12 +33,16 @@ import type { } from '../src/client/contract/slots.ts' import type { ViewTab } from '../src/client/contract/views.ts' +// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + // jsdom implements no Range geometry (Lexical's scroll-into-view measures the // caret with one once the surface is genuinely contenteditable). Range.prototype.getBoundingClientRect = () => ({ top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}), }) + function fakeWiring() { const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const })) const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandAttachments: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` } }) @@ -194,6 +199,7 @@ function mount( useChat={useChat} useTrajectory={useTrajectory} useSessions={props.useSessions} + useResource={useResource} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={props.useWorkspaces} useProjection={(() => undefined)} @@ -219,6 +225,7 @@ function mount( useChat={useChat} useTrajectory={useTrajectory} useSessions={props.useSessions} + useResource={useResource} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={props.useWorkspaces} useProjection={(() => undefined)} @@ -240,6 +247,7 @@ function mount( children} + useResource={useResource} useSession={useSession} useConversation={useConversation} useSessions={props.useSessions} @@ -293,6 +301,7 @@ function mount( useConversation, useSessions: bindSnapshotSelector(sessions), useSessionPendingInteraction, + useResource, useWorkspaces: bindSnapshotSelector(workspaces), useProjection: (() => undefined), useComposerBlock: select => select(options.composerBlock), @@ -437,6 +446,7 @@ describe('ConversationRoot resident composer', () => { expect(b.slotCalls).toContain('conversation.session.header.lineage') expect(b.slotCalls).toContain('conversation.session.header.actions') expect(b.slotCalls).toContain('conversation.session.header.utilities') + expect(b.slotCalls).toContain('conversation.session.header.corner') }) it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => { diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index c3174c5164..01417ca259 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -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-deliverables/README.md -README.md: 567ccca6e7c7668686716b3e33ed43209cfcfee4 -README.zh.md: fde644925a64f1a7d4b9d131b9eb2e2eb384c735 +README.md: bfd4c40a952122db25172bebb077d12800b49308 +README.zh.md: 23c983f938d2d77fa5f33e8dcd2526282268b835 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 567ccca6e7..bfd4c40a95 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -25,7 +25,7 @@ This package renders the deliverables row a finished turn ends with — the file ## Use this package -Mount this plugin alongside `ui-conversation`; a finished turn then ends with the produced-files row between the closing message's body and its action footer. Each chip opens the file through the Host opener, with relative paths resolved against the session cwd; when the row first appears, it queries `session.canOpenWorkspacePath()`, and an omitted-file **Show in folder** action opens the session workspace only when the page is loopback and that query succeeds with `true`. +Mount this plugin alongside `ui-conversation`; a finished turn then ends with the produced-files row between the closing message's body and its action footer. Each chip opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar as a text-preview tab, with relative paths resolved against the session cwd. The row offers no folder action: the Sidebar has no directory form, so an omitted-file remainder is a label only. ### The row @@ -55,7 +55,7 @@ The Node half registers the static `ui:deliverable-file-references` system-promp Read these pages when the deliverables surface is not enough. They move from the row to the turn-tail hole and the decisions behind the vocabulary. - [ui-conversation](../ui-conversation/README.md) — declares the `conversation.chat.turnTail` hole and renders the closing prose. -- [Workspace file links](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md) — the decision behind the produced-files row and the Host open path. +- [Workspace file links](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md) — the decision behind the produced-files row; its Host open path is superseded by the [right Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md). - [Inline file mentions](../../../.agents/notes/archived/feature/2026-08-07-web-inline-file-mentions.md) — the decision behind clickable mentions in the closing prose. - [Client package map](../README.md) — adjacent browser UI packages. @@ -87,7 +87,7 @@ These limits define the current deliverables vocabulary. They are current packag - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. - **Files created indirectly by terminal commands remain outside the matching vocabulary** — naming such a file in inline code does not make it clickable unless a successful mutation location also records that path. -- **Native folder handoff targets the Host desktop** — a browser reached through a non-loopback authority omits the action, as does a deployment reporting no native opener; SSH forwarding that makes a remote Host look loopback-local must set the Session Controller's `nativeOpen: false`. +- **Directories have no destination** — chips open files in the right Sidebar's text preview, which shows files only; the former native folder handoff is gone rather than replaced. ### Dev Note diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index fde644925a..23c983f938 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经 Host 打开器打开文件,相对路径按会话 cwd 解析;该行首次显示时会查询 `session.canOpenWorkspacePath()`,有文件被省略、页面为 loopback 且查询成功返回 `true` 时,**在文件夹中显示**动作才会打开会话工作区。 +与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经属主的 `openFile` 打开文件——chat 视图把它路由到右侧 Sidebar 作为一个文本预览 tab——相对路径按会话 cwd 解析。该行不提供文件夹动作:Sidebar 没有目录形态,因此省略文件的余数只是一个标签才会打开会话工作区。 ### 该行 @@ -55,7 +55,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, 当产出物面不够用时阅读以下页面。它们从该行进入 turn-tail 洞与词表背后的决策。 - [ui-conversation](../ui-conversation/README.zh.md)——声明 `conversation.chat.turnTail` 洞并渲染收尾正文。 -- [工作区文件链接](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md)——产出文件行与宿主打开路径背后的决策。 +- [工作区文件链接](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md)——产出文件行背后的决策;其 Host 打开路径已被[右侧 Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)取代。 - [行内文件提及](../../../.agents/notes/archived/feature/2026-08-07-web-inline-file-mentions.md)——收尾正文可点击提及背后的决策。 - [客户端包映射](../README.zh.md)——相邻的浏览器 UI 包。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 3c1683305b..5cb017c4d2 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Produced-files turn tail and clickable final-response file references for Web", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.module.css b/packages/client/ui-deliverables/src/client/ProducedFiles.module.css index 29310fe389..19cc03c126 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.module.css +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.module.css @@ -85,8 +85,7 @@ text-underline-offset: 3px; } -.file:focus-visible, -.showFolder:focus-visible { +.file:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-border-l3); } @@ -103,14 +102,6 @@ display: inline; } -.showFolder { - display: none; -} - -.lane:has(.more[data-shown='6']) > .showFolder { - display: flex; -} - /* Each band budgets 96px per chip, 8px gaps, and 64px for the remainder. */ @container (max-width: 687px) { .file:nth-of-type(6), @@ -121,14 +112,6 @@ .more[data-shown='5'] { display: inline; } - - .showFolder { - display: none; - } - - .lane:has(.more[data-shown='5']) > .showFolder { - display: flex; - } } @container (max-width: 583px) { @@ -140,14 +123,6 @@ .more[data-shown='4'] { display: inline; } - - .showFolder { - display: none; - } - - .lane:has(.more[data-shown='4']) > .showFolder { - display: flex; - } } @container (max-width: 479px) { @@ -159,14 +134,6 @@ .more[data-shown='3'] { display: inline; } - - .showFolder { - display: none; - } - - .lane:has(.more[data-shown='3']) > .showFolder { - display: flex; - } } @container (max-width: 375px) { @@ -178,14 +145,6 @@ .more[data-shown='2'] { display: inline; } - - .showFolder { - display: none; - } - - .lane:has(.more[data-shown='2']) > .showFolder { - display: flex; - } } @container (max-width: 271px) { @@ -197,32 +156,4 @@ .more[data-shown='1'] { display: inline; } - - .showFolder { - display: none; - } - - .lane:has(.more[data-shown='1']) > .showFolder { - display: flex; - } -} - -.showFolder { - justify-self: start; - align-items: center; - gap: 5px; - margin: 0; - padding: 0 2px; - border: none; - border-radius: 4px; - background: transparent; - color: var(--dsw-alias-label-tertiary); - font: inherit; - line-height: 20px; - cursor: pointer; -} - -.showFolder:hover { - color: var(--dsw-alias-label-secondary); - text-decoration: underline; } diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx index 035bca4900..3908138708 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -1,6 +1,5 @@ -import { useEffect } from 'react' import { LinkIcon, classifyLinkPath } from '@deepseek-ai/dsh-client-ui-primitives' -import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { basename } from './turn-deliverables.ts' import type { NS } from './locales.ts' @@ -9,22 +8,10 @@ import css from './ProducedFiles.module.css' /** Maximum number of file chips rendered before the remainder counter. */ const SHOWN_LIMIT = 6 -/** Registration-side Host capability facts. */ -export interface ProducedFilesInjected { - /** Whether the browser itself is connected over loopback. */ - isLoopback: boolean - /** Load the opener capability when this row first reaches the page. */ - ensureWorkspacePathOpen(): void - hooks: { - /** Current generation's Session workspace opener capability. */ - workspacePathOpen: HostObservable - } -} - -/** Matched paths plus the opener, locale, and injected Host capability. */ +/** Matched paths, the opener, and the locale seat. */ export type ProducedFilesProps = Pick & { matched: readonly string[] -} & PropsLocale & InjectFace +} & PropsLocale function moreLabel(t: ProducedFilesProps['t'], count: number): string { return count === 1 ? t('produced.moreOne') : t('produced.more', { count: String(count) }) @@ -35,12 +22,7 @@ function moreLabel(t: ProducedFilesProps['t'], count: number): string { * @param props - selector-matched paths, the chat view's file opener, and the locale seat. * @returns The produced-files row. */ -export function ProducedFiles({ - matched: paths, openFile, isLoopback, ensureWorkspacePathOpen, useWorkspacePathOpen, t, -}: ProducedFilesProps) { - useEffect(() => { ensureWorkspacePathOpen() }, [ensureWorkspacePathOpen]) - const hostCanOpenPath = useWorkspacePathOpen(available => available === true) - const canOpenPath = isLoopback && hostCanOpenPath +export function ProducedFiles({ matched: paths, openFile, t }: ProducedFilesProps) { const shown = paths.slice(0, SHOWN_LIMIT) return (
@@ -73,16 +55,6 @@ export function ProducedFiles({ ) })}
- {paths.length > 1 && canOpenPath && ( - - )}
) diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index d66a505ff2..01877b7b57 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -9,7 +9,6 @@ */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -38,32 +37,6 @@ export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.se * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const workspacePathOpen = createSnapshotStore(undefined) - let requestedWorkspacePathOpen = false - let capabilityRevision = 0 - let pendingCapability: Promise | undefined - const loadWorkspacePathOpen = (): void => { - if (pendingCapability !== undefined) return - const revision = capabilityRevision - const pending = ctx.remote.session.canOpenWorkspacePath() - .then((result) => { - if (revision === capabilityRevision) workspacePathOpen.set(result.ok && result.value) - }) - .finally(() => { - if (pendingCapability === pending) pendingCapability = undefined - }) - pendingCapability = pending - } - const ensureWorkspacePathOpen = (): void => { - requestedWorkspacePathOpen = true - if (workspacePathOpen.getSnapshot() === undefined) loadWorkspacePathOpen() - } - ctx.on('connection/reset', () => { - capabilityRevision++ - pendingCapability = undefined - workspacePathOpen.set(undefined) - if (requestedWorkspacePathOpen) loadWorkspacePathOpen() - }) ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( @@ -72,11 +45,6 @@ export function apply(ctx: ClientContext): void { name: 'conversation.chat.turnTail', select: selectProducedFiles, locale: NS, - inject: () => ({ - isLoopback: ctx.remote.$host.isLoopback, - ensureWorkspacePathOpen, - hooks: { workspacePathOpen }, - }), }, ProducedFiles), ) // The prose side of the same vocabulary: the chat view reaches this face diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index ec8b587b28..158b659f59 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -9,7 +9,6 @@ export const zh = { 'produced.moreOne': '+ 1 个文件', 'produced.more': '+ {count} 个文件', 'produced.open': '打开 {name}', - 'produced.showInFolder': '在文件夹中显示', } /** English dictionary (same key set). */ @@ -18,7 +17,6 @@ export const en: Record = { 'produced.moreOne': '+ 1 file', 'produced.more': '+ {count} files', 'produced.open': 'Open {name}', - 'produced.showInFolder': 'Show in folder', } /** Union of this namespace's dictionary keys. */ diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 49a93b4ade..9c006c93e7 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -20,8 +20,8 @@ import type { import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' -import { makeTranslate, RemoteError, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' -import { ProducedFiles, type ProducedFilesInjected, type ProducedFilesProps } from '../src/client/ProducedFiles.tsx' +import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { ProducedFiles } from '../src/client/ProducedFiles.tsx' import { basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles, type DeliverablesTurnData, @@ -405,24 +405,12 @@ describe('produced-file Turn data', () => { describe('ProducedFiles row', () => { const t = makeTranslate(zh) - const capability = ( - canOpenPath: boolean | undefined, - isLoopback = true, - ): Pick => { - return { - isLoopback, - ensureWorkspacePathOpen: () => {}, - useWorkspacePathOpen: selector => selector(canOpenPath), - } - } - it('renders the bounded CSS candidates and opens a file or the workspace folder', () => { + it('renders the bounded chips and opens the file it was clicked for', () => { const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts', 'h.ts'] const openFile = vi.fn<(path: string) => void>() - const view = render( - , - ) + const view = render() expect(view.getByText('产物')).toBeTruthy() const row = view.container.querySelector('[data-produced-files-row]') if (!(row instanceof HTMLElement)) throw new Error('produced row missing') @@ -433,24 +421,27 @@ describe('ProducedFiles row', () => { expect(chip.getAttribute('title')).toBe('deep/a.html') expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull() fireEvent.click(chip) + // The row hands over the path it was given; where it opens is the + // Sidebar's decision, not this row's. expect(openFile).toHaveBeenCalledWith('deep/a.html') - - const showFolder = view.getByRole('button', { name: '在文件夹中显示' }) - fireEvent.click(showFolder) - expect(openFile).toHaveBeenLastCalledWith('.') }) - it('keeps the folder action absent without overflow or a local native opener', () => { + it('renders a remainder counter after every chip but the last when every file fits', () => { + const view = render( {}} t={t} />) + const row = view.container.querySelector('[data-produced-files-row]') + if (!(row instanceof HTMLElement)) throw new Error('produced row missing') + expect(within(row).getAllByRole('button')).toHaveLength(3) + // One counter per chip that could be the last visible one; the final chip hides nothing. + expect([...row.querySelectorAll('[data-shown]')].map(node => node.getAttribute('data-shown'))).toEqual(['1', '2']) + }) + + it('offers no folder action, because a directory has no preview to open', () => { const openFile = vi.fn<(path: string) => void>() - const view = render( - , - ) const overflowing = ['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md'] + const view = render() expect(view.queryByRole('button', { name: '在文件夹中显示' })).toBeNull() - for (const unavailable of [capability(false), capability(true, false), capability(undefined)]) { - view.rerender() - expect(view.queryByRole('button', { name: '在文件夹中显示' })).toBeNull() - } + // Nothing in the row reaches the local machine any more. + expect(openFile).not.toHaveBeenCalled() }) it('uses singular English copy when exactly one file is hidden', () => { @@ -458,7 +449,6 @@ describe('ProducedFiles row', () => { {}} - {...capability(false)} t={makeTranslate(en)} />, ) @@ -523,16 +513,9 @@ describe('plugin registration', () => { await fiber.await() const [entry] = ctx.slots.entries('conversation.chat.turnTail') expect(entry).toBeDefined() - const injected = entry?.inject?.() as unknown as ProducedFilesInjected - expect(injected.isLoopback).toBe(false) - expect(typeof injected.ensureWorkspacePathOpen).toBe('function') - expect(injected.hooks.workspacePathOpen.getSnapshot()).toBeUndefined() - ctx.emit('connection/reset') - injected.ensureWorkspacePathOpen() - await vi.waitFor(() => { - expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(true) - }) - injected.ensureWorkspacePathOpen() + // The row needs no injected Host capability: it hands a path to its owner + // and nothing in it reaches the local machine. + expect(entry?.inject).toBeUndefined() // The prose face is live while the plugin is: a produced turn yields a // resolver whose matches open through the owner-supplied opener. @@ -554,52 +537,4 @@ describe('plugin registration', () => { // Fiber teardown retracts the service: the consumer's ctx.get sees the off state. expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined() }) - - it('queries the workspace opener lazily and replaces stale results after reconnect', async () => { - const ctx = new Context() - await ctx.plugin(SlotRegistry).await() - new UiConversation(ctx, { binding: () => undefined } as never) - ctx.slots.register({ - name: 'root', - children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, - } as never, () => null) - const first = Promise.withResolvers<{ ok: true; value: boolean }>() - const second = Promise.withResolvers<{ ok: true; value: boolean }>() - const staleFailure = Promise.withResolvers<{ ok: false; error: RemoteError }>() - const capability = vi.fn() - .mockReturnValueOnce(first.promise) - .mockReturnValueOnce(second.promise) - .mockReturnValueOnce(staleFailure.promise) - .mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'offline', {}) }) - const session = { canOpenWorkspacePath: capability } - ctx.provide('remote', { - $on: () => () => {}, - $host: { home: undefined, isLoopback: true }, - session, - } as never) - ctx.provide('remote.session', session as never) - ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - const entry = ctx.slots.entries('conversation.chat.turnTail')[0] - const injected = entry?.inject?.() as unknown as ProducedFilesInjected - - injected.ensureWorkspacePathOpen() - injected.ensureWorkspacePathOpen() - expect(capability).toHaveBeenCalledOnce() - ctx.emit('connection/reset') - expect(capability).toHaveBeenCalledTimes(2) - first.resolve({ ok: true, value: false }) - await Promise.resolve() - expect(injected.hooks.workspacePathOpen.getSnapshot()).toBeUndefined() - second.resolve({ ok: true, value: true }) - await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(true) }) - - ctx.emit('connection/reset') - ctx.emit('connection/reset') - staleFailure.resolve({ ok: false, error: new RemoteError('gateway/internal', 'stale offline', {}) }) - await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(false) }) - await fiber.dispose() - }) }) diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index c230af8586..a1b3e3296b 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 71841dee4f..a6f9e8ecbc 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml new file mode 100644 index 0000000000..bd0993ceba --- /dev/null +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-dockkit/README.md +README.md: e012295852ad3c957695fcfbd0568a45b3f54856 +README.zh.md: 2423887d60460651a224ffe1cb0fc9b61363c0e2 diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md new file mode 100644 index 0000000000..e012295852 --- /dev/null +++ b/packages/client/ui-dockkit/README.md @@ -0,0 +1,107 @@ +--- +description: "Docking layout kit for the dsh web client: a split tree of tabbed panes with invertible operations, planners, a linear history, and the components that render and drive it." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-dockkit + +English | [中文](README.zh.md) + +## Summary + +A docking layout kit: a split tree of tabbed panes with invertible operations, and the components that render and drive it. The Harness Web client is its first embedder; nothing in here knows that. + +> **Internal engine.** This package is published because the Sidebar links it statically, not as a stable API: its exports — `LayoutState`, `LayoutOp`, the planners, `DockIntents`, `DockLabels`, `DockMode` — may change in any release, and none of them appears in a service interface (`ctx.sidebarRight` exposes operations, never layout snapshots or operation logs). + +## Table of Contents + +- [The two layers](#the-two-layers) +- [Embedding it](#embedding-it) +- [Interaction rules worth keeping](#interaction-rules-worth-keeping) +- [Build shape](#build-shape) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## The two layers + +**The engine** is pure logic — no UI framework, no DOM, no host concepts. + +- A normalized recursive split tree: `nodes` keyed by id, `rootId` for the docked root, `floats` bottom-to-top. `PaneId`, `SplitId`, and `TabId` are branded strings: only a `Mint` (or the kit's own DOM round trip) produces one, so a pane, a split, and a tab never stand in for one another or for a bare string. A floating panel is not a second concept — it is a pane whose `host` is `'float'`, capacity one tab, drawn without a tab strip. +- `applyOp(state, op)` returns the next state **and the operations that undo it**. Inverses are captured when an operation runs, because by undo time the pre-operation state is gone. +- Every operation carries the ids it creates, so `replay(initial, ops)` reproduces the same tree. The engine reads no clock and no random source. +- `Sequencer` keeps a linear history with one entry per intent: the operations one gesture or command produced step back and forward together, a run of consecutive focus-only entries steps as one, and a new entry after stepping back drops the forward branch. +- `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory. An embedder that wants empty panes simply does not call it. +- `DockController` is the intent layer and an observable source (`subscribe` + `getSnapshot`, whose reference only changes when the layout does). + +**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items, and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface. + + +## Embedding it + +Everything host-specific arrives through props: + +| Contract | Carries | +|---|---| +| `DockLabels` | every rendered string, already localized, accessible names included | +| `TabRenderer` | one tab's body (`renderTab`), and optionally what its chip or panel header shows as a title (`renderTabTitle`, falling back to the record's `title`); the embedder dispatches on `tab.kind` | +| `DockIntents` | the settled results of every gesture | + +`DockController` satisfies `DockIntents` as written, so the simplest embedding hands the controller straight to `DockSurface`. An embedder that routes through its own store implements the same method names instead. Two props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`) and `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane). Hiding the add control moves nothing else in the strip. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule. + +`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitAtCapacity` hides the split control at the pane budget; its default is false, and a width-blocked control remains disabled. + +A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOptions`), so what a fresh pane contains is the embedder's decision, not this package's. Content identity is the pair (`kind`, `contentId`): `findContentTab(state, contentId, kind?)` finds the tab showing it anywhere and `findPaneContentTab(state, paneId, contentId, kind?)` within one pane, and `planOpenContent` focuses that tab instead of opening another unless told `revealIfOpened: false`; an explicit `index` seats a new tab at a strip slot rather than at the end. + +`DockSurface` is the docked area. Chrome around it — a rail, a collapsed presentation, any history controls — belongs to the embedder, which reads `state.expanded` and decides; the kit ships no undo/redo control of its own. Surface-wide controls the embedder does want on the surface go through the `chrome` prop, which the kit places at the far end of the top-right pane's tab strip (the last child of every row split, the first of every column split), so a surface needs no header row of its own. `FloatLayer` owns its own gestures and positions panels in viewport coordinates, so it may be mounted anywhere, including a portal. + + +## Interaction rules worth keeping + +These are not stylistic; each one fixes a defect found in a real browser. + +- **Capture the pointer** when a gesture starts. Without it any scroll container the pointer crosses can claim the gesture, which the browser reports as a cancelled pointer and an abandoned drag. Capture is hardening — the window listeners carry the gesture either way, so an environment without the API still works. +- **The chips give way; the strip's end controls never do.** The chip box is the strip's one shrinking part (`flex: 0 1 auto; min-width: 0; overflow: hidden`); the add, split, and chrome controls are `flex: none`, so they keep their width and place in any pane at least as wide as they are (about 130px with the chrome, 72px without). The surface's `min-width: 0` and the pane's `overflow: hidden` stop a body's longest unwrapped line from widening the pane past its box, which is what carried the controls and the body's scrollbar off-screen. +- **The chip box is not a scroll container.** A horizontal scroller claims press-and-move for itself; tabs shrink, ellipsize, and then clip instead. +- **A split needs room for two working halves.** A pane splits into equal halves, so each half must hold what cannot shrink: the strip's fixed part — measured as the strip's width minus the chip box and the fill, which is the padding, the gaps, and every control that pane draws (its own chrome included, so the top-right pane asks more) — plus one chip at its minimum — `.tab` declares `min-width: 44px` on a content-box, so its footprint is 44px plus 10px + 5px of padding, 59px, read from a rendered chip's computed style (the stylesheet figure when none can be read); the divider between the halves takes its rendered thickness (4px). A column split, which only an edge drop makes, needs each half to hold the strip (36px) plus a 48px body: one 13px secondary line at 1.6 line-height inside the body's 12px padding. `halvesFit` in `geometry.ts` is the arithmetic; `measure.ts` reads the rectangles after every commit and whenever the surface resizes, because the layout state carries fractions, never pixels, and the engine's planners stay that way. A pane without room keeps its split control, disabled with `splitPaneNarrow`, and offers no edge drop zone for that axis (the release is then not a move). A pane the user narrows afterwards — a divider or the embedder's column dragged — keeps its size: the rule only decides its next split. +- **Focus lands on click, not on press.** A state change between `pointerdown` and the first `pointermove` rebuilds the pressed subtree, and a replaced element cancels the pointer. It also keeps a drag from recording a redundant focus operation first. Clicks on the chips, the strip's controls, and the embedder's chrome stop at the strip: the intent each reports already decides the active pane, or is the embedder's own, so the pane's click-to-focus records nothing extra. A floating panel's grip and corner report through their gesture the same way — a press released in place is a click that raises the panel, and a drag records only the move or resize, whose operation raises it — while a press on the panel's body raises it directly. A click on the pane that is active already, a click or key on that pane's selected chip, or a press on the panel that is active and on top already, changes nothing and records nothing. +- **A control nested inside a draggable chip stops its own press.** Otherwise the press starts a drag, captures the pointer, and the nested control's click never lands. +- **Emphasis takes the platform's accent, never `--dsw-alias-brand-primary`.** This platform binds `brand-primary` to its near-black (light) or near-white (dark) foreground, so a hovered divider, the drop caret, and the drop-zone hint use `--dsw-alias-brand-primary-new-colorprimary-new-color`, as the trajectory views do. A floating panel's border is the same `--dsw-alias-border-l2` hairline whether it is active or not: the active panel is already on top and casts the shadow; a darker frame around it read as a defect. + + +## Build shape + +The package is statically linked: tsdown's `staticLinked` preset emits one browser ESM bundle at `lib/index.js` (every bare specifier stays an import, sourcemaps chain to the sources) and ships the stylesheet under `lib/` at its `src`-relative path, and the Web shell resolves the package name and bundles that artifact itself, so vite stays the only owner of class hashing. One consequence is load-bearing — the kit keeps **one** stylesheet, `dockkit.module.css`, because a consumer de-duplicates injected sheets by file name and a collision would drop one silently. + + +## Model Experience + +None, as the package is a browser-side docking layout engine and component set that registers nothing model-facing. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +- **Size semantics are deliberately small**: fractional weights with one minimum-size clamp. No snap, priority, or preferred size, so the cascading-squeeze behaviour of a full splitview is absent. +- **Touch is untuned.** Gestures are pointer-based and `touch-action` is set where a scroller would otherwise interfere, but no touch-specific tuning has been done. +- **Accessibility is incomplete**: no `separator` role on dividers and no keyboard route to split, move, or float. +- **No published stylesheet contract.** Consumers get hashed module class names; the kit exposes no theming API beyond the `--dsw-*` custom properties it reads. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
+ +**Runtime invariant:** No companion is published. The engine is pure functions over plain data and the components report intents only; the operation sequence's invertibility and the settle rule are asserted directly by this package's engine specs, and no cordis service is provided or observed. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md new file mode 100644 index 0000000000..2423887d60 --- /dev/null +++ b/packages/client/ui-dockkit/README.zh.md @@ -0,0 +1,107 @@ +--- +description: "dsh Web 客户端的停靠布局套件:带可逆操作的标签格分裂树、planner、线性历史,以及渲染并驱动它的组件。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-dockkit + +[English](README.md) | 中文 + +## 概述 + +一套停靠布局套件:由带可逆操作的标签格组成的分裂树,以及渲染并驱动它的组件。Harness Web 客户端是它的第一个嵌入方;这里的代码对此一无所知。 + +> **内部引擎。** 本包之所以发布,是因为 Sidebar 以静态链接方式使用它,而非作为稳定 API:它的导出——`LayoutState`、`LayoutOp`、各 planner、`DockIntents`、`DockLabels`、`DockMode`——在任何版本都可能变化,并且没有任何一个出现在服务接口里(`ctx.sidebarRight` 只暴露操作,从不暴露布局快照或操作日志)。 + +## 目录 + +- [两层结构](#the-two-layers) +- [如何嵌入](#embedding-it) +- [值得保留的交互规则](#interaction-rules-worth-keeping) +- [构建形态](#build-shape) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 两层结构 + +**引擎**是纯逻辑——没有 UI 框架、没有 DOM、没有宿主概念。 + +- 一棵归一化的递归分裂树:按 id 索引的 `nodes`、指向停靠根的 `rootId`、自底向上排列的 `floats`。`PaneId`、`SplitId`、`TabId` 是带 brand 的字符串:只有 `Mint`(或库自己的 DOM 往返)能产出,因此 pane、split、tab 三种 id 彼此不可互换,也不能拿裸字符串充数。浮窗不是第二个概念——它就是 `host` 为 `'float'` 的格,容量一个 tab,绘制时不带 tab 条。 +- `applyOp(state, op)` 返回下一状态**以及撤销它的操作**。逆操作在操作执行时捕获,因为到撤销时操作前的状态已经不存在了。 +- 每个操作都携带它创建的 id,因此 `replay(initial, ops)` 能复现同一棵树。引擎不读时钟,也不读随机源。 +- `Sequencer` 维护一条线性历史,每个意图一条记录:一次手势或命令产生的操作一起后退、一起前进,连续的纯焦点记录作为一步,后退后的新记录会丢弃前进分支。 +- `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种。想要空格的嵌入方只需不调用它。 +- `DockController` 是意图层,也是一个可观察源(`subscribe` + `getSnapshot`,其引用只在布局变化时才变)。 + +**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目,并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。 + + +## 如何嵌入 + +一切宿主相关的东西都通过 props 进入: + +| 契约 | 承载内容 | +|---|---| +| `DockLabels` | 每一个渲染出来的字符串,已本地化,含无障碍名称 | +| `TabRenderer` | 一个 tab 的正文(`renderTab`),以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 | +| `DockIntents` | 每次手势落定的结果 | + +`DockController` 原样满足 `DockIntents`,所以最简单的嵌入就是把 controller 直接交给 `DockSurface`。经由自己 store 路由的嵌入方则实现同名方法。有两个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)与 `canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)。隐藏添加控件不会移动 tab 条里的其它任何东西。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。 + +`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitAtCapacity` 在达到窗格预算时隐藏分栏控件,默认值为 false;宽度不足的控件仍以禁用状态显示。 + +tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOptions`),因此新格里放什么由嵌入方决定,与本包无关。内容身份是二元组(`kind`、`contentId`):`findContentTab(state, contentId, kind?)` 在任意位置找到展示它的 tab,`findPaneContentTab(state, paneId, contentId, kind?)` 在一个格内找;`planOpenContent` 会聚焦该 tab 而非再开一个,除非被告知 `revealIfOpened: false`;显式的 `index` 把新 tab 放到 tab 条的某个位置而非末尾。 + +`DockSurface` 是停靠区。它周围的 chrome——轨道、折叠形态、任何历史控件——属于嵌入方,由嵌入方读取 `state.expanded` 后自行决定;套件不自带撤销/重做控件。嵌入方确实想放到面上的整面控件通过 `chrome` prop 传入,套件把它放在右上格 tab 条的最末端(每个横向分裂的最后一个子节点、每个纵向分裂的第一个子节点),因此停靠面不需要自己的标题行。`FloatLayer` 拥有自己的手势并以视口坐标定位浮窗,因此可以挂在任何位置,包括 portal 里。 + + +## 值得保留的交互规则 + +这些不是风格偏好;每一条都修复了在真实浏览器里发现的缺陷。 + +- **手势开始时捕获指针。** 不捕获的话,指针经过的任何滚动容器都可能接管手势,浏览器会将其报告为指针取消和拖动中止。捕获是加固——无论如何都由 window 监听器承载手势,所以没有该 API 的环境照样可用。 +- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow: hidden`);添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。 +- **chip 盒不是滚动容器。** 横向滚动容器会把按下并移动据为己有;tab 转而收缩、省略、然后被裁切。 +- **分栏需要给两个可用的半格留出空间。** 格被等分成两半,因此每一半都必须容得下不可收缩的部分:tab 条的固定部分——按 tab 条宽减去 chip 盒与填充条测得,即内边距、间隙以及该格绘制的每个控件(含它自己的 chrome,所以右上格要求更多)——加上一枚最小尺寸的 chip——`.tab` 在 content-box 上声明 `min-width: 44px`,所以它的足印是 44px 加 10px + 5px 内边距,即 59px,从已渲染 chip 的计算样式读取(读不到时用样式表数值);两半之间的分隔条取其渲染厚度(4px)。纵向分栏只由边缘落下产生,它要求每一半容得下 tab 条(36px)加 48px 正文:正文 12px 内边距内一行 13px、行高 1.6 的次级文字。`geometry.ts` 里的 `halvesFit` 是算术;`measure.ts` 在每次提交后与停靠面尺寸变化时读取矩形,因为布局状态只携带比例、从不携带像素,引擎的 planner 也保持如此。没有空间的格保留分栏控件,以 `splitPaneNarrow` 禁用,并且在该轴上不提供边缘落区(松手就不是移动)。用户随后把格拖窄——拖分隔条或拖嵌入方的列——的格保持原尺寸:规则只决定它的下一次分栏。 +- **焦点落在 click 而不是按下。** 在 `pointerdown` 与第一次 `pointermove` 之间的状态变化会重建被按下的子树,而被替换的元素会取消指针。这也避免拖动先记录一条多余的焦点操作。chip、标签条各控件以及嵌入方 chrome 上的 click 都止于标签条:它们各自上报的意图已决定了活动格,或本就是嵌入方自己的事,所以格自身的点击聚焦不再多记一条。浮动面板的抓手与角柄同样通过手势上报——原地松开的按下是一次 click,抬起面板;真正的拖动只记录移动或缩放,由该操作自己抬起面板——而按在面板主体上则直接抬起它。点击本已活动的格、点击或按键选中该格本已选中的 chip,或按下本已活动且在最上层的面板,什么都不改变,也什么都不记录。 +- **嵌套在可拖动 chip 里的控件要拦住自己的按下。** 否则按下会开始拖动、捕获指针,嵌套控件的 click 就永远落不下。 +- **强调色用平台的强调 token,绝不用 `--dsw-alias-brand-primary`。** 本平台把 `brand-primary` 绑定到近黑(浅色)或近白(深色)的前景色,因此悬停的分隔条、落点光标与落区提示都用 `--dsw-alias-brand-primary-new-colorprimary-new-color`,与轨迹视图一致。浮窗的边框无论是否活动都是同一条 `--dsw-alias-border-l2` 细线:活动浮窗本就在最上层并投下阴影;围它一圈更深的边框读起来像缺陷。 + + +## 构建形态 + +本包静态链接:tsdown 的 `staticLinked` 预设在 `lib/index.js` 产出一个浏览器 ESM bundle(所有裸说明符保持为 import,sourcemap 链回源码),并把样式表按其相对 `src` 的路径放到 `lib/` 下;Web 外壳按包名解析并自行打包该产物,因此 vite 仍是 class 哈希的唯一拥有者。有一个后果是承重的——套件只保留**一张**样式表 `dockkit.module.css`,因为消费方按文件名去重注入的样式表,撞名会静默丢掉一张。 + + +## 模型体验 + +None, as the package is a browser-side docking layout engine and component set that registers nothing model-facing. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## 已知限制与延期工作 + + + +- **尺寸语义刻意保持精简**:比例权重加一处最小尺寸夹取。没有吸附、优先级或首选尺寸,因此完整 splitview 的级联挤压行为不存在。 +- **触控未调优。** 手势基于 pointer 事件,并在滚动容器可能干扰处设置了 `touch-action`,但没有做过触控专项调优。 +- **无障碍不完整**:分隔条没有 `separator` 角色,也没有键盘路径去分栏、移动或浮出。 +- **没有发布样式表契约。** 消费方拿到的是哈希化的模块类名;套件除读取的 `--dsw-*` 自定义属性外不暴露任何主题 API。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
+ +**运行时不变量:** 不发布 companion。引擎是作用于纯数据的纯函数,组件只上报意图;操作序列的可逆性与 settle 规则由本包的引擎 spec 直接断言,不提供也不观察任何 cordis 服务。 diff --git a/packages/client/ui-dockkit/package.json b/packages/client/ui-dockkit/package.json new file mode 100644 index 0000000000..984daeca2a --- /dev/null +++ b/packages/client/ui-dockkit/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-dockkit", + "description": "Docking layout kit: split-tree engine with invertible operations, and the React components that render and drive it (zero cordis)", + "version": "0.1.3-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-dockkit" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", + "react-dom": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/**/*.css", + "lib/types/**/*.d.ts" + ], + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/client/ui-dockkit/src/components/DockSurface.tsx b/packages/client/ui-dockkit/src/components/DockSurface.tsx new file mode 100644 index 0000000000..4ff0d3789d --- /dev/null +++ b/packages/client/ui-dockkit/src/components/DockSurface.tsx @@ -0,0 +1,286 @@ +/** + * The docked surface: the split tree plus the tab and divider gestures over it. + * This is the whole kit as far as an embedder's layout column is concerned — + * chrome around it (a rail, a header, a collapsed state) belongs to the embedder. + * + * A gesture only previews until it ends, then leaves through one intent, so the + * embedder's operation sequence stays the single source of truth. Releasing a tab + * clear of this surface floats it; releasing inside it but on no pane is not a + * move at all. + */ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { ReactNode } from 'react' +import type { DockIntents, DockLabels, TabMenuExtras, TabRenderer } from '../contract/adapter.ts' +import type { LayoutState, PaneId, SplitId, TabId } from '../contract/types.ts' +import { clampSizes, FLOAT_DEFAULT_SIZE, MIN_PANE_FRACTION } from '../engine/constraints.ts' +import { getSplit, topRightPaneId } from '../engine/tree.ts' +import type { DropTarget, HalvesFit } from '../engine/geometry.ts' +import { + containsPoint, dividerSizes, floatRectAt, insertionIndex, passedThreshold, zoneInRect, +} from '../engine/geometry.ts' +import { fitOf, measurePaneFits, paneElements, sameFits } from './measure.ts' +import { useGesture } from './pointer.ts' +import { PaneTree, type SizePreview } from './PaneTree.tsx' +import type { PaneCallbacks, SplitBlock } from './render.ts' +import css from './dockkit.module.css' + +/** What the docked surface needs: the layout, its limits, and the outward contracts. */ +export interface DockSurfaceProps { + readonly state: LayoutState + /** + * Whether another pane may still be created: the pane budget. Width is the + * kit's own concern — a pane too narrow for two working halves keeps its + * split control disabled with `labels.splitPaneNarrow` (see README). + */ + readonly canSplit: boolean + /** Hide the split control when the pane budget is spent; defaults to false. Width-blocked controls remain disabled. */ + readonly hideSplitAtCapacity?: boolean + /** Body drop geometry: all edge bands, or left/right halves with whole-pane moves once splitting is unavailable. */ + readonly dropZones?: 'edges' | 'horizontal' + /** Smallest share a divider may leave a pane; defaults to the kit's fraction. */ + readonly minPaneFraction?: number + /** + * Whether a pane's strip draws the add control. Called per docked pane on + * every render; omit to draw one in every pane. `false` leaves the strip's + * end controls where they are and the chips as the only shrinking part. + */ + readonly canAddTab?: (paneId: PaneId) => boolean + readonly intents: DockIntents + readonly labels: DockLabels + readonly renderTab: TabRenderer + /** + * What a tab's chip shows as its title; omit to show the record's `title` + * text. An embedder-internal seam: the Sidebar dispatches it to a per-kind + * slot, and nothing outside that embedder is expected to supply it. + */ + readonly renderTabTitle?: TabRenderer + /** Extra items for a tab's context menu; omit for the kit's own item only. */ + readonly renderTabMenuItems?: TabMenuExtras + /** + * Surface-wide controls, drawn at the far end of the top-right pane's tab + * strip so the surface needs no header of its own. The kit places them; what + * they do is the embedder's. + */ + readonly chrome?: ReactNode + /** + * Called with the room rule's latest readings whenever they change, so an + * embedder driving splits programmatically can honour the same rule the + * split control does. A pane absent from the map has not been measured. + */ + readonly onRoom?: (fits: ReadonlyMap) => void +} + +/** A divider drag: the split it moves and the fractions it started from. */ +interface DividerDrag { + readonly splitId: SplitId + /** The boundary being moved: between child `index` and `index + 1`. */ + readonly index: number + readonly axis: 'row' | 'column' + /** Pointer coordinate along the axis at the press. */ + readonly origin: number + /** The split's pixel extent along the axis, so travel converts to fractions. */ + readonly extent: number + readonly sizes: readonly number[] +} + +/** What the gesture is currently showing, before anything settles. */ +interface Preview { + readonly draggingTabId: TabId | undefined + readonly dropTarget: DropTarget | undefined + readonly sizes: SizePreview | undefined +} + +const NO_PREVIEW: Preview = { draggingTabId: undefined, dropTarget: undefined, sizes: undefined } + +/** Nothing measured yet: every pane fits until a reading says otherwise. */ +const NO_FITS: ReadonlyMap = new Map() + +/** The default add-control policy: every pane offers one. */ +const ALWAYS = (): boolean => true + +/** + * Resolve where a pointer sits inside the docked surface. An edge zone is only + * offered where the split it would make is allowed: within the pane budget and + * with room for two halves; otherwise the release is not a move at all. + */ +function hitTest( + root: HTMLElement, + x: number, + y: number, + canSplit: boolean, + fits: ReadonlyMap, + dropZones: 'edges' | 'horizontal', +): DropTarget | undefined { + for (const [paneId, pane] of paneElements(root)) { + const rect = pane.getBoundingClientRect() + if (!containsPoint(rect, x, y)) continue + const strip = pane.querySelector('[data-dockkit-strip]') + if (strip !== null && containsPoint(strip.getBoundingClientRect(), x, y)) { + const tabs = [...strip.querySelectorAll('[data-dockkit-tab]')] + return { kind: 'strip', paneId, index: insertionIndex(tabs.map(tab => tab.getBoundingClientRect()), x) } + } + const zone = dropZones === 'horizontal' + ? canSplit && fitOf(fits, paneId).row + ? x < rect.x + rect.width / 2 ? 'left' : 'right' + : 'center' + : zoneInRect(rect, x, y) + if (zone !== 'center') { + const fit = fitOf(fits, paneId) + const room = zone === 'left' || zone === 'right' ? fit.row : fit.column + if (!canSplit || !room) return undefined + } + return { kind: 'zone', paneId, zone } + } + return undefined +} + +/** Fractions a divider drag has reached, clamped to the pane minimum. */ +function draggedSizes(drag: DividerDrag, x: number, y: number, minimum: number): readonly number[] { + const moved = (drag.axis === 'row' ? x : y) - drag.origin + const delta = drag.extent > 0 ? moved / drag.extent : 0 + return clampSizes(dividerSizes(drag.sizes, drag.index, delta), minimum) +} + +/** Fractions closer than this are the same split: renormalizing recorded sizes moves them by no more. */ +const SIZE_TOLERANCE = 1e-9 + +/** Whether two fraction lists describe the same split. */ +function sameSizes(a: readonly number[], b: readonly number[]): boolean { + return a.length === b.length && a.every((size, index) => { + const other = b[index] + return other !== undefined && Math.abs(size - other) < SIZE_TOLERANCE + }) +} + +/** The split tree and the gestures over it. */ +export function DockSurface({ + state, canSplit, canAddTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom, + dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitAtCapacity = false, +}: DockSurfaceProps): ReactNode { + const surface = useRef(null) + const [preview, setPreview] = useState(NO_PREVIEW) + const [fits, setFits] = useState(NO_FITS) + const begin = useGesture(() => { setPreview(NO_PREVIEW) }) + + /** Run `use` on the surface element, which every commit and every press inside it has mounted. */ + const withSurface = useCallback((use: (root: HTMLElement) => void): void => { + const root = surface.current + /* v8 ignore next -- ref-null guard: the surface div renders unconditionally. */ + if (root === null) return + use(root) + }, []) + + // The room rule reads pixels, which the layout state does not carry: measure + // after every commit (a split, a divider drag, a closed tab all move panes) + // and whenever the surface itself is resized (the embedder's column dragged + // wider or narrower). A reading that changed nothing renders nothing. + const remeasure = useCallback((): void => { + withSurface((root) => { + const next = measurePaneFits(root) + setFits(current => sameFits(current, next) ? current : next) + }) + }, [withSurface]) + useLayoutEffect(() => { remeasure() }) + useEffect(() => { onRoom?.(fits) }, [fits, onRoom]) + useEffect(() => { + const root = surface.current + if (root === null || typeof ResizeObserver === 'undefined') return undefined + const observer = new ResizeObserver(() => { remeasure() }) + observer.observe(root) + return () => { observer.disconnect() } + }, [remeasure]) + + /** Why a pane cannot split right now: the budget first, then its own width. */ + const splitBlock = (paneId: PaneId): SplitBlock | undefined => { + if (!canSplit) return 'budget' + return fitOf(fits, paneId).row ? undefined : 'width' + } + + const callbacks: PaneCallbacks = { + onFocusTab: intents.focusTab.bind(intents), + onFocusPane: intents.focusPane.bind(intents), + onSplitPane: intents.splitPane.bind(intents), + onAddTab: intents.addTab.bind(intents), + onCloseTab: intents.closeTab.bind(intents), + // A press is not yet a drag: the chip lifts, and the drop preview follows, + // once the pointer has travelled the threshold. A release before that is a + // click and reports nothing here. + onTabPressed: (tabId, event) => { + withSurface((root) => { + const startX = event.clientX + const startY = event.clientY + let dragging = false + begin(event.currentTarget, event.pointerId, { + move: (moved) => { + if (!dragging) { + if (!passedThreshold(startX, startY, moved.clientX, moved.clientY)) return + dragging = true + } + setPreview({ + ...NO_PREVIEW, + draggingTabId: tabId, + dropTarget: hitTest(root, moved.clientX, moved.clientY, canSplit, fits, dropZones), + }) + }, + up: (released) => { + if (!dragging) return + const target = hitTest(root, released.clientX, released.clientY, canSplit, fits, dropZones) + if (target === undefined) { + if (containsPoint(root.getBoundingClientRect(), released.clientX, released.clientY)) return + intents.floatTab(tabId, floatRectAt(released.clientX, released.clientY, FLOAT_DEFAULT_SIZE)) + return + } + if (target.kind === 'strip') intents.placeTab(tabId, target.paneId, target.index) + else intents.dropTab(tabId, target.paneId, target.zone) + }, + }) + }) + }, + onDividerPressed: (splitId, index, event) => { + const container = event.currentTarget.parentElement + /* v8 ignore next -- a divider is rendered as a child of its split's element. */ + if (container === null) return + const split = getSplit(state, splitId) + const box = container.getBoundingClientRect() + const drag: DividerDrag = { + splitId, + index, + axis: split.axis, + origin: split.axis === 'row' ? event.clientX : event.clientY, + extent: split.axis === 'row' ? box.width : box.height, + sizes: split.sizes, + } + // A release that left the fractions where they were — a click on the + // divider, a drag returned to its start, or one pushed further into the + // clamp — is not a resize and reports nothing. + begin(event.currentTarget, event.pointerId, { + move: (moved) => { + setPreview({ ...NO_PREVIEW, sizes: { splitId, sizes: draggedSizes(drag, moved.clientX, moved.clientY, minPaneFraction) } }) + }, + up: (released) => { + const sizes = draggedSizes(drag, released.clientX, released.clientY, minPaneFraction) + if (sameSizes(sizes, drag.sizes)) return + intents.resizeSplit(splitId, sizes) + }, + }) + }, + splitBlock, + hideSplitAtCapacity, + canAddTab: canAddTab ?? ALWAYS, + dropTarget: preview.dropTarget, + horizontalDrops: dropZones === 'horizontal', + draggingTabId: preview.draggingTabId, + labels, + renderTab, + renderTabTitle, + renderTabMenuItems, + chromePaneId: topRightPaneId(state), + chrome, + } + + return ( +
+ +
+ ) +} diff --git a/packages/client/ui-dockkit/src/components/FloatLayer.tsx b/packages/client/ui-dockkit/src/components/FloatLayer.tsx new file mode 100644 index 0000000000..3c7fa9c1d9 --- /dev/null +++ b/packages/client/ui-dockkit/src/components/FloatLayer.tsx @@ -0,0 +1,152 @@ +/** + * The floating layer: one overlay panel per floating pane, bottom-to-top in the + * model's z order. A floating pane hosts exactly one tab and renders no tab + * strip — the panel *is* the tab. Pressing a panel's body raises it. Its grip + * and corner report through their gesture instead: a press released in place is + * a click and raises the panel; a drag records the move or resize, and that + * operation raises the panel itself, so one gesture is one intent. Raising a + * panel that is active and on top already changes nothing and reports nothing. + * + * The layer owns its own drag and resize gestures, so where it mounts is not + * part of its contract: panels are positioned in viewport coordinates and read + * only `state` and the outward contracts. An embedder may portal it anywhere, + * and nothing here assumes the docked tree is an ancestor or even present. + */ +import { useState } from 'react' +import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react' +import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts' +import type { FloatRect, LayoutState, PaneId } from '../contract/types.ts' +import { FLOAT_MIN_SIZE } from '../engine/constraints.ts' +import { movedRect, resizedRect } from '../engine/geometry.ts' +import { floatRect, getPane, getTab, onlyTabId } from '../engine/tree.ts' +import { useGesture } from './pointer.ts' +import css from './dockkit.module.css' + +/** The layout whose `floats` this layer draws. */ +export interface FloatLayerProps { + readonly state: LayoutState + readonly intents: DockIntents + readonly labels: DockLabels + readonly renderTab: TabRenderer + /** The panel header's title content; omit to show the record's `title` text (see `DockSurfaceProps`). */ + readonly renderTabTitle?: TabRenderer +} + +/** A floating-panel gesture: what it moves and where it started. */ +interface FloatDrag { + readonly mode: 'move' | 'resize' + readonly originX: number + readonly originY: number + readonly rect: FloatRect +} + +/** The rectangle a gesture has reached. */ +function draggedRect(drag: FloatDrag, x: number, y: number): FloatRect { + const dx = x - drag.originX + const dy = y - drag.originY + return drag.mode === 'move' + ? movedRect(drag.rect, dx, dy) + : resizedRect(drag.rect, dx, dy, FLOAT_MIN_SIZE) +} + +/** Whether two rectangles agree in every coordinate. */ +function sameRect(a: FloatRect, b: FloatRect): boolean { + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height +} + +/** Whether a floating pane is already where a raise would put it: focused and on top. */ +function raised(state: LayoutState, paneId: PaneId): boolean { + return state.activePaneId === paneId && state.floats.at(-1) === paneId +} + +/** Every floating panel, in z order. */ +export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle }: FloatLayerProps): ReactNode { + const [preview, setPreview] = useState<{ paneId: PaneId; rect: FloatRect } | undefined>(undefined) + const begin = useGesture(() => { setPreview(undefined) }) + + /** Focus and raise a panel from a press or click on it, unless it is raised already. */ + const raise = (paneId: PaneId): void => { + if (raised(state, paneId)) return + intents.focusPane(paneId) + } + + /** Start a move or resize from a press on the panel's grip or corner; a release that moved nothing is a click. */ + const drag = (mode: 'move' | 'resize', paneId: PaneId, event: ReactPointerEvent): void => { + // The press stops here: the panel's own press-to-focus would record a focus + // entry before the drag's, and the release below decides which one it is. + event.stopPropagation() + const start: FloatDrag = { mode, originX: event.clientX, originY: event.clientY, rect: floatRect(getPane(state, paneId)) } + begin(event.currentTarget, event.pointerId, { + move: (moved) => { setPreview({ paneId, rect: draggedRect(start, moved.clientX, moved.clientY) }) }, + up: (released) => { + const rect = draggedRect(start, released.clientX, released.clientY) + if (sameRect(rect, start.rect)) raise(paneId) + else if (mode === 'move') intents.moveFloat(paneId, rect.x, rect.y) + else intents.resizeFloat(paneId, rect) + }, + }) + } + + return ( + <> + {state.floats.map((paneId, depth) => { + const pane = getPane(state, paneId) + const tab = getTab(state, onlyTabId(pane)) + // A panel mid-gesture draws where the pointer has taken it and on top, + // as the operation its release records will leave it. + const lifted = preview?.paneId === paneId ? preview.rect : undefined + const live = lifted ?? floatRect(pane) + return ( +
{ raise(paneId) }} + > +
{ drag('move', paneId, event) }} + > + {renderTabTitle?.(tab) ?? tab.title} + + +
+
{renderTab(tab)}
+
{ drag('resize', paneId, event) }} + /> +
+ ) + })} + + ) +} diff --git a/packages/client/ui-dockkit/src/components/PaneTree.tsx b/packages/client/ui-dockkit/src/components/PaneTree.tsx new file mode 100644 index 0000000000..60f3f6976e --- /dev/null +++ b/packages/client/ui-dockkit/src/components/PaneTree.tsx @@ -0,0 +1,60 @@ +/** + * The docked split tree: nested flex runs sized by each split's fractions, with a + * draggable divider between neighbours. A live divider drag renders from the + * preview fractions instead of the recorded ones — the gesture only settles one + * intent when it ends. + */ +import { Fragment } from 'react' +import type { ReactNode } from 'react' +import clsx from 'clsx' +import type { LayoutState, NodeId, SplitId } from '../contract/types.ts' +import { getNode } from '../engine/tree.ts' +import type { PaneCallbacks } from './render.ts' +import { TabPanel } from './TabPanel.tsx' +import css from './dockkit.module.css' + +/** Fractions a live divider drag is previewing for one split. */ +export interface SizePreview { + readonly splitId: SplitId + readonly sizes: readonly number[] +} + +/** One subtree of the docked layout. */ +export interface PaneTreeProps { + readonly state: LayoutState + readonly nodeId: NodeId + readonly callbacks: PaneCallbacks + readonly preview: SizePreview | undefined +} + +/** Render a split or pane node and everything under it. */ +export function PaneTree({ state, nodeId, callbacks, preview }: PaneTreeProps): ReactNode { + const node = getNode(state, nodeId) + if (node.kind === 'pane') return + const sizes = preview !== undefined && preview.splitId === node.id ? preview.sizes : node.sizes + return ( +
+ {node.children.map((childId, index) => ( + + {index > 0 && ( +
{ callbacks.onDividerPressed(node.id, index - 1, event) }} + /> + )} +
+ +
+ + ))} +
+ ) +} diff --git a/packages/client/ui-dockkit/src/components/TabMenu.tsx b/packages/client/ui-dockkit/src/components/TabMenu.tsx new file mode 100644 index 0000000000..0f610cc0ed --- /dev/null +++ b/packages/client/ui-dockkit/src/components/TabMenu.tsx @@ -0,0 +1,101 @@ +/** + * The per-tab context menu, opened by a secondary press on the chip. It carries + * the close gesture and whatever the embedder appends; the copy and float + * gestures have no menu item — copying is an embedder API, floating is a drag + * released clear of the surface. Presentational — it renders what its props + * supply and dismisses itself on outside presses. + * + * It renders in a portal, positioned against the control that opened it. The tab + * strip clips its overflow on purpose (so it never becomes a scroll container + * that claims a drag), and a menu drawn inside the strip would be clipped with + * it; a portal puts it above every clipping ancestor. React still bubbles the + * portal's synthetic events through the strip, which is why the press guards + * below remain necessary. + */ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { CSSProperties, ReactNode } from 'react' +import { createPortal } from 'react-dom' +import type { DockLabels } from '../contract/adapter.ts' +import css from './dockkit.module.css' + +/** Gap between the opening control and the menu, and the viewport margin kept clear. */ +const MENU_GAP = 4 + +/** What the menu offers, where it anchors, and how it closes. */ +export interface TabMenuProps { + readonly labels: DockLabels + /** The control that opened the menu; the menu hangs below its left edge. */ + readonly anchor: HTMLElement + readonly onClose: () => void + /** Dismiss without acting. */ + readonly onDismiss: () => void + /** Embedder items, rendered after the kit's own; absent means none. */ + readonly extras: ReactNode +} + +/** Where the menu sits, or `undefined` before the first measurement. */ +function placeMenu(anchor: HTMLElement, menu: HTMLElement): CSSProperties { + const rect = anchor.getBoundingClientRect() + const width = menu.offsetWidth + // Below the control, aligned to its left edge; flipped to its right edge when + // that would run off the viewport, as it does for the last tab in a column + // against the window's right side. + const left = rect.left + width + MENU_GAP > window.innerWidth + ? Math.max(MENU_GAP, rect.right - width) + : rect.left + return { top: rect.bottom + MENU_GAP, left } +} + +/** The actions menu body, anchored to the control that opened it. */ +export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuProps): ReactNode { + const self = useRef(null) + const [position, setPosition] = useState(undefined) + + useLayoutEffect(() => { + /* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */ + if (self.current === null) return + setPosition(placeMenu(anchor, self.current)) + }, [anchor]) + + useEffect(() => { + const menu = self.current + /* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */ + if (menu === null) return undefined + // A press anywhere but inside the menu dismisses it; one with no element + // target (dispatched to the window itself) counts as outside. + const onPointerDown = (event: PointerEvent): void => { + if (event.target instanceof Node && menu.contains(event.target)) return + onDismiss() + } + // Capture phase: a press on a tab chip starts a drag on its own handler, + // so the menu must be gone before that handler runs. + window.addEventListener('pointerdown', onPointerDown, true) + return () => { window.removeEventListener('pointerdown', onPointerDown, true) } + }, [onDismiss]) + + return createPortal( +
{ event.stopPropagation() }} + onClick={(event) => { event.stopPropagation() }} + > + + {/* Embedder items last: the kit's own item is the same in every menu, so + a reader looks for it in the same place every time. */} + {extras} +
, + document.body, + ) +} diff --git a/packages/client/ui-dockkit/src/components/TabPanel.tsx b/packages/client/ui-dockkit/src/components/TabPanel.tsx new file mode 100644 index 0000000000..a4a9cdc6f1 --- /dev/null +++ b/packages/client/ui-dockkit/src/components/TabPanel.tsx @@ -0,0 +1,281 @@ +/** + * One pane: its tab strip (drag source, drop target, split control) and the + * active tab's body with the dock preview overlay. Presentational; every gesture + * leaves through `PaneCallbacks`, and the body itself comes from `renderTab`. + * + * A chip is a capsule carrying one control, its close, at its right end; the + * context menu (secondary press) carries the same close plus whatever the + * embedder appends. The chips sit in their own box, the strip's one shrinking + * part: in a narrow pane they ellipsize and then clip there, so the add + * control after them (drawn while the embedder's `canAddTab` allows), the + * pane's split control, and the embedder's chrome keep their width and their + * place at the strip's end. + */ +import { Fragment, useState } from 'react' +import type { ReactNode } from 'react' +import clsx from 'clsx' +import type { LayoutState, PaneNode, TabId } from '../contract/types.ts' +import { getTab } from '../engine/tree.ts' +import type { PaneCallbacks, SplitBlock } from './render.ts' +import { TabMenu } from './TabMenu.tsx' +import css from './dockkit.module.css' + +/** The split control's glyph: a frame divided by a vertical line, as the split itself is. */ +function SplitGlyph(): ReactNode { + return ( + + ) +} + +/** The add control's glyph. */ +function PlusGlyph(): ReactNode { + return ( + + ) +} + +/** The close control's glyph. */ +function CloseGlyph(): ReactNode { + return ( + + ) +} + +/** A pane and the live layout it reads its tabs from. */ +export interface TabPanelProps { + readonly state: LayoutState + readonly pane: PaneNode + readonly callbacks: PaneCallbacks +} + +/** + * The chip a navigation key moves focus to, in the WAI-ARIA tabs pattern with + * manual activation: Left and Right step through the strip and wrap, Home and + * End jump to its ends. Selecting is a separate key. + * @returns the chip to focus, or `undefined` when the key is not a navigation key. + */ +function chipToFocus(key: string, tabs: readonly TabId[], tabId: TabId): TabId | undefined { + const count = tabs.length + const index = tabs.indexOf(tabId) + switch (key) { + case 'ArrowLeft': return tabs[(index - 1 + count) % count] + case 'ArrowRight': return tabs[(index + 1) % count] + case 'Home': return tabs[0] + case 'End': return tabs.at(-1) + default: return undefined + } +} + +/** Whether a key selects the focused chip. */ +function selects(key: string): boolean { + return key === 'Enter' || key === ' ' +} + +/** The split control's title: what it does, or why it cannot right now. */ +function splitTitle(labels: PaneCallbacks['labels'], block: SplitBlock | undefined): string { + switch (block) { + case undefined: return labels.splitPane + case 'budget': return labels.splitPaneDisabled + case 'width': return labels.splitPaneNarrow + } +} + +/** The pane's tab strip, split control, and body. */ +export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { + // The open context menu and the chip that opened it; the menu positions + // itself against that chip from its portal. + const [menu, setMenu] = useState<{ readonly tabId: TabId; readonly anchor: HTMLElement } | undefined>(undefined) + // The mounted chips by tab, for the keys that move focus between them. + const [chips] = useState(() => new Map()) + const active = pane.activeTabId === undefined ? undefined : getTab(state, pane.activeTabId) + const block = callbacks.splitBlock(pane.id) + const target = callbacks.dropTarget + const stripIndex = target !== undefined && target.kind === 'strip' && target.paneId === pane.id + ? target.index + : undefined + const zone = target !== undefined && target.kind === 'zone' && target.paneId === pane.id + ? target.zone + : undefined + + /** Select a tab from a click or a key, unless it is the active pane's selected tab already: that changes nothing. */ + const activate = (tabId: TabId): void => { + if (state.activePaneId === pane.id && pane.activeTabId === tabId) return + callbacks.onFocusTab(tabId) + } + + const focusChip = (tabId: TabId): void => { + const chip = chips.get(tabId) + /* v8 ignore next -- every tab in the strip has a mounted chip, registered by its ref. */ + if (chip === undefined) return + chip.focus() + } + + return ( +
{ + if (state.activePaneId === pane.id) return + callbacks.onFocusPane(pane.id) + }} + > +
+
+ {pane.tabs.map((tabId, index) => { + const tab = getTab(state, tabId) + const selected = tabId === pane.activeTabId + return ( + + {stripIndex === index &&
} +
{ + if (element === null) chips.delete(tabId) + else chips.set(tabId, element) + }} + // Focus lands on click, not on press: a state change between + // pointerdown and the first pointermove rebuilds this subtree, + // and Chromium cancels the pointer when the pressed element is + // replaced — which would abandon every drag. A drag that ends + // elsewhere fires no click, and its own operation carries focus. + onPointerDown={(event) => { + // A secondary press is the menu, never a drag. + if (event.button === 2) return + callbacks.onTabPressed(tabId, event) + }} + onClick={(event) => { + event.stopPropagation() + activate(tabId) + }} + onKeyDown={(event) => { + // Keys on the chip's nested close control are that control's. + if (event.target !== event.currentTarget) return + const next = chipToFocus(event.key, pane.tabs, tabId) + if (next !== undefined) { + event.preventDefault() + focusChip(next) + return + } + if (selects(event.key)) { + event.preventDefault() + activate(tabId) + } + }} + onContextMenu={(event) => { + event.preventDefault() + const anchor = event.currentTarget + setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor }) + }} + > + {callbacks.renderTabTitle?.(tab) ?? tab.title} + + {menu?.tabId === tabId && ( + { setMenu(undefined); callbacks.onCloseTab(tabId) }} + onDismiss={() => { setMenu(undefined) }} + extras={callbacks.renderTabMenuItems?.(tab, () => { setMenu(undefined) })} + /> + )} +
+ + ) + })} + {stripIndex === pane.tabs.length &&
} +
+ {callbacks.canAddTab(pane.id) && ( + + )} +
+ {!(callbacks.hideSplitAtCapacity && block === 'budget') && ( + + )} + {/* The embedder's surface-wide controls, in the top-right pane only: the + strip is the surface's top edge, and this pane's end is its corner. */} + {pane.id === callbacks.chromePaneId && callbacks.chrome !== undefined && ( + // The embedder's controls report their own intents; the pane's + // click-to-focus must not add a focus entry to each of them. +
{ event.stopPropagation() }} + > + {callbacks.chrome} +
+ )} +
+
+ {active === undefined + ?

{callbacks.labels.emptyPane}

+ : callbacks.renderTab(active)} + {zone !== undefined && (callbacks.horizontalDrops && zone !== 'center' + ? <> +
+
+ + :
)} +
+
+ ) +} diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css new file mode 100644 index 0000000000..2e995a2700 --- /dev/null +++ b/packages/client/ui-dockkit/src/components/dockkit.module.css @@ -0,0 +1,423 @@ +/* + * Docking-kit styles. One sheet on purpose: a consumer bundle de-duplicates + * injected stylesheets by `/`, so a second sheet whose file + * name matches one in the embedding package would be dropped silently. Keeping + * a single `dockkit.module.css` makes that collision impossible. + * + * Colours come from the embedder's token layer; the kit names no literal. Type + * follows the embedder's content axis (`--dsh-content-font-size` and its + * secondary step) so a surface reads at the same size as the page around it. + * Emphasis — a hovered divider, the drop caret, the drop-zone hint — takes the + * platform's accent (`--dsw-alias-brand-primary-new-colorprimary-new-color`), + * not `--dsw-alias-brand-primary`, which this platform binds to its + * near-black (light) or near-white (dark) foreground. + */ + +.split { + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} + +.splitRow { + flex-direction: row; +} + +.splitColumn { + flex-direction: column; +} + +.splitCell { + display: flex; + flex-basis: 0; + min-width: 0; + min-height: 0; +} + +.divider { + position: relative; + flex: none; + background: var(--dsw-alias-border-l1); + touch-action: none; +} + +.splitRow > .divider { + width: 4px; + cursor: col-resize; +} + +.splitColumn > .divider { + height: 4px; + cursor: row-resize; +} + +.divider:hover { + background: var(--dsw-alias-brand-primary-new-colorprimary-new-color); +} + +/* Both floors: a flex item's minimum is its content's, and a body's longest + unwrapped line would widen the surface past the embedder's box, carrying the + strip's controls and the body's scrollbar out of view. */ +.surface { + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} + +/* A body that overflows scrolls inside its pane; nothing escapes the pane. */ +.pane { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + border: 1px solid transparent; +} + +.pane[data-dockkit-pane-active] { + border-color: var(--dsw-alias-border-l2); +} + +/* One centre line for everything in the strip: every child — chip, add + control, split control, the embedder's chrome — is 24px tall, and the strip + centres them, so chip text and control glyphs never sit at different heights. + A child with another height would break that; keep them at 24px. + + The strip never clips: the chip box below is its one shrinking part, and + every control after it is `flex: none`, so a narrow pane costs chips, never + controls. */ +.tabStrip { + display: flex; + flex: none; + gap: 4px; + align-items: center; + height: 36px; + padding: 0 6px; + border-bottom: 0.5px solid var(--dsw-alias-border-l1); + touch-action: none; +} + +/* The chips. Shrinks to nothing before any control after it moves; what no + longer fits is clipped here. Deliberately not a scroller: a horizontal + scroll container claims a press-and-move as its own gesture and cancels the + pointer, which would abandon every tab drag in a narrow pane. Tabs shrink + and ellipsize first. */ +.stripTabs { + display: flex; + flex: 0 1 auto; + gap: 4px; + align-items: center; + min-width: 0; + overflow: hidden; + touch-action: none; +} + +/* Takes the free space and gives it all back first: a zero basis shrinks + nothing, so shortage lands on the chip box alone. */ +.stripFill { + flex: 1 1 0; + min-width: 0; +} + +/* Embedder controls at the strip's end, set off from the kit's own split + control by a hairline so the two groups read as two groups. */ +.stripChrome { + display: flex; + flex: none; + gap: 2px; + align-items: center; + height: 24px; + margin-left: 2px; + padding-left: 4px; + border-left: 0.5px solid var(--dsw-alias-border-l1); +} + +.caret { + flex: none; + align-self: center; + width: 2px; + height: 20px; + background: var(--dsw-alias-brand-primary-new-colorprimary-new-color); +} + +.tab { + position: relative; + display: flex; + flex: 0 1 auto; + gap: 4px; + align-items: center; + min-width: 44px; + max-width: 170px; + height: 24px; + padding: 0 5px 0 10px; + color: var(--dsw-alias-label-secondary); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: 1; + white-space: nowrap; + border-radius: 12px; + cursor: pointer; + touch-action: none; + user-select: none; +} + +.tabTitle { + overflow: hidden; + text-overflow: ellipsis; +} + +.tabClose { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + color: inherit; + line-height: 1; + background: transparent; + border: none; + border-radius: 50%; + corner-shape: round; + cursor: pointer; +} + +.addTab { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + color: var(--dsw-alias-label-secondary); + line-height: 1; + background: transparent; + border: none; + border-radius: 12px; + cursor: pointer; +} + +.addTab:hover { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +.tabClose:hover { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover-solid); +} + +.tab:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* The active chip is the filled capsule; the rest are bare text. */ +.tabActive { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-active); +} + +.tabDragging { + opacity: 0.5; +} + +.iconButton { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + color: var(--dsw-alias-label-secondary); + line-height: 1; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.iconButton:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +.iconButton:disabled { + color: var(--dsw-alias-label-tertiary); + cursor: default; +} + +/* + * Portalled beside the app root, so its stacking level is stated here: above the + * floating panels (which the embedder draws at 60), because a menu opened from + * a tab must never sit under a panel. No z-index token layer exists to draw from + * yet. + */ +.menu { + position: fixed; + z-index: 70; + display: flex; + flex-direction: column; + min-width: 96px; + padding: 4px; + background: var(--dsw-alias-bg-layer-3); + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 6px; +} + +.menuItem { + padding: 5px 8px; + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size-secondary, 13px); + text-align: left; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.menuItem:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.paneBody { + position: relative; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + padding: 12px; + overflow: auto; /* This sheet draws elevated surfaces (the strip and the menu), so a scroller + inside it rebinds the thumb indirection in a complete pair — a base-surface + thumb on an elevated ground reads as a smudge. */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + +.empty { + margin: 0; + color: var(--dsw-alias-label-tertiary); + font-size: var(--dsh-content-font-size-secondary, 13px); +} + +.dockHint { + position: absolute; + background: var(--dsw-alias-bg-multi-select); + border: 1px solid var(--dsw-alias-brand-primary-new-colorprimary-new-color); + pointer-events: none; +} + +.dockHint[data-dockkit-dock-zone='center'] { + inset: 0; +} + +.dockHint[data-dockkit-dock-zone='left'] { + top: 0; + bottom: 0; + left: 0; + width: 40%; +} + +.dockHint[data-dockkit-dock-zone='right'] { + top: 0; + right: 0; + bottom: 0; + width: 40%; +} + +[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='left'], +[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='right'] { + width: 50%; +} + +[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='left']:not([data-dockkit-drop-active]), +[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='right']:not([data-dockkit-drop-active]) { + background: transparent; + border-color: var(--dsw-alias-border-l2); +} + +.dockHint[data-dockkit-dock-zone='top'] { + top: 0; + right: 0; + left: 0; + height: 40%; +} + +.dockHint[data-dockkit-dock-zone='bottom'] { + right: 0; + bottom: 0; + left: 0; + height: 40%; +} + +.float { + position: fixed; + display: flex; + flex-direction: column; + background: var(--dsw-alias-bg-layer-1); + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 8px; + box-shadow: 0 8px 24px var(--dsw-alias-bg-mask-drop); + pointer-events: auto; +} + +/* The active panel keeps the same hairline: it is already on top of the z + order and casts the same shadow, and a heavier or darker frame read as a + defect. `data-dockkit-float-active` stays on the element for tests. */ + +.floatHeader { + display: flex; + flex: none; + gap: 2px; + align-items: center; + height: 28px; + padding: 0 4px 0 10px; + border-bottom: 0.5px solid var(--dsw-alias-border-l1); + cursor: move; + touch-action: none; + user-select: none; +} + +.floatTitle { + flex: 1 1 auto; + overflow: hidden; + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size-secondary, 13px); + white-space: nowrap; + text-overflow: ellipsis; +} + +.floatBody { + flex: 1 1 auto; + min-height: 0; + padding: 10px; + overflow: auto; /* This sheet draws elevated surfaces (the strip and the menu), so a scroller + inside it rebinds the thumb indirection in a complete pair — a base-surface + thumb on an elevated ground reads as a smudge. */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + +.floatResize { + position: absolute; + right: 0; + bottom: 0; + width: 14px; + height: 14px; + cursor: nwse-resize; + touch-action: none; +} + +.floatResize::after { + position: absolute; + right: 3px; + bottom: 3px; + width: 6px; + height: 6px; + border-right: 2px solid var(--dsw-alias-label-tertiary); + border-bottom: 2px solid var(--dsw-alias-label-tertiary); + content: ''; +} diff --git a/packages/client/ui-dockkit/src/components/measure.ts b/packages/client/ui-dockkit/src/components/measure.ts new file mode 100644 index 0000000000..6ce1fea4eb --- /dev/null +++ b/packages/client/ui-dockkit/src/components/measure.ts @@ -0,0 +1,109 @@ +/** + * DOM side of the room rule: read each docked pane's rectangles after a commit + * and ask `halvesFit` whether a split would leave two working halves. Pixels + * live here and in `geometry.ts`; the engine's planners never see them. + */ +import type { PaneId } from '../contract/types.ts' +import { halvesFit, SPLIT_MINIMUMS } from '../engine/geometry.ts' +import type { HalvesFit, Rect, SplitMinimums } from '../engine/geometry.ts' + +const NO_RECT: Rect = { x: 0, y: 0, width: 0, height: 0 } + +/** What an unmeasured pane is taken to be: fitting, until a reading says otherwise. */ +const UNMEASURED: HalvesFit = { row: true, column: true } + +function rectOf(element: Element | null): Rect { + return element === null ? NO_RECT : element.getBoundingClientRect() +} + +function px(value: string): number { + const parsed = Number.parseFloat(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +/** + * Every docked pane element under `root`, in document order, with the pane id + * each carries. + * @param root - the docked surface's element. + * @returns pane ids paired with their elements. + */ +export function paneElements(root: HTMLElement): readonly (readonly [PaneId, HTMLElement])[] { + const panes: (readonly [PaneId, HTMLElement])[] = [] + for (const pane of root.querySelectorAll('[data-dockkit-pane]')) { + // The attribute is the kit's own PaneId written on render; the DOM hands it + // back as a bare string, so the brand is restored here and nowhere else. + const paneId = pane.dataset.dockkitPane as PaneId | undefined + /* v8 ignore next -- the selector admits only elements carrying the attribute. */ + if (paneId === undefined) continue + panes.push([paneId, pane]) + } + return panes +} + +/** + * One chip's minimum footprint from a rendered chip's computed style; the + * stylesheet fallback where none is rendered or styles are not applied. + */ +function chipMinimum(root: HTMLElement): number { + const chip = root.querySelector('[data-dockkit-tab]') + if (chip === null) return SPLIT_MINIMUMS.chip + const style = getComputedStyle(chip) + const min = px(style.minWidth) + if (min <= 0) return SPLIT_MINIMUMS.chip + if (style.boxSizing === 'border-box') return min + return min + px(style.paddingLeft) + px(style.paddingRight) + px(style.borderLeftWidth) + px(style.borderRightWidth) +} + +/** A rendered divider's thickness, or the stylesheet fallback before the first split. */ +function dividerSize(root: HTMLElement): number { + const divider = root.querySelector('[data-dockkit-divider]') + if (divider === null) return SPLIT_MINIMUMS.divider + const { width, height } = divider.getBoundingClientRect() + const thickness = Math.min(width, height) + return thickness > 0 ? thickness : SPLIT_MINIMUMS.divider +} + +/** + * Measure every docked pane under `root`. + * @param root - the docked surface's element. + * @returns each pane's fit, keyed by pane id. + */ +export function measurePaneFits(root: HTMLElement): ReadonlyMap { + const minimums: SplitMinimums = { divider: dividerSize(root), chip: chipMinimum(root), body: SPLIT_MINIMUMS.body } + const fits = new Map() + for (const [paneId, pane] of paneElements(root)) { + fits.set(paneId, halvesFit({ + pane: rectOf(pane), + strip: rectOf(pane.querySelector('[data-dockkit-strip]')), + chipsWidth: rectOf(pane.querySelector('[data-dockkit-strip-tabs]')).width, + fillWidth: rectOf(pane.querySelector('[data-dockkit-strip-fill]')).width, + }, minimums)) + } + return fits +} + +/** + * One pane's latest reading. A pane the map does not name has not been + * measured and fits: the rule only blocks on a positive reading. + * @param fits - the latest measurement. + * @param paneId - the pane asked about. + * @returns whether each split axis leaves two working halves. + */ +export function fitOf(fits: ReadonlyMap, paneId: PaneId): HalvesFit { + return fits.get(paneId) ?? UNMEASURED +} + +/** + * Whether two measurements agree, so a re-measure that changed nothing re-renders nothing. + * @param a - one measurement. + * @param b - the other. + * @returns whether both name the same panes with the same readings. + */ +export function sameFits(a: ReadonlyMap, b: ReadonlyMap): boolean { + if (a.size !== b.size) return false + for (const [paneId, fit] of a) { + const other = b.get(paneId) + if (other === undefined || other.row !== fit.row || other.column !== fit.column) return false + } + return true +} diff --git a/packages/client/ui-dockkit/src/components/pointer.ts b/packages/client/ui-dockkit/src/components/pointer.ts new file mode 100644 index 0000000000..a3b368ea9b --- /dev/null +++ b/packages/client/ui-dockkit/src/components/pointer.ts @@ -0,0 +1,106 @@ +/** + * Pointer ownership shared by the docking surface and the float layer. + * + * Capture is hardening, not the mechanism: the window listeners carry the + * gesture either way. Capture is what stops a scroll container the pointer + * crosses from claiming it, which Chromium reports as a cancelled pointer and + * an abandoned drag. Environments without the API (jsdom) simply go unhardened. + */ +import { useEffect, useRef } from 'react' + +/** The three window listeners one gesture installs. */ +export interface PointerFollowers { + readonly move: (event: PointerEvent) => void + readonly up: (event: PointerEvent) => void + readonly cancel: () => void +} + +/** + * Take ownership of the pointer for the rest of the gesture. + * @param element - the element the gesture started on. + * @param pointerId - the pointer to capture. + */ +export function capturePointer(element: HTMLElement, pointerId: number): void { + if (typeof element.setPointerCapture !== 'function') return + element.setPointerCapture(pointerId) +} + +/** + * Capture the pointer, then follow it on the window until release or cancel. + * Only that pointer's events count: a second finger or a pen beside the mouse + * neither moves nor ends the gesture. The listeners remove themselves before + * `up` or `cancel` runs; the returned callback ends the gesture early, for an + * unmount or a superseding press. + * @param element - the element the gesture started on. + * @param pointerId - the pointer to capture and follow. + * @param followers - listeners for move, release, and cancel. + * @returns detach callback removing the three listeners. + */ +export function followPointer(element: HTMLElement, pointerId: number, followers: PointerFollowers): () => void { + capturePointer(element, pointerId) + const controller = new AbortController() + const { signal } = controller + const own = (event: PointerEvent): boolean => event.pointerId === pointerId + window.addEventListener('pointermove', (event) => { if (own(event)) followers.move(event) }, { signal }) + window.addEventListener('pointerup', (event) => { + if (!own(event)) return + controller.abort() + followers.up(event) + }, { signal }) + window.addEventListener('pointercancel', (event) => { + if (!own(event)) return + controller.abort() + followers.cancel() + }, { signal }) + return () => { controller.abort() } +} + +/** What one gesture does while it lasts and when it settles. */ +export interface GestureFollowers { + readonly move: (event: PointerEvent) => void + /** The release. The gesture has already ended, and its preview reset, when this runs. */ + readonly up: (event: PointerEvent) => void +} + +/** + * Start a gesture from the element a press landed on. + * @param element - the pressed element; the pointer is captured on it. + * @param pointerId - the pressing pointer. + * @param followers - what the gesture does. + */ +export type BeginGesture = (element: HTMLElement, pointerId: number, followers: GestureFollowers) => void + +/** + * One pointer gesture at a time for a component. A gesture ends on release, on + * cancel, or when a new press supersedes it; `reset` runs at each of those ends + * so the component clears its preview. Unmounting mid-gesture removes the + * listeners without resetting anything. + * @param reset - clears the component's gesture preview. + * @returns the gesture starter, called from a pointer-down handler. + */ +export function useGesture(reset: () => void): BeginGesture { + const inFlight = useRef<{ readonly stop: () => void; readonly end: () => void } | undefined>(undefined) + useEffect(() => () => { inFlight.current?.stop() }, []) + return (element, pointerId, followers) => { + inFlight.current?.end() + const settle = (): void => { + inFlight.current = undefined + reset() + } + const stop = followPointer(element, pointerId, { + move: followers.move, + up: (event) => { + settle() + followers.up(event) + }, + cancel: settle, + }) + inFlight.current = { + stop, + end: () => { + stop() + settle() + }, + } + } +} diff --git a/packages/client/ui-dockkit/src/components/render.ts b/packages/client/ui-dockkit/src/components/render.ts new file mode 100644 index 0000000000..1bc3c703f6 --- /dev/null +++ b/packages/client/ui-dockkit/src/components/render.ts @@ -0,0 +1,46 @@ +/** + * Prop shares the kit's own components pass among themselves. These are internal + * to the package — the outward contracts are in `adapter.ts`. + */ +import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react' +import type { DockLabels, TabMenuExtras, TabRenderer } from '../contract/adapter.ts' +import type { PaneId, SplitId, TabId } from '../contract/types.ts' +import type { DropTarget } from '../engine/geometry.ts' + +/** Why a pane's split control is disabled: the pane budget, or too little width for two halves. */ +export type SplitBlock = 'budget' | 'width' + +/** What a pane subtree needs: settled callbacks, gesture starters, and live preview. */ +export interface PaneCallbacks { + readonly onFocusTab: (tabId: TabId) => void + readonly onFocusPane: (paneId: PaneId) => void + readonly onSplitPane: (paneId: PaneId) => void + readonly onAddTab: (paneId: PaneId) => void + readonly onCloseTab: (tabId: TabId) => void + /** Begin dragging a tab; the surface owns the gesture from here. */ + readonly onTabPressed: (tabId: TabId, event: ReactPointerEvent) => void + /** Begin dragging a divider inside `splitId`, at the boundary after `index`. */ + readonly onDividerPressed: (splitId: SplitId, index: number, event: ReactPointerEvent) => void + /** Why a pane cannot split right now, or `undefined` while it can. */ + readonly splitBlock: (paneId: PaneId) => SplitBlock | undefined + /** Hide budget-blocked split controls without hiding width-blocked controls. */ + readonly hideSplitAtCapacity?: boolean + /** Whether a pane's strip draws the add control. */ + readonly canAddTab: (paneId: PaneId) => boolean + /** Live drop preview, or `undefined` while nothing is being dragged. */ + readonly dropTarget: DropTarget | undefined + /** Show both horizontal landing regions while a body split is being targeted. */ + readonly horizontalDrops?: boolean + /** Tab currently being dragged, so its chip can render as lifted. */ + readonly draggingTabId: TabId | undefined + readonly labels: DockLabels + readonly renderTab: TabRenderer + /** A chip's or panel header's title content; absent means the record's `title` text. */ + readonly renderTabTitle: TabRenderer | undefined + /** Embedder items appended to a tab's context menu; absent means the kit's item only. */ + readonly renderTabMenuItems: TabMenuExtras | undefined + /** The pane whose strip hosts the embedder's surface-wide controls. */ + readonly chromePaneId: PaneId + /** Those controls; absent means the strip ends at the kit's own split control. */ + readonly chrome: ReactNode +} diff --git a/packages/client/ui-dockkit/src/contract/adapter.ts b/packages/client/ui-dockkit/src/contract/adapter.ts new file mode 100644 index 0000000000..eb1abf8d5f --- /dev/null +++ b/packages/client/ui-dockkit/src/contract/adapter.ts @@ -0,0 +1,93 @@ +/** + * The kit's outward contracts: state in, intents out. + * + * Everything host-specific arrives through these — every rendered string, every + * tab body, and every net gesture result. The kit itself holds no copy, no icon + * set, and no knowledge of what a tab's `kind` means. + */ +import type { ReactNode } from 'react' +import type { DockZone, FloatRect, PaneId, SplitId, TabId, TabRecord } from './types.ts' + +/** + * Every string the kit renders, already localized by the embedder. + * + * Accessible names are included: a control with no visible text still needs + * one, and the kit must not invent it. + */ +export interface DockLabels { + /** Body of a pane holding no tabs. */ + readonly emptyPane: string + /** The split control, while splitting is allowed. */ + readonly splitPane: string + /** The split control, once the pane budget is spent. */ + readonly splitPaneDisabled: string + /** The split control, while the pane is too narrow for two working halves. */ + readonly splitPaneNarrow: string + /** Destroy a tab: the chip's close control and the menu's close item. */ + readonly closeTab: string + /** The strip's add control, which seats the embedder's seeded tab. */ + readonly addTab: string + /** Send a floating panel back into the docked tree. */ + readonly dockFloat: string + /** Close a floating panel. */ + readonly closeFloat: string +} + +/** + * Renders one tab's body. The embedder dispatches on `tab.kind`, which is the + * only place that string carries meaning. + */ +export type TabRenderer = (tab: TabRecord) => ReactNode + +/** + * Renders extra items at the end of one tab's context menu (opened by a + * secondary press on the chip). + * + * The kit's own item is the close gesture; anything that means something about + * the tab's content comes from here. An item that acts MUST call `dismiss`, + * because the menu closes on its own items only. + * @param tab - the tab whose menu is open. + * @param dismiss - close the menu without acting. + * @returns the extra items, or nothing. + */ +export type TabMenuExtras = (tab: TabRecord, dismiss: () => void) => ReactNode + +/** + * Net gesture results the kit reports. Each call is one settled intent — never a + * drag frame — so an embedder recording them produces one operation per gesture. + * + * `DockController` satisfies this contract as-is; an embedder that routes + * through its own store implements the same names. + */ +export interface DockIntents { + /** Focus a tab and its pane. */ + readonly focusTab: (tabId: TabId) => void + /** Focus a pane, raising it when it floats. */ + readonly focusPane: (paneId: PaneId) => void + /** Split a pane and seed the new one. */ + readonly splitPane: (paneId: PaneId) => void + /** Add the embedder's seeded tab to a pane (the strip's `+`). */ + readonly addTab: (paneId: PaneId) => void + /** Destroy a tab. */ + readonly closeTab: (tabId: TabId) => void + /** Copy a tab beside itself. No kit control drives this; embedders reach it through their own API. */ + readonly duplicateTab: (tabId: TabId) => void + /** Float a tab, at `rect` when the release point decided one (a drag released clear of the surface). */ + readonly floatTab: (tabId: TabId, rect?: FloatRect) => void + /** Return a floating panel's tab to the docked tree. */ + readonly unfloatPane: (paneId: PaneId) => void + /** + * Put a tab at an explicit strip slot: a reorder, a move, or a return. `index` + * is the caret slot counted over the destination strip's chips as drawn, the + * dragged chip included when the strip is its own. + */ + readonly placeTab: (tabId: TabId, toPaneId: PaneId, index: number) => void + /** Resolve a release on a pane body: the centre moves in, an edge splits. */ + readonly dropTab: (tabId: TabId, paneId: PaneId, zone: DockZone) => void + /** Net position of a floating-panel drag; the operation it records focuses and raises the panel too. */ + readonly moveFloat: (paneId: PaneId, x: number, y: number) => void + /** Net rectangle of a floating-panel resize; the operation it records focuses and raises the panel too. */ + readonly resizeFloat: (paneId: PaneId, rect: FloatRect) => void + /** Net fractions of a divider drag. */ + readonly resizeSplit: (splitId: SplitId, sizes: readonly number[]) => void +} diff --git a/packages/client/ui-dockkit/src/contract/types.ts b/packages/client/ui-dockkit/src/contract/types.ts new file mode 100644 index 0000000000..e5f9a665e8 --- /dev/null +++ b/packages/client/ui-dockkit/src/contract/types.ts @@ -0,0 +1,201 @@ +/** + * Layout model and operation vocabulary. Types only: no runtime code, no React, + * no DOM, and no host concepts — a tab's `kind` is an opaque string this kit + * never interprets, so the embedder owns what content families exist. + * + * The model is a normalized recursive split tree. `nodes` holds every split and + * pane keyed by id; `rootId` names the docked root; `floats` lists floating + * panes bottom-to-top. A floating panel is not a second concept — it is a pane + * whose `host` is `'float'`, capacity 1 tab, drawn without a tab strip. + * + * Ids are branded: a pane, a split, and a tab id never stand in for one another + * or for a bare string, and only a mint (or a DOM round trip of an id the kit + * wrote itself) produces one. + */ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Identity of a pane node in `LayoutState.nodes`. */ +export type PaneId = Branded<'PaneId'> + +/** Identity of a split node in `LayoutState.nodes`. */ +export type SplitId = Branded<'SplitId'> + +/** Identity of any node in `LayoutState.nodes`. */ +export type NodeId = PaneId | SplitId + +/** Identity of one open tab; distinct copies of one content share `contentId`, never `TabId`. */ +export type TabId = Branded<'TabId'> + +/** Direction a split lays its children out in. */ +export type SplitAxis = 'row' | 'column' + +/** Which side of the reference pane a new pane takes. */ +export type SplitDirection = 'before' | 'after' + +/** The five drop regions a pane offers a dragged tab. */ +export type DockZone = 'center' | 'top' | 'right' | 'bottom' | 'left' + +/** + * How the docked area is presented. + * + * `push` takes room from its neighbours; `fullscreen` covers the viewport. The kit + * records the choice but does not implement either — the embedder reads this and + * positions the surface. It lives here, beside `expanded`, because switching is a + * recorded operation the user can step back through. The values are the kit's + * own words and no service interface repeats them. + */ +export type DockMode = 'push' | 'fullscreen' + +/** Viewport rectangle of a floating pane, in CSS pixels. */ +export interface FloatRect { + readonly x: number + readonly y: number + readonly width: number + readonly height: number +} + +/** Interior node: an ordered run of children along one axis with fractional sizes. */ +export interface SplitNode { + readonly kind: 'split' + readonly id: SplitId + readonly axis: SplitAxis + /** At least two children; a one-child split collapses into that child. */ + readonly children: readonly NodeId[] + /** Same length as `children`, each above zero, summing to 1. */ + readonly sizes: readonly number[] +} + +/** Where a pane is drawn: inside the docked split tree, or as a viewport overlay. */ +export type PaneHost = 'dock' | 'float' + +/** Leaf node: an ordered tab list with at most one active tab. */ +export interface PaneNode { + readonly kind: 'pane' + readonly id: PaneId + readonly host: PaneHost + readonly tabs: readonly TabId[] + /** `undefined` exactly when `tabs` is empty. */ + readonly activeTabId: TabId | undefined + /** Set exactly when `host` is `'float'`. */ + readonly rect: FloatRect | undefined +} + +/** Either kind of tree node. */ +export type LayoutNode = SplitNode | PaneNode + +/** + * One open tab. + * + * `kind` selects the embedder's content family and is never interpreted here. + * `contentId` is the identity `openContent` de-duplicates against, so two tabs + * sharing it are deliberate copies of one thing. + */ +export interface TabRecord { + readonly id: TabId + readonly kind: string + readonly contentId: string + readonly title: string +} + +/** + * The whole layout of one docking surface. Every field is replaced rather than + * mutated, and untouched sub-objects keep their identity so consumers can + * compare by reference. + */ +export interface LayoutState { + readonly nodes: Readonly> + readonly tabs: Readonly> + /** Root of the docked tree; always a split or pane that exists in `nodes`. */ + readonly rootId: NodeId + /** Floating panes, bottom-to-top; the last entry is on top. */ + readonly floats: readonly PaneId[] + /** Focused pane, docked or floating. */ + readonly activePaneId: PaneId + /** Whether the docked area is expanded; floating panes ignore it. */ + readonly expanded: boolean + /** How the docked area is presented; floating panes ignore it. */ + readonly mode: DockMode +} + +/** Recipe for putting a pane back where it was, used by `insertPane`. */ +export type PaneAttachment = + /** Re-insert as a child of an existing split, restoring that split's sizes verbatim. */ + | { readonly mode: 'child'; readonly parentId: SplitId; readonly index: number; readonly sizes: readonly number[] } + /** Re-create a collapsed split in `targetId`'s slot; `split` already lists both children. */ + | { readonly mode: 'wrap'; readonly targetId: NodeId; readonly split: SplitNode } + /** Re-insert a floating pane at its former z index. */ + | { readonly mode: 'float'; readonly index: number } + +/** + * One recorded layout mutation. Ids that an operation creates are carried in + * the operation itself, so replaying a sequence from the same initial state + * reproduces the same ids without any minting during apply. + * + * `insertPane`, `insertTab`, and `restoreFocus` exist to express inverses + * exactly; they are applied like any other operation. + */ +export type LayoutOp = + /** Give `paneId` a new empty sibling pane along `axis`. */ + | { + readonly type: 'split' + readonly paneId: PaneId + readonly axis: SplitAxis + readonly direction: SplitDirection + readonly newPaneId: PaneId + /** Used only when the reference pane's parent cannot host `axis` directly. */ + readonly newSplitId: SplitId + } + /** Drop an empty docked pane and collapse the split it leaves behind. */ + | { readonly type: 'merge'; readonly paneId: PaneId } + /** Add a new tab to a docked pane and focus it. */ + | { readonly type: 'openTab'; readonly paneId: PaneId; readonly tab: TabRecord; readonly index: number } + /** Destroy a tab and its content state; a floating host pane goes with it. */ + | { readonly type: 'closeTab'; readonly tabId: TabId } + /** Move a tab to a different docked pane. */ + | { readonly type: 'moveTab'; readonly tabId: TabId; readonly toPaneId: PaneId; readonly index: number } + /** Move a tab within its own pane. */ + | { readonly type: 'reorderTab'; readonly tabId: TabId; readonly index: number } + /** Focus a tab, its owning pane, and raise that pane when floating. */ + | { readonly type: 'focusTab'; readonly tabId: TabId } + /** Focus a pane and raise it when floating. */ + | { readonly type: 'focusPane'; readonly paneId: PaneId } + /** Net result of a divider drag. */ + | { readonly type: 'resize'; readonly splitId: SplitId; readonly sizes: readonly number[] } + /** Take a tab out of the docked tree into a new floating pane. */ + | { readonly type: 'float'; readonly tabId: TabId; readonly newPaneId: PaneId; readonly rect: FloatRect } + /** Return a floating pane's only tab to a docked pane and destroy the floating pane. */ + | { readonly type: 'unfloat'; readonly paneId: PaneId; readonly toPaneId: PaneId; readonly index: number } + /** Net result of dragging a floating pane; the pane is focused and raised with it. */ + | { readonly type: 'moveFloat'; readonly paneId: PaneId; readonly x: number; readonly y: number } + /** Net result of resizing a floating pane; the pane is focused and raised with it. */ + | { readonly type: 'resizeFloat'; readonly paneId: PaneId; readonly rect: FloatRect } + /** Expand or collapse the docked area. */ + | { readonly type: 'setExpanded'; readonly expanded: boolean } + /** Switch how the docked area is presented. */ + | { readonly type: 'setMode'; readonly mode: DockMode } + /** Put a pane back, with the tab records it owned. */ + | { + readonly type: 'insertPane' + readonly pane: PaneNode + readonly tabs: readonly TabRecord[] + readonly attach: PaneAttachment + } + /** Put one tab record back into a docked pane. */ + | { readonly type: 'insertTab'; readonly paneId: PaneId; readonly tab: TabRecord; readonly index: number } + /** Restore focus facts an operation displaced. */ + | { + readonly type: 'restoreFocus' + readonly activePaneId: PaneId + readonly floats: readonly PaneId[] + /** Active tab per pane, for the panes the inverted operation touched. */ + readonly paneActiveTabs: Readonly> + } + +/** Operation kinds that only move focus; `Sequencer` collapses runs of these into one undo step. */ +export type FocusOpType = 'focusTab' | 'focusPane' | 'restoreFocus' + +/** Result of applying one operation: the next state plus the operations that undo it, in order. */ +export interface ApplyResult { + readonly state: LayoutState + readonly inverse: readonly LayoutOp[] +} diff --git a/packages/client/ui-dockkit/src/css-modules.d.ts b/packages/client/ui-dockkit/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-dockkit/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-dockkit/src/engine/constraints.ts b/packages/client/ui-dockkit/src/engine/constraints.ts new file mode 100644 index 0000000000..10ebb9fb14 --- /dev/null +++ b/packages/client/ui-dockkit/src/engine/constraints.ts @@ -0,0 +1,104 @@ +/** + * Interaction limits and dock geometry. The model itself is unbounded; these + * are the V1 rules the interaction layer enforces before it dispatches, kept + * pure so they can be asserted without a browser. + */ +import type { DockZone, LayoutState, SplitAxis, SplitDirection } from '../contract/types.ts' +import { assertNever, dockPaneIds } from './tree.ts' + +/** V1 caps the docked grid at four panes; floating panes do not count. */ +export const MAX_DOCK_PANES = 4 + +/** Smallest fraction a divider drag may leave a pane, as a share of its split. */ +export const MIN_PANE_FRACTION = 0.12 + +/** Size a tab takes when it first floats, in CSS pixels. */ +export const FLOAT_DEFAULT_SIZE = { width: 380, height: 300 } as const + +/** Smallest size a floating panel may be resized to, in CSS pixels. */ +export const FLOAT_MIN_SIZE = { width: 220, height: 140 } as const + +/** Fraction of a pane's width or height that counts as its dock edge. */ +export const DOCK_EDGE_FRACTION = 0.25 + +/** + * Number of docked panes. + * @param state - current layout. + * @returns how many panes the docked tree holds; floating panes do not count. + */ +export function dockPaneCount(state: LayoutState): number { + return dockPaneIds(state).length +} + +/** + * Whether another docked pane is allowed. + * @param state - current layout. + * @returns whether the docked tree is under `MAX_DOCK_PANES`. + */ +export function canSplit(state: LayoutState): boolean { + return dockPaneCount(state) < MAX_DOCK_PANES +} + +/** The five dock regions a tab can be dropped on. */ +export const DOCK_ZONES: readonly DockZone[] = ['center', 'top', 'right', 'bottom', 'left'] + +/** + * Which dock region a pointer sits in. + * @param x - pointer x as a fraction of pane width. + * @param y - pointer y as a fraction of pane height. + * @param edge - edge band width as a fraction; defaults to `DOCK_EDGE_FRACTION`. + * @returns the closest edge when the pointer is inside its band, else `'center'`. + */ +export function zoneAt(x: number, y: number, edge: number = DOCK_EDGE_FRACTION): DockZone { + let zone: DockZone = 'left' + let distance = x + if (1 - x < distance) { zone = 'right'; distance = 1 - x } + if (y < distance) { zone = 'top'; distance = y } + if (1 - y < distance) { zone = 'bottom'; distance = 1 - y } + return distance < edge ? zone : 'center' +} + +/** + * How a dock region splits the pane it targets. + * @param zone - the region the pointer released in. + * @returns the split's axis and direction, or `undefined` for `'center'`, which moves the tab into the pane instead. + */ +export function zoneSplit(zone: DockZone): { axis: SplitAxis; direction: SplitDirection } | undefined { + switch (zone) { + case 'center': return undefined + case 'left': return { axis: 'row', direction: 'before' } + case 'right': return { axis: 'row', direction: 'after' } + case 'top': return { axis: 'column', direction: 'before' } + case 'bottom': return { axis: 'column', direction: 'after' } + /* v8 ignore next -- closed-union backstop; the compiler rejects a new zone here. */ + default: return assertNever(zone, 'layout: dock zone') + } +} + +/** + * Clamp divider sizes so no pane falls under `MIN_PANE_FRACTION`. + * @param sizes - candidate fractions from the drag preview. + * @param minimum - smallest allowed share; defaults to the kit's pane fraction. + * @returns fractions summing to 1 with every entry at or above the minimum. + */ +export function clampSizes(sizes: readonly number[], minimum = MIN_PANE_FRACTION): number[] { + if (sizes.length === 0) return [] + const floor = Math.min(minimum, 1 / sizes.length) + const positive = sizes.map(size => (size > 0 ? size : 0)) + const total = positive.reduce((sum, size) => sum + size, 0) + let shares = total > 0 ? positive.map(size => size / total) : positive.map(() => 1 / sizes.length) + // Pin every share under the floor at the floor and hand the remainder to the + // others in proportion; a share that only now drops under joins the pinned + // set on the next pass, so the result holds the floor exactly. The free + // shares sit at or above the floor and sum to at least the remainder, so at + // least one stays free and their total stays positive. + const pinned = new Set() + for (;;) { + const under = shares.flatMap((share, index) => (!pinned.has(index) && share < floor ? [index] : [])) + if (under.length === 0) return shares + for (const index of under) pinned.add(index) + const remainder = 1 - pinned.size * floor + const freeTotal = shares.reduce((sum, share, index) => (pinned.has(index) ? sum : sum + share), 0) + shares = shares.map((share, index) => (pinned.has(index) ? floor : (share / freeTotal) * remainder)) + } +} diff --git a/packages/client/ui-dockkit/src/engine/controller.ts b/packages/client/ui-dockkit/src/engine/controller.ts new file mode 100644 index 0000000000..9c599f71be --- /dev/null +++ b/packages/client/ui-dockkit/src/engine/controller.ts @@ -0,0 +1,315 @@ +/** + * The intent layer's stateful embedding: one controller per docking surface, + * React-free, and itself the observable source the UI subscribes to + * (`subscribe` + `getSnapshot`, whose reference only changes when the layout + * does). + * + * Every method here is a planner call plus recording plus one notification. The + * decisions live in `planner.ts` so an embedder holding its layout in an external + * store shares them rather than reimplementing them; a planner that returns no + * operations records nothing and notifies nobody. + * + * The controller holds no host concepts: what a seeded tab contains arrives as a + * factory, and a tab's `kind` is an opaque string. + */ +import type { + DockMode, DockZone, FloatRect, LayoutOp, LayoutState, PaneId, SplitId, TabId, +} from '../contract/types.ts' +import { canSplit } from './constraints.ts' +import { createIdMinter, createInitialState, type IdMinter, type TabFactory } from './initial.ts' +import { + activeDockPaneId as dockedActivePane, planAddTab, planDropTab, planDuplicateTab, planFloatTab, planOpenContent, planPlaceTab, + planResizeSplit, planSetExpanded, planSetMode, planSplitPane, planUnfloatPane, + type OpenContentInput, +} from './planner.ts' +import type { Mint } from './planner.ts' +import { Sequencer } from './sequence.ts' + +export type { OpenContentInput } from './planner.ts' + +/** Everything the rendering layer reads, in one immutable value. */ +export interface DockSnapshot { + readonly state: LayoutState + readonly canUndo: boolean + readonly canRedo: boolean + /** Whether the docked grid still has room for another pane. */ + readonly canSplit: boolean + /** Recorded operation count, redo branch included. */ + readonly opCount: number + /** How many recorded intents are applied. */ + readonly cursor: number +} + +/** What the embedder seeds new panes with. */ +export interface DockControllerOptions { + /** Builds the tab the starting pane holds; omit to start empty. */ + readonly makeInitialTab?: TabFactory + /** Builds the tab a pane created by `splitPane` holds; omit to leave it empty. */ + readonly makePaneTab?: TabFactory + /** Starting presentation; defaults to `push`. */ + readonly mode?: DockMode +} + +/** One docking surface: history, interaction limits, and change notification. */ +export class DockController { + private readonly minter: IdMinter + private readonly sequencer: Sequencer + private readonly listeners = new Set<() => void>() + private readonly makePaneTab: TabFactory | undefined + private snapshot: DockSnapshot + + /** @param options - the tab factories this surface seeds panes with. */ + constructor(options: DockControllerOptions = {}) { + this.minter = createIdMinter() + this.makePaneTab = options.makePaneTab + this.sequencer = new Sequencer(createInitialState(this.minter, options.makeInitialTab, options.mode)) + this.snapshot = this.buildSnapshot() + } + + /** + * Observe layout changes. + * @param listener - called after every committed change. + * @returns disposer removing the listener. + */ + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** Current snapshot; the same reference until the layout changes. */ + getSnapshot = (): DockSnapshot => this.snapshot + + /** Recorded sequence, for tests and the operation readout. */ + get ops(): readonly LayoutOp[] { + return this.sequencer.ops + } + + private buildSnapshot(): DockSnapshot { + const state = this.sequencer.state + return { + state, + canUndo: this.sequencer.canUndo, + canRedo: this.sequencer.canRedo, + canSplit: canSplit(state), + opCount: this.sequencer.ops.length, + cursor: this.sequencer.cursor, + } + } + + private commit(): void { + this.snapshot = this.buildSnapshot() + for (const listener of [...this.listeners]) listener() + } + + private get state(): LayoutState { + return this.sequencer.state + } + + private get mint(): Mint { return this.minter.next } + + /** + * Record a planned intent as one history entry. + * @param ops - the planner's operations; empty plans nothing. + * @returns whether anything was recorded. + */ + private run(ops: readonly LayoutOp[]): boolean { + if (ops.length === 0) return false + this.sequencer.dispatchAll(ops) + this.commit() + return true + } + + /** + * Expand or collapse the docked area. Floating panels are unaffected. + * @param expanded - whether the docked area is shown. + */ + setExpanded(expanded: boolean): void { + this.run(planSetExpanded(this.state, expanded)) + } + + /** Flip the docked area between expanded and collapsed. */ + toggleExpanded(): void { + this.setExpanded(!this.state.expanded) + } + + /** + * Switch how the docked area is presented. + * @param mode - the presentation to record. + */ + setMode(mode: DockMode): void { + this.run(planSetMode(this.state, mode)) + } + + /** + * Split a pane to its right and seat the embedder's pane tab in the new pane. + * @param paneId - pane to split; defaults to the active docked pane. + * @returns false when the docked grid is already at `MAX_DOCK_PANES`. + */ + splitPane(paneId?: PaneId): boolean { + return this.run(planSplitPane(this.state, this.mint, paneId, this.makePaneTab)) + } + + /** + * Seat the pane-tab factory's tab at the end of a pane's strip. + * @param paneId - the docked pane whose strip asked. + * @returns false when there is no factory or the pane is not docked. + */ + addTab(paneId: PaneId): boolean { + return this.run(planAddTab(this.state, this.mint, paneId, this.makePaneTab)) + } + + /** + * Open content, or focus the tab already showing it. + * @param input - consistency id, copy, and optional target pane. + * @returns the tab now focused. + */ + openContent(input: OpenContentInput): TabId { + const planned = planOpenContent(this.state, this.mint, input) + this.run(planned.ops) + return planned.tabId + } + + /** + * Open a second, independent tab on the same content. + * @param tabId - tab to copy. + * @returns the new tab id. + */ + duplicateTab(tabId: TabId): TabId { + const planned = planDuplicateTab(this.state, this.mint, tabId) + this.run(planned.ops) + return planned.tabId + } + + /** + * Destroy a tab and its content state. A floating host panel goes with it. + * @param tabId - the tab to close. + */ + closeTab(tabId: TabId): void { + this.run([{ type: 'closeTab', tabId }]) + } + + /** + * Focus a tab, its pane, and raise that pane when it floats. + * @param tabId - the tab to focus. + */ + focusTab(tabId: TabId): void { + this.run([{ type: 'focusTab', tabId }]) + } + + /** + * Focus a pane, raising it when it floats. + * @param paneId - the pane to focus. + */ + focusPane(paneId: PaneId): void { + this.run([{ type: 'focusPane', paneId }]) + } + + /** + * Move a tab inside its own pane. + * @param tabId - the tab to move. + * @param index - its position in the strip without it. + */ + reorderTab(tabId: TabId, index: number): void { + this.run([{ type: 'reorderTab', tabId, index }]) + } + + /** + * Put a tab at an explicit slot: a reorder inside its own pane, otherwise a + * move (or a return, when it currently floats). + * @param tabId - the tab being placed. + * @param toPaneId - destination docked pane. + * @param index - caret slot in the destination strip, counting the dragged chip when the strip is its own. + * @returns false when the placement changes nothing. + */ + placeTab(tabId: TabId, toPaneId: PaneId, index: number): boolean { + return this.run(planPlaceTab(this.state, tabId, toPaneId, index)) + } + + /** + * Resolve a tab drop inside the docked area. + * @param tabId - the dragged tab. + * @param targetPaneId - pane under the pointer. + * @param zone - dock region the pointer released in. + * @returns false when the drop changes nothing or the grid is full. + */ + dropTab(tabId: TabId, targetPaneId: PaneId, zone: DockZone): boolean { + return this.run(planDropTab(this.state, this.mint, tabId, targetPaneId, zone)) + } + + /** + * Take a tab out into a floating panel. + * @param tabId - tab to float. + * @param rect - explicit rectangle; defaults to a cascade from the last panel. + * @returns the new floating pane id. + */ + floatTab(tabId: TabId, rect?: FloatRect): PaneId { + const planned = planFloatTab(this.state, this.mint, tabId, rect) + this.run(planned.ops) + return planned.paneId + } + + /** + * Send a floating panel's tab back into the docked tree. + * @param paneId - the floating pane. + * @param toPaneId - destination docked pane; defaults to the active one. + */ + unfloatPane(paneId: PaneId, toPaneId?: PaneId): void { + this.run(planUnfloatPane(this.state, paneId, toPaneId)) + } + + /** + * Record the net position of a floating-panel drag; the panel is focused and raised with it. + * @param paneId - the floating pane. + * @param x - its new left edge, in viewport pixels. + * @param y - its new top edge, in viewport pixels. + */ + moveFloat(paneId: PaneId, x: number, y: number): void { + this.run([{ type: 'moveFloat', paneId, x, y }]) + } + + /** + * Record the net rectangle of a floating-panel resize; the panel is focused and raised with it. + * @param paneId - the floating pane. + * @param rect - its new rectangle. + */ + resizeFloat(paneId: PaneId, rect: FloatRect): void { + this.run([{ type: 'resizeFloat', paneId, rect }]) + } + + /** + * Record the net sizes of a divider drag, clamped to the pane minimum. + * @param splitId - the split whose divider moved. + * @param sizes - the fractions the drag reached. + */ + resizeSplit(splitId: SplitId, sizes: readonly number[]): void { + this.run(planResizeSplit(splitId, sizes)) + } + + /** + * Step back one intent, or one run of consecutive focus-only intents. + * @returns false when there is nothing to undo. + */ + undo(): boolean { + if (!this.sequencer.undo()) return false + this.commit() + return true + } + + /** + * Step forward over what the matching undo stepped back. + * @returns false when there is nothing to redo. + */ + redo(): boolean { + if (!this.sequencer.redo()) return false + this.commit() + return true + } + + /** + * The pane a new tab lands in, for an embedder that needs to name it. + * @returns the active pane when docked, else the first docked pane. + */ + activeDockPaneId(): PaneId { + return dockedActivePane(this.state) + } +} diff --git a/packages/client/ui-dockkit/src/engine/geometry.ts b/packages/client/ui-dockkit/src/engine/geometry.ts new file mode 100644 index 0000000000..ff32cd3208 --- /dev/null +++ b/packages/client/ui-dockkit/src/engine/geometry.ts @@ -0,0 +1,208 @@ +/** + * Pure geometry for the drag interaction: point tests, dock-zone resolution + * against a real element rectangle, and tab-strip insertion slots. Kept free of + * React and DOM types so the drop rules can be asserted without a browser; the + * component layer measures rectangles and calls in. + */ +import type { DockZone, FloatRect, PaneId } from '../contract/types.ts' +import { DOCK_EDGE_FRACTION, zoneAt } from './constraints.ts' + +/** A measured rectangle in viewport coordinates. */ +export interface Rect { + readonly x: number + readonly y: number + readonly width: number + readonly height: number +} + +/** Where a drag would land if released now. */ +export type DropTarget = + /** Into a tab strip at an explicit slot: a reorder or a cross-pane move. */ + | { readonly kind: 'strip'; readonly paneId: PaneId; readonly index: number } + /** Onto a pane body: the centre moves the tab in, an edge splits the pane. */ + | { readonly kind: 'zone'; readonly paneId: PaneId; readonly zone: DockZone } + +/** + * Whether a point is inside a rectangle, edges included. + * @param rect - the rectangle. + * @param x - point x in the same coordinates. + * @param y - point y in the same coordinates. + * @returns whether the point lies on or inside the rectangle. + */ +export function containsPoint(rect: Rect, x: number, y: number): boolean { + return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height +} + +/** + * Dock region a point falls in, relative to one pane's rectangle. + * @param rect - the pane's measured box. + * @param x - pointer x in the same coordinates. + * @param y - pointer y in the same coordinates. + * @param edge - edge band as a fraction; defaults to the model's value. + * @returns the region; `'center'` when the point is not in an edge band. + */ +export function zoneInRect(rect: Rect, x: number, y: number, edge: number = DOCK_EDGE_FRACTION): DockZone { + if (!(rect.width > 0) || !(rect.height > 0)) return 'center' + return zoneAt((x - rect.x) / rect.width, (y - rect.y) / rect.height, edge) +} + +/** + * Slot a tab would take in a strip, by comparing the pointer with each tab's midpoint. + * @param tabRects - the strip's tab boxes in strip order. + * @param x - pointer x. + * @returns the insertion index, from 0 to `tabRects.length`. + */ +export function insertionIndex(tabRects: readonly Rect[], x: number): number { + let index = 0 + for (const rect of tabRects) { + if (x < rect.x + rect.width / 2) break + index += 1 + } + return index +} + +/** What one pane's strip measured, for the room rule. */ +export interface PaneMeasure { + /** The pane's box, borders included. */ + readonly pane: Rect + /** The strip's box, inside the borders; its height is what a vertical half must carry. */ + readonly strip: Rect + /** Width of the chip box, the strip's one shrinking part. */ + readonly chipsWidth: number + /** Width of the fill: free space, not a control. */ + readonly fillWidth: number +} + +/** Pixel minimums the room rule holds each half to. */ +export interface SplitMinimums { + /** The divider a split puts between the halves. */ + readonly divider: number + /** One chip at its minimum: the smallest strip that still names a tab. */ + readonly chip: number + /** The smallest body under a strip: one secondary text line inside the body's padding. */ + readonly body: number +} + +/** + * The minimums where no computed style can be read, mirroring + * `dockkit.module.css`: `.splitRow > .divider` is 4px wide; `.tab` is 44px of + * content plus 10px + 5px of padding (content-box), 59px; the body's 12px + * padding above and below one 13px secondary line at 1.6 line-height is 45px, + * held to 48px. + */ +export const SPLIT_MINIMUMS: SplitMinimums = { divider: 4, chip: 59, body: 48 } + +/** Whether a pane's two halves after an equal split would each still work. */ +export interface HalvesFit { + /** A row split: each half holds the strip's fixed controls and one minimum chip. */ + readonly row: boolean + /** A column split: each half holds the strip and a minimum body. */ + readonly column: boolean +} + +/** + * The room rule. After an equal split each half must hold what cannot shrink: + * horizontally the strip's fixed part — its width minus the chip box and the + * fill, which is the padding, the gaps, and every control that pane draws — + * plus one chip at its minimum; vertically the strip plus a minimum body. The + * borders are what the pane's box exceeds the strip's by. An unmeasured pane + * (no layout, as under jsdom) fits: the rule only blocks on a positive reading. + * @param measure - the pane's rectangles. + * @param minimums - the pixel minimums; defaults to the stylesheet's. + * @returns whether a row and a column split each leave two working halves. + */ +export function halvesFit(measure: PaneMeasure, minimums: SplitMinimums = SPLIT_MINIMUMS): HalvesFit { + const { pane, strip } = measure + if (!(pane.width > 0) || !(pane.height > 0) || !(strip.width > 0)) return { row: true, column: true } + const borders = Math.max(0, pane.width - strip.width) + const fixed = Math.max(0, strip.width - measure.chipsWidth - measure.fillWidth) + const halfWidth = (pane.width - minimums.divider) / 2 - borders + const halfHeight = (pane.height - minimums.divider) / 2 - borders + return { + row: halfWidth >= fixed + minimums.chip, + column: halfHeight >= strip.height + minimums.body, + } +} + +/** How far a pointer must travel before a press becomes a drag, in pixels. */ +export const DRAG_THRESHOLD = 4 + +/** + * Whether a press has travelled far enough to be a drag. + * @param startX - press x. + * @param startY - press y. + * @param x - current pointer x. + * @param y - current pointer y. + * @returns whether either axis moved at least `DRAG_THRESHOLD`. + */ +export function passedThreshold(startX: number, startY: number, x: number, y: number): boolean { + return Math.abs(x - startX) >= DRAG_THRESHOLD || Math.abs(y - startY) >= DRAG_THRESHOLD +} + +/** + * Split fractions after a divider drag. + * @param sizes - the split's current fractions. + * @param index - divider position: the boundary between `index` and `index + 1`. + * @param delta - pointer travel along the split axis, as a fraction of the split's extent. + * @returns new fractions; the two neighbours absorb the whole change. + */ +export function dividerSizes( + sizes: readonly number[], + index: number, + delta: number, +): number[] { + const before = sizes[index] + const after = sizes[index + 1] + if (before === undefined || after === undefined) return [...sizes] + const next = [...sizes] + next[index] = before + delta + next[index + 1] = after - delta + return next +} + +/** A width/height pair used as a floating-panel bound. */ +export interface Size { + readonly width: number + readonly height: number +} + +/** + * A floating panel's rectangle after a drag. + * @param rect - the rectangle the gesture started from. + * @param dx - pointer travel on x. + * @param dy - pointer travel on y. + * @returns the moved rectangle; the size is unchanged. + */ +export function movedRect(rect: FloatRect, dx: number, dy: number): FloatRect { + return { ...rect, x: rect.x + dx, y: rect.y + dy } +} + +/** + * A floating panel's rectangle after a bottom-right resize. + * @param rect - the rectangle the gesture started from. + * @param dx - pointer travel on x. + * @param dy - pointer travel on y. + * @param min - smallest size the panel may take. + * @returns the resized rectangle; the origin is unchanged. + */ +export function resizedRect(rect: FloatRect, dx: number, dy: number, min: Size): FloatRect { + return { + ...rect, + width: Math.max(min.width, rect.width + dx), + height: Math.max(min.height, rect.height + dy), + } +} + +/** + * Where a panel should appear when a tab is dropped outside the docked area. + * @param x - drop point x. + * @param y - drop point y. + * @param size - the panel's size. + * @returns a rectangle whose header sits under the drop point. + */ +export function floatRectAt(x: number, y: number, size: Size): FloatRect { + return { x: Math.max(0, x - GRAB_OFFSET.x), y: Math.max(0, y - GRAB_OFFSET.y), ...size } +} + +/** How far the new panel's origin sits above and left of the drop point. */ +const GRAB_OFFSET = { x: 60, y: 14 } as const diff --git a/packages/client/ui-dockkit/src/engine/initial.ts b/packages/client/ui-dockkit/src/engine/initial.ts new file mode 100644 index 0000000000..b3b1313d20 --- /dev/null +++ b/packages/client/ui-dockkit/src/engine/initial.ts @@ -0,0 +1,73 @@ +/** + * Initial state and the identity mint every operation draws its new ids from. + * Ids are minted outside `applyOp` so a recorded sequence replays to the exact + * same tree. + * + * What the first tab *is* belongs to the embedder: pass a factory and this + * module only decides where it sits. + */ +import type { DockMode, LayoutState, PaneId, TabId, TabRecord } from '../contract/types.ts' +import type { Mint } from './planner.ts' + +/** Monotonic id source; one instance belongs to one surface's sequence. */ +export interface IdMinter { + /** Next id under `prefix`, unique for the life of this minter; the prefix names the id's kind. */ + readonly next: Mint +} + +/** + * Create an id source. + * @param seed - number the first id counts from; defaults to 0. + * @returns a minter producing `` ids. + */ +export function createIdMinter(seed = 0): IdMinter { + let counter = seed + // The one place a string becomes an id: the prefix names the kind, the counter + // keeps every id this minter hands out unique. + const next = ((prefix: string): string => { + counter += 1 + return `${prefix}${counter}` + }) as Mint + return { next } +} + +/** Builds the tab record a newly seeded pane should hold. */ +export type TabFactory = (id: TabId) => TabRecord + +/** + * The state a surface starts in: collapsed, one docked pane, and whatever tab + * `makeInitialTab` supplies. + * + * The first tab belongs to the initial state rather than to an operation, so + * expanding and collapsing never accumulates copies of it. + * @param minter - id source this surface's sequence will keep using. + * @param makeInitialTab - builds the starting tab; omit for an empty pane. + * @param mode - starting presentation; the embedder's product default. + * @returns the collapsed single-pane starting state. + */ +export function createInitialState( + minter: IdMinter, + makeInitialTab?: TabFactory, + mode: DockMode = 'push', +): LayoutState { + const paneId: PaneId = minter.next('pane') + const initial = makeInitialTab?.(minter.next('tab')) + return { + nodes: { + [paneId]: { + kind: 'pane', + id: paneId, + host: 'dock', + tabs: initial === undefined ? [] : [initial.id], + activeTabId: initial?.id, + rect: undefined, + }, + }, + tabs: initial === undefined ? {} : { [initial.id]: initial }, + rootId: paneId, + floats: [], + activePaneId: paneId, + expanded: false, + mode, + } +} diff --git a/packages/client/ui-dockkit/src/engine/operations.ts b/packages/client/ui-dockkit/src/engine/operations.ts new file mode 100644 index 0000000000..c090099404 --- /dev/null +++ b/packages/client/ui-dockkit/src/engine/operations.ts @@ -0,0 +1,464 @@ +/** + * The operation engine: one pure `applyOp` that returns the next state plus the + * operations that undo it. No React, no DOM, no ambient state — replaying the + * same operations over the same initial state always yields the same result, + * because every id an operation creates travels inside the operation. + * + * Interaction limits (pane count, drag preview coalescing) are not enforced + * here; they belong to the interaction layer (`constraints.ts` and the UI). + */ +import type { + ApplyResult, FloatRect, LayoutOp, LayoutState, NodeId, PaneId, PaneNode, TabId, +} from '../contract/types.ts' +import { + assertNever, entriesOf, findParent, findTabPane, firstDockPaneId, floatIndex, floatRect, getPane, getSplit, getTab, + insertAt, keysOf, neighbourTabId, normalizeSizes, onlyTabId, paneWithTabs, removeAt, replaceInParent, withNodes, withTabs, +} from './tree.ts' + +/** Capture the focus facts of `paneIds` plus global focus, as the operation that restores them. */ +function focusSnapshot(state: LayoutState, paneIds: readonly PaneId[]): LayoutOp { + const paneActiveTabs: Record = {} + for (const id of paneIds) paneActiveTabs[id] = getPane(state, id).activeTabId + return { + type: 'restoreFocus', + activePaneId: state.activePaneId, + floats: state.floats, + paneActiveTabs, + } +} + +/** Move `paneId` to the top of the floating z order. */ +function raise(floats: readonly PaneId[], paneId: PaneId): PaneId[] { + return [...floats.filter(id => id !== paneId), paneId] +} + +/** Keep `activePaneId` on a live pane after `state` lost the focused one. */ +function reseatFocus(state: LayoutState, removedPaneId: PaneId): LayoutState { + if (state.activePaneId !== removedPaneId) return state + return { ...state, activePaneId: firstDockPaneId(state) } +} + +/** A fresh empty docked pane. */ +function emptyDockPane(id: PaneId): PaneNode { + return { kind: 'pane', id, host: 'dock', tabs: [], activeTabId: undefined, rect: undefined } +} + +/** Reject an id that a creating operation expects to be free. */ +function assertFreeNode(state: LayoutState, id: NodeId): void { + if (state.nodes[id] !== undefined) throw new Error(`layout: node ${id} already exists`) +} + +/** Reject a tab id that an opening operation expects to be free. */ +function assertFreeTab(state: LayoutState, id: TabId): void { + if (state.tabs[id] !== undefined) throw new Error(`layout: tab ${id} already exists`) +} + +/** Give `paneId` an empty sibling along `axis`. */ +function applySplit(state: LayoutState, op: Extract): ApplyResult { + const pane = getPane(state, op.paneId) + if (pane.host !== 'dock') throw new Error('layout: split requires a docked pane') + assertFreeNode(state, op.newPaneId) + const newPane = emptyDockPane(op.newPaneId) + const parent = findParent(state, op.paneId) + + if (parent !== undefined && parent.axis === op.axis) { + const index = parent.children.indexOf(op.paneId) + const at = op.direction === 'after' ? index + 1 : index + const children = insertAt(parent.children, at, op.newPaneId) + // The reference pane's share is halved between it and the new pane; the two + // halves are equal, so the sizes align with `children` whichever side it took. + const sizes = parent.sizes.flatMap((size, i) => (i === index ? [size / 2, size / 2] : [size])) + return { + state: withNodes(state, { [op.newPaneId]: newPane, [parent.id]: { ...parent, children, sizes } }), + inverse: [ + { type: 'merge', paneId: op.newPaneId }, + { type: 'resize', splitId: parent.id, sizes: parent.sizes }, + ], + } + } + + assertFreeNode(state, op.newSplitId) + // The reference pane's slot takes the new split; compute that swap before the + // split node exists, or `findParent` would find the split itself. + const rehomed = replaceInParent(state, op.paneId, op.newSplitId) + const children = op.direction === 'after' ? [op.paneId, op.newPaneId] : [op.newPaneId, op.paneId] + return { + state: withNodes(rehomed, { + [op.newPaneId]: newPane, + [op.newSplitId]: { kind: 'split', id: op.newSplitId, axis: op.axis, children, sizes: [0.5, 0.5] }, + }), + inverse: [{ type: 'merge', paneId: op.newPaneId }], + } +} + +/** Drop an empty pane; a two-child split collapses into its surviving child. */ +function applyMerge(state: LayoutState, op: Extract): ApplyResult { + const pane = getPane(state, op.paneId) + if (pane.tabs.length > 0) throw new Error('layout: merge requires an empty pane') + const focus = focusSnapshot(state, []) + + if (pane.host === 'float') { + const index = floatIndex(state, op.paneId) + const dropped = withNodes({ ...state, floats: removeAt(state.floats, index) }, { [op.paneId]: null }) + return { + state: reseatFocus(dropped, op.paneId), + inverse: [{ type: 'insertPane', pane, tabs: [], attach: { mode: 'float', index } }, focus], + } + } + + const parent = findParent(state, op.paneId) + if (parent === undefined) throw new Error('layout: the docked root pane cannot be merged') + const index = parent.children.indexOf(op.paneId) + + if (parent.children.length > 2) { + const children = removeAt(parent.children, index) + const sizes = normalizeSizes(removeAt(parent.sizes, index)) + const dropped = withNodes(state, { [op.paneId]: null, [parent.id]: { ...parent, children, sizes } }) + return { + state: reseatFocus(dropped, op.paneId), + inverse: [ + { + type: 'insertPane', + pane, + tabs: [], + attach: { mode: 'child', parentId: parent.id, index, sizes: parent.sizes }, + }, + focus, + ], + } + } + + const siblingId = parent.children[1 - index] + /* v8 ignore next -- a split holds at least two children, so one survives the merged pane. */ + if (siblingId === undefined) throw new Error('layout: merge found a split without a sibling') + const collapsed = withNodes(replaceInParent(state, parent.id, siblingId), { + [op.paneId]: null, + [parent.id]: null, + }) + return { + state: reseatFocus(collapsed, op.paneId), + inverse: [ + { type: 'insertPane', pane, tabs: [], attach: { mode: 'wrap', targetId: siblingId, split: parent } }, + focus, + ], + } +} + +/** Add a new tab to a docked pane and focus it. */ +function applyOpenTab(state: LayoutState, op: Extract): ApplyResult { + const pane = getPane(state, op.paneId) + if (pane.host !== 'dock') throw new Error('layout: openTab requires a docked pane') + assertFreeTab(state, op.tab.id) + const focus = focusSnapshot(state, [pane.id]) + const seated = withNodes(withTabs(state, { [op.tab.id]: op.tab }), { + [pane.id]: paneWithTabs(pane, insertAt(pane.tabs, op.index, op.tab.id), op.tab.id), + }) + return { + state: { ...seated, activePaneId: pane.id }, + inverse: [{ type: 'closeTab', tabId: op.tab.id }, focus], + } +} + +/** Put one tab record back where it was, without stealing focus. */ +function applyInsertTab(state: LayoutState, op: Extract): ApplyResult { + const pane = getPane(state, op.paneId) + if (pane.host !== 'dock') throw new Error('layout: insertTab requires a docked pane') + assertFreeTab(state, op.tab.id) + const focus = focusSnapshot(state, [pane.id]) + const tabs = insertAt(pane.tabs, op.index, op.tab.id) + return { + state: withNodes(withTabs(state, { [op.tab.id]: op.tab }), { + [pane.id]: paneWithTabs(pane, tabs, pane.activeTabId ?? op.tab.id), + }), + inverse: [{ type: 'closeTab', tabId: op.tab.id }, focus], + } +} + +/** Destroy a tab and its content state; a floating host pane goes with its only tab. */ +function applyCloseTab(state: LayoutState, op: Extract): ApplyResult { + const tab = getTab(state, op.tabId) + const pane = findTabPane(state, op.tabId) + const index = pane.tabs.indexOf(op.tabId) + const focus = focusSnapshot(state, [pane.id]) + + if (pane.host === 'float') { + const index = floatIndex(state, pane.id) + const dropped = withTabs( + withNodes({ ...state, floats: removeAt(state.floats, index) }, { [pane.id]: null }), + { [op.tabId]: null }, + ) + return { + state: reseatFocus(dropped, pane.id), + inverse: [ + { type: 'insertPane', pane, tabs: [tab], attach: { mode: 'float', index } }, + focus, + ], + } + } + + const activeTabId = pane.activeTabId === op.tabId ? neighbourTabId(pane.tabs, index) : pane.activeTabId + return { + state: withTabs( + withNodes(state, { [pane.id]: paneWithTabs(pane, removeAt(pane.tabs, index), activeTabId) }), + { [op.tabId]: null }, + ), + inverse: [{ type: 'insertTab', paneId: pane.id, tab, index }, focus], + } +} + +/** + * Put a pane back, with the tab records it owned. A docked pane returns empty + * (its tabs return through `insertTab`, as `closeTab` records them); a floating + * pane returns with its one tab, or empty. + */ +function applyInsertPane(state: LayoutState, op: Extract): ApplyResult { + assertFreeNode(state, op.pane.id) + if (op.pane.tabs.length !== op.tabs.length) throw new Error('layout: insertPane tab records do not match the pane') + if (op.pane.host === 'dock' && op.tabs.length > 0) throw new Error('layout: insertPane returns a docked pane empty') + const tabUpdates: Record = {} + for (const tab of op.tabs) { + assertFreeTab(state, tab.id) + tabUpdates[tab.id] = tab + } + const restoredTab = op.tabs[0] + const inverse: LayoutOp[] = restoredTab === undefined + ? [{ type: 'merge', paneId: op.pane.id }] + : [{ type: 'closeTab', tabId: restoredTab.id }, focusSnapshot(state, [])] + + const attach = op.attach + switch (attach.mode) { + case 'child': { + const parent = getSplit(state, attach.parentId) + const children = insertAt(parent.children, attach.index, op.pane.id) + if (attach.sizes.length !== children.length) throw new Error('layout: insertPane sizes do not match the split') + inverse.push({ type: 'resize', splitId: parent.id, sizes: parent.sizes }) + return { + state: withNodes(withTabs(state, tabUpdates), { + [op.pane.id]: op.pane, + [parent.id]: { ...parent, children, sizes: attach.sizes }, + }), + inverse, + } + } + case 'wrap': { + if (!attach.split.children.includes(op.pane.id)) { + throw new Error('layout: insertPane wrap split does not list the pane') + } + const rehomed = replaceInParent(state, attach.targetId, attach.split.id) + return { + state: withNodes(withTabs(rehomed, tabUpdates), { + [op.pane.id]: op.pane, + [attach.split.id]: attach.split, + }), + inverse, + } + } + case 'float': { + if (op.pane.host !== 'float') throw new Error('layout: float attachment requires a floating pane') + const floats = insertAt(state.floats, attach.index, op.pane.id) + return { + state: withNodes(withTabs({ ...state, floats }, tabUpdates), { [op.pane.id]: op.pane }), + inverse, + } + } + /* v8 ignore next 2 -- closed-union backstop; the compiler rejects a new attachment mode here. */ + default: + return assertNever(attach, 'layout: insertPane attachment') + } +} + +/** Move a tab to a different docked pane and focus it there. */ +function applyMoveTab(state: LayoutState, op: Extract): ApplyResult { + const from = findTabPane(state, op.tabId) + if (from.host !== 'dock') throw new Error('layout: moveTab source must be docked; use unfloat') + const to = getPane(state, op.toPaneId) + if (to.host !== 'dock') throw new Error('layout: moveTab target must be docked') + if (to.id === from.id) throw new Error('layout: moveTab across one pane; use reorderTab') + const index = from.tabs.indexOf(op.tabId) + const focus = focusSnapshot(state, [from.id, to.id]) + const activeTabId = from.activeTabId === op.tabId ? neighbourTabId(from.tabs, index) : from.activeTabId + const moved = withNodes(state, { + [from.id]: paneWithTabs(from, removeAt(from.tabs, index), activeTabId), + [to.id]: paneWithTabs(to, insertAt(to.tabs, op.index, op.tabId), op.tabId), + }) + return { + state: { ...moved, activePaneId: to.id }, + inverse: [{ type: 'moveTab', tabId: op.tabId, toPaneId: from.id, index }, focus], + } +} + +/** Move a tab within its own pane. */ +function applyReorderTab(state: LayoutState, op: Extract): ApplyResult { + const pane = findTabPane(state, op.tabId) + const from = pane.tabs.indexOf(op.tabId) + const tabs = insertAt(removeAt(pane.tabs, from), op.index, op.tabId) + return { + state: withNodes(state, { [pane.id]: { ...pane, tabs } }), + inverse: [{ type: 'reorderTab', tabId: op.tabId, index: from }], + } +} + +/** Focus a tab, its pane, and raise that pane when floating. */ +function applyFocusTab(state: LayoutState, op: Extract): ApplyResult { + const pane = findTabPane(state, op.tabId) + const focus = focusSnapshot(state, [pane.id]) + const focused = withNodes(state, { [pane.id]: { ...pane, activeTabId: op.tabId } }) + const floats = pane.host === 'float' ? raise(focused.floats, pane.id) : focused.floats + return { state: { ...focused, activePaneId: pane.id, floats }, inverse: [focus] } +} + +/** Focus a pane and raise it when floating. */ +function applyFocusPane(state: LayoutState, op: Extract): ApplyResult { + const pane = getPane(state, op.paneId) + const focus = focusSnapshot(state, []) + const floats = pane.host === 'float' ? raise(state.floats, pane.id) : state.floats + return { state: { ...state, activePaneId: pane.id, floats }, inverse: [focus] } +} + +/** Record the net result of a divider drag. */ +function applyResize(state: LayoutState, op: Extract): ApplyResult { + const split = getSplit(state, op.splitId) + if (op.sizes.length !== split.children.length) throw new Error('layout: resize sizes do not match the split') + if (op.sizes.some(size => !(size > 0))) throw new Error('layout: resize sizes must all be above zero') + return { + state: withNodes(state, { [split.id]: { ...split, sizes: normalizeSizes(op.sizes) } }), + inverse: [{ type: 'resize', splitId: split.id, sizes: split.sizes }], + } +} + +/** Take a tab out of the docked tree into a new floating pane on top. */ +function applyFloat(state: LayoutState, op: Extract): ApplyResult { + getTab(state, op.tabId) + const from = findTabPane(state, op.tabId) + if (from.host !== 'dock') throw new Error('layout: float requires a docked tab') + assertFreeNode(state, op.newPaneId) + const index = from.tabs.indexOf(op.tabId) + const focus = focusSnapshot(state, [from.id]) + const activeTabId = from.activeTabId === op.tabId ? neighbourTabId(from.tabs, index) : from.activeTabId + const floated = withNodes(state, { + [from.id]: paneWithTabs(from, removeAt(from.tabs, index), activeTabId), + [op.newPaneId]: { + kind: 'pane', + id: op.newPaneId, + host: 'float', + tabs: [op.tabId], + activeTabId: op.tabId, + rect: op.rect, + }, + }) + return { + state: { ...floated, floats: [...floated.floats, op.newPaneId], activePaneId: op.newPaneId }, + inverse: [{ type: 'unfloat', paneId: op.newPaneId, toPaneId: from.id, index }, focus], + } +} + +/** Return a floating pane's only tab to a docked pane and destroy the floating pane. */ +function applyUnfloat(state: LayoutState, op: Extract): ApplyResult { + const pane = getPane(state, op.paneId) + const rect = floatRect(pane) + const tabId = onlyTabId(pane) + const to = getPane(state, op.toPaneId) + if (to.host !== 'dock') throw new Error('layout: unfloat target must be docked') + const focus = focusSnapshot(state, [to.id]) + const docked = withNodes({ ...state, floats: removeAt(state.floats, floatIndex(state, op.paneId)) }, { + [op.paneId]: null, + [to.id]: paneWithTabs(to, insertAt(to.tabs, op.index, tabId), tabId), + }) + return { + state: { ...docked, activePaneId: to.id }, + inverse: [{ type: 'float', tabId, newPaneId: op.paneId, rect }, focus], + } +} + +/** Give a floating pane a new rectangle, focus it, and raise it: the one operation a drag of it records. */ +function reshapeFloat(state: LayoutState, pane: PaneNode, rect: FloatRect): LayoutState { + const reshaped = withNodes(state, { [pane.id]: { ...pane, rect } }) + return { ...reshaped, activePaneId: pane.id, floats: raise(reshaped.floats, pane.id) } +} + +/** Record the net result of dragging a floating pane, which also focuses and raises it. */ +function applyMoveFloat(state: LayoutState, op: Extract): ApplyResult { + const pane = getPane(state, op.paneId) + const rect = floatRect(pane) + return { + state: reshapeFloat(state, pane, { ...rect, x: op.x, y: op.y }), + inverse: [{ type: 'moveFloat', paneId: op.paneId, x: rect.x, y: rect.y }, focusSnapshot(state, [])], + } +} + +/** Record the net result of resizing a floating pane, which also focuses and raises it. */ +function applyResizeFloat(state: LayoutState, op: Extract): ApplyResult { + const pane = getPane(state, op.paneId) + const rect = floatRect(pane) + if (!(op.rect.width > 0) || !(op.rect.height > 0)) throw new Error('layout: float size must be above zero') + return { + state: reshapeFloat(state, pane, op.rect), + inverse: [{ type: 'resizeFloat', paneId: op.paneId, rect }, focusSnapshot(state, [])], + } +} + +/** Restore focus facts a previous operation displaced. */ +function applyRestoreFocus(state: LayoutState, op: Extract): ApplyResult { + const inverse = focusSnapshot(state, keysOf(op.paneActiveTabs)) + for (const paneId of op.floats) { + const pane = getPane(state, paneId) + if (pane.host !== 'float') throw new Error(`layout: restoreFocus lists docked pane ${paneId} as floating`) + } + let next = state + for (const [paneId, activeTabId] of entriesOf(op.paneActiveTabs)) { + const pane = getPane(next, paneId) + next = withNodes(next, { [paneId]: { ...pane, activeTabId } }) + } + getPane(next, op.activePaneId) + return { state: { ...next, activePaneId: op.activePaneId, floats: op.floats }, inverse: [inverse] } +} + +/** + * Apply one operation. + * @param state - state the operation reads; never mutated. + * @param op - the operation, carrying every id it creates. + * @returns the next state and the operations that undo it, applied in order. + * @throws when the operation addresses missing nodes or breaks a model rule. + */ +export function applyOp(state: LayoutState, op: LayoutOp): ApplyResult { + switch (op.type) { + case 'split': return applySplit(state, op) + case 'merge': return applyMerge(state, op) + case 'openTab': return applyOpenTab(state, op) + case 'insertTab': return applyInsertTab(state, op) + case 'closeTab': return applyCloseTab(state, op) + case 'insertPane': return applyInsertPane(state, op) + case 'moveTab': return applyMoveTab(state, op) + case 'reorderTab': return applyReorderTab(state, op) + case 'focusTab': return applyFocusTab(state, op) + case 'focusPane': return applyFocusPane(state, op) + case 'resize': return applyResize(state, op) + case 'float': return applyFloat(state, op) + case 'unfloat': return applyUnfloat(state, op) + case 'moveFloat': return applyMoveFloat(state, op) + case 'resizeFloat': return applyResizeFloat(state, op) + case 'setExpanded': + return { + state: { ...state, expanded: op.expanded }, + inverse: [{ type: 'setExpanded', expanded: state.expanded }], + } + case 'setMode': + return { + state: { ...state, mode: op.mode }, + inverse: [{ type: 'setMode', mode: state.mode }], + } + case 'restoreFocus': return applyRestoreFocus(state, op) + /* v8 ignore next -- closed-union backstop; the compiler rejects a new operation type here. */ + default: return assertNever(op, 'layout: operation') + } +} + +/** + * Fold operations forward, discarding inverses. + * @param state - starting state. + * @param ops - operations in recorded order. + * @returns the state after every operation. + */ +export function replay(state: LayoutState, ops: readonly LayoutOp[]): LayoutState { + return ops.reduce((current, op) => applyOp(current, op).state, state) +} diff --git a/packages/client/ui-dockkit/src/engine/planner.ts b/packages/client/ui-dockkit/src/engine/planner.ts new file mode 100644 index 0000000000..1c61ab94c2 --- /dev/null +++ b/packages/client/ui-dockkit/src/engine/planner.ts @@ -0,0 +1,389 @@ +/** + * Intent planning: each interaction, as a pure function from the current state to + * the operations that carry it out. + * + * Planners mint the ids their operations create and enforce the interaction + * limits, but they hold no state and apply nothing. That split is what lets the + * same intent vocabulary serve two embeddings — a `DockController` that keeps the + * state itself, and a host store that keeps it and only needs the operations — + * without either one reimplementing `openContent`'s identity lookup, `dropTab`'s + * region resolution, or the floating cascade. + * + * A planner returning no operations means the intent changes nothing; the caller + * records nothing and notifies nobody. + */ +import type { + DockMode, DockZone, FloatRect, LayoutOp, LayoutState, PaneId, PaneNode, SplitId, TabId, TabRecord, +} from '../contract/types.ts' +import { canSplit, clampSizes, FLOAT_DEFAULT_SIZE, zoneSplit } from './constraints.ts' +import type { TabFactory } from './initial.ts' +import { applyOp } from './operations.ts' +import { dockPaneIds, findTabPane, firstDockPaneId, getNode, getPane, getTab } from './tree.ts' + +/** Mints ids for the operations a planner produces: the one place a string becomes an id. */ +export interface Mint { + (prefix: 'tab'): TabId + (prefix: 'pane' | 'float'): PaneId + (prefix: 'split'): SplitId +} + +/** Distance each newly floated panel steps down and right from the last. */ +const FLOAT_CASCADE_STEP = 24 + +/** Where the first floating panel appears, in viewport pixels. */ +const FLOAT_ORIGIN = { x: 160, y: 120 } as const + +/** No operations: the intent is a no-op against this state. */ +const NOTHING: readonly LayoutOp[] = [] + +/** Where a new tab should go and what it should say. */ +export interface OpenContentInput { + /** Consistency id: with `kind`, the identity opening twice focuses instead of adding to. */ + readonly contentId: string + readonly title: string + readonly kind: string + /** Target pane; defaults to the active docked pane. */ + readonly paneId?: PaneId + /** Strip slot in the target pane; defaults to its end. */ + readonly index?: number + /** + * Whether a tab already showing this (kind, contentId) is focused instead of + * a second one being opened. Defaults to `true`. + */ + readonly revealIfOpened?: boolean +} + +/** An intent that both acts and names the tab it settled on. */ +export interface PlannedTab { + readonly ops: readonly LayoutOp[] + /** The tab the intent focused or created. */ + readonly tabId: TabId +} + +/** + * First tab in one pane carrying `contentId`, in strip order. + * @param state - current layout. + * @param paneId - the pane to search, docked or floating. + * @param contentId - the content identity. + * @param kind - restrict to tabs of this kind; omit to match any kind. + * @returns the tab, or `undefined` when that pane shows no such content. + */ +export function findPaneContentTab(state: LayoutState, paneId: PaneId, contentId: string, kind?: string): TabId | undefined { + for (const tabId of getPane(state, paneId).tabs) { + const tab = state.tabs[tabId] + if (tab?.contentId === contentId && (kind === undefined || tab.kind === kind)) return tabId + } + return undefined +} + +/** + * First tab carrying `contentId`, searched docked panes first, in visual order. + * @param state - current layout. + * @param contentId - the content identity. + * @param kind - restrict to tabs of this kind; omit to match any kind. + * @returns the tab, or `undefined` when nothing shows the content. + */ +export function findContentTab(state: LayoutState, contentId: string, kind?: string): TabId | undefined { + for (const paneId of [...dockPaneIds(state), ...state.floats]) { + const found = findPaneContentTab(state, paneId, contentId, kind) + if (found !== undefined) return found + } + return undefined +} + +/** + * The pane a new tab lands in. + * @param state - current layout. + * @returns the active pane when docked, else the first docked pane. + */ +export function activeDockPaneId(state: LayoutState): PaneId { + const active = getPane(state, state.activePaneId) + return active.host === 'dock' ? active.id : firstDockPaneId(state) +} + +/** Send a tab into a docked pane, choosing the operation its current host needs. */ +function tabInto(source: PaneNode, tabId: TabId, toPaneId: PaneId, index: number): LayoutOp { + return source.host === 'float' + ? { type: 'unfloat', paneId: source.id, toPaneId, index } + : { type: 'moveTab', tabId, toPaneId, index } +} + +/** + * Expand or collapse the docked area. + * @param state - current layout. + * @param expanded - whether the docked area is shown. + * @returns the operation, or none when the value is already current. + */ +export function planSetExpanded(state: LayoutState, expanded: boolean): readonly LayoutOp[] { + return state.expanded === expanded ? NOTHING : [{ type: 'setExpanded', expanded }] +} + +/** + * Switch the presentation. + * @param state - current layout. + * @param mode - the presentation to record. + * @returns the operation, or none when the value is already current. + */ +export function planSetMode(state: LayoutState, mode: DockMode): readonly LayoutOp[] { + return state.mode === mode ? NOTHING : [{ type: 'setMode', mode }] +} + +/** + * Split a pane to its right and seed the new pane. + * @param state - current layout. + * @param mint - id source for the pane, split, and seeded tab. + * @param paneId - pane to split; defaults to the active docked pane. + * @param makePaneTab - builds the seeded tab; omit to leave the new pane empty. + * @returns the operations, or none when the pane budget is spent. + */ +export function planSplitPane( + state: LayoutState, + mint: Mint, + paneId?: PaneId, + makePaneTab?: TabFactory, +): readonly LayoutOp[] { + if (!canSplit(state)) return NOTHING + const target = paneId ?? activeDockPaneId(state) + if (getPane(state, target).host !== 'dock') return NOTHING + const newPaneId = mint('pane') + const ops: LayoutOp[] = [{ + type: 'split', + paneId: target, + axis: 'row', + direction: 'after', + newPaneId, + newSplitId: mint('split'), + }] + // Seeding is its own operation inside the same intent: the record keeps the + // two apart, one step back undoes both — and the factory decides whether + // there is anything to seat. + const seed = makePaneTab?.(mint('tab')) + if (seed !== undefined) ops.push({ type: 'openTab', paneId: newPaneId, tab: seed, index: 0 }) + return ops +} + +/** + * Seat the embedder's seeded tab at the end of a docked pane's strip. + * @param state - current layout. + * @param mint - id source for the new tab. + * @param paneId - the pane whose strip asked; must be docked. + * @param makeTab - builds the seeded tab; omit to plan nothing. + * @returns the operations, or none when there is nothing to seat. + */ +export function planAddTab( + state: LayoutState, + mint: Mint, + paneId: PaneId, + makeTab?: TabFactory, +): readonly LayoutOp[] { + if (makeTab === undefined) return NOTHING + const pane = getPane(state, paneId) + if (pane.host !== 'dock') return NOTHING + return [{ type: 'openTab', paneId, tab: makeTab(mint('tab')), index: pane.tabs.length }] +} + +/** + * Open content, or focus the tab already showing it. + * @param state - current layout. + * @param mint - id source for a newly opened tab. + * @param input - identity, copy, and optional placement. + * @returns the operations plus the tab they settle on. + */ +export function planOpenContent(state: LayoutState, mint: Mint, input: OpenContentInput): PlannedTab { + const existing = input.revealIfOpened === false + ? undefined + : findContentTab(state, input.contentId, input.kind) + if (existing !== undefined) return { ops: [{ type: 'focusTab', tabId: existing }], tabId: existing } + const paneId = input.paneId ?? activeDockPaneId(state) + const tab: TabRecord = { + id: mint('tab'), + kind: input.kind, + contentId: input.contentId, + title: input.title, + } + return { + ops: [{ type: 'openTab', paneId, tab, index: input.index ?? getPane(state, paneId).tabs.length }], + tabId: tab.id, + } +} + +/** + * Open a second, independent tab on the same content, beside the original. + * @param state - current layout. + * @param mint - id source for the copy. + * @param tabId - tab to copy. + * @returns the operations plus the new tab's id. + */ +export function planDuplicateTab(state: LayoutState, mint: Mint, tabId: TabId): PlannedTab { + const source = getTab(state, tabId) + const pane = findTabPane(state, tabId) + const host = pane.host === 'dock' ? pane.id : activeDockPaneId(state) + const index = pane.host === 'dock' ? pane.tabs.indexOf(tabId) + 1 : getPane(state, host).tabs.length + const tab: TabRecord = { ...source, id: mint('tab') } + return { ops: [{ type: 'openTab', paneId: host, tab, index }], tabId: tab.id } +} + +/** + * Put a tab at an explicit strip slot: a reorder inside its own pane, otherwise a + * move, or a return when it currently floats. + * @param state - current layout. + * @param tabId - the tab being placed. + * @param toPaneId - destination docked pane. + * @param index - caret slot in the destination strip, counted over the chips as + * drawn — the dragged chip included when the destination is its own pane, so + * the slot just before or just after it is where it already sits. + * @returns the operations, or none when the placement changes nothing. + */ +export function planPlaceTab( + state: LayoutState, + tabId: TabId, + toPaneId: PaneId, + index: number, +): readonly LayoutOp[] { + const source = findTabPane(state, tabId) + if (getPane(state, toPaneId).host !== 'dock') return NOTHING + if (source.id === toPaneId) { + // `reorderTab` indexes the strip without the tab: a caret past the chip + // counts one slot the chip itself vacates. + const from = source.tabs.indexOf(tabId) + const to = index > from ? index - 1 : index + return to === from ? NOTHING : [{ type: 'reorderTab', tabId, index: to }] + } + return [tabInto(source, tabId, toPaneId, index)] +} + +/** + * Resolve a tab release on a pane body: the centre moves the tab in, an edge + * splits the pane and seats the tab in the new half. A pane's only tab released + * on that pane changes nothing in either zone: the split would empty the pane + * and seat the tab beside where it already was. + * @param state - current layout. + * @param mint - id source for a pane an edge release creates. + * @param tabId - the dragged tab. + * @param targetPaneId - pane under the pointer. + * @param zone - dock region the pointer released in. + * @returns the operations, or none when the release changes nothing. + */ +export function planDropTab( + state: LayoutState, + mint: Mint, + tabId: TabId, + targetPaneId: PaneId, + zone: DockZone, +): readonly LayoutOp[] { + const source = findTabPane(state, tabId) + const target = getPane(state, targetPaneId) + if (target.host !== 'dock') return NOTHING + const split = zoneSplit(zone) + + if (split === undefined) { + if (source.id === targetPaneId) return NOTHING + return [tabInto(source, tabId, targetPaneId, target.tabs.length)] + } + + if (source.id === targetPaneId && source.tabs.length === 1) return NOTHING + if (!canSplit(state)) return NOTHING + const newPaneId = mint('pane') + return [ + { + type: 'split', + paneId: targetPaneId, + axis: split.axis, + direction: split.direction, + newPaneId, + newSplitId: mint('split'), + }, + tabInto(source, tabId, newPaneId, 0), + ] +} + +/** + * Take a tab out into a floating panel. + * @param state - current layout. + * @param mint - id source for the floating pane. + * @param tabId - tab to float. + * @param rect - explicit rectangle; defaults to a cascade from the last panel. + * @returns the operations plus the floating pane's id. + */ +export function planFloatTab( + state: LayoutState, + mint: Mint, + tabId: TabId, + rect?: FloatRect, +): { readonly ops: readonly LayoutOp[]; readonly paneId: PaneId } { + const step = state.floats.length * FLOAT_CASCADE_STEP + const newPaneId = mint('float') + return { + ops: [{ + type: 'float', + tabId, + newPaneId, + rect: rect ?? { + x: FLOAT_ORIGIN.x + step, + y: FLOAT_ORIGIN.y + step, + width: FLOAT_DEFAULT_SIZE.width, + height: FLOAT_DEFAULT_SIZE.height, + }, + }], + paneId: newPaneId, + } +} + +/** + * Send a floating panel's tab back into the docked tree. + * @param state - current layout. + * @param paneId - the floating pane. + * @param toPaneId - destination docked pane; defaults to the active one. + * @returns the operations. + */ +export function planUnfloatPane( + state: LayoutState, + paneId: PaneId, + toPaneId?: PaneId, +): readonly LayoutOp[] { + const destination = toPaneId ?? activeDockPaneId(state) + return [{ type: 'unfloat', paneId, toPaneId: destination, index: getPane(state, destination).tabs.length }] +} + +/** + * Record the net sizes of a divider drag, clamped to the pane minimum. + * @param splitId - the split whose divider moved. + * @param sizes - the fractions the drag reached. + * @param minimum - smallest pane share; defaults to the kit's fraction. + * @returns the resize operation. + */ +export function planResizeSplit(splitId: SplitId, sizes: readonly number[], minimum?: number): readonly LayoutOp[] { + return [{ type: 'resize', splitId, sizes: clampSizes(sizes, minimum) }] +} + +/** + * Keep the docked area populated after an intent: drop every docked pane the + * intent left empty, and when the surviving root pane is itself empty, seed it. + * + * A pane empties when its last tab is closed, moved out, or floated; each such + * pane is merged away, innermost first, until none remains. The root pane cannot + * be merged, so it is reseeded instead — with the factory's tab, or left empty + * when the embedder supplies none. The returned operations continue the intent + * they follow, so a caller records both as one entry. + * @param state - the layout after the intent's own operations. + * @param mint - id source for the reseeded tab. + * @param makeTab - builds the tab an emptied root pane is reseeded with. + * @returns the follow-up operations, or none when every docked pane holds a tab. + */ +export function planSettle(state: LayoutState, mint: Mint, makeTab?: TabFactory): readonly LayoutOp[] { + const ops: LayoutOp[] = [] + let current = state + for (;;) { + const emptied = dockPaneIds(current) + .find(id => id !== current.rootId && getPane(current, id).tabs.length === 0) + if (emptied === undefined) break + const merge: LayoutOp = { type: 'merge', paneId: emptied } + ops.push(merge) + current = applyOp(current, merge).state + } + const root = getNode(current, current.rootId) + if (root.kind === 'pane' && root.tabs.length === 0 && makeTab !== undefined) { + ops.push({ type: 'openTab', paneId: root.id, tab: makeTab(mint('tab')), index: 0 }) + } + return ops +} diff --git a/packages/client/ui-dockkit/src/engine/sequence.ts b/packages/client/ui-dockkit/src/engine/sequence.ts new file mode 100644 index 0000000000..7e9569c051 --- /dev/null +++ b/packages/client/ui-dockkit/src/engine/sequence.ts @@ -0,0 +1,243 @@ +/** + * Linear operation history over `applyOp`. Recording is total — every operation + * lands in the sequence, focus moves included — and grouped by intent: the + * operations one gesture or command produced form one entry, so stepping lands + * on a point the user actually stopped at. Stepping is coarser still across + * focus: a run of consecutive focus-only entries undoes and redoes as one step. + * + * Redoing re-applies the recorded operations; undoing applies the inverses that + * were captured when they ran, so both directions stay exact. A new entry after + * an undo drops the redo branch. + * + * Two shapes, one implementation. `record`/`stepBack`/`stepForward` are pure + * functions over a plain `History`, which is what an embedder holding its layout + * in an external store needs; `Sequencer` is a thin mutable wrapper over exactly + * those functions, for an embedder that would rather hold the state here. + */ +import type { FocusOpType, LayoutOp, LayoutState } from '../contract/types.ts' +import { applyOp } from './operations.ts' + +/** One recorded intent: its operations, and the operations that undo them all. */ +export interface HistoryEntry { + readonly ops: readonly LayoutOp[] + /** Already ordered for application: the last operation's inverse comes first. */ + readonly inverse: readonly LayoutOp[] +} + +/** A recorded sequence and how much of it is applied. Plain data, safe to store. */ +export interface History { + readonly entries: readonly HistoryEntry[] + /** How many entries are applied; entries beyond it are the redo branch. */ + readonly cursor: number +} + +/** A sequence that has recorded nothing. */ +export const EMPTY_HISTORY: History = { entries: [], cursor: 0 } + +/** A history and the state it produced, returned together so neither can drift. */ +export interface HistoryStep { + readonly history: History + readonly state: LayoutState +} + +/** Operation kinds that only move focus. */ +const FOCUS_OP_TYPES: ReadonlySet = new Set(['focusTab', 'focusPane', 'restoreFocus']) + +/** + * Whether an operation only moves focus, and so merges into its neighbours' undo step. + * @param op - the operation. + * @returns whether its type is a `FocusOpType`. + */ +export function isFocusOp(op: LayoutOp): boolean { + return FOCUS_OP_TYPES.has(op.type) +} + +/** Whether the entry at `index` only moves focus. */ +function isFocusEntry(history: History, index: number): boolean { + const entry = history.entries[index] + return entry !== undefined && entry.ops.every(isFocusOp) +} + +/** + * Whether a step back exists. + * @param history - the sequence so far. + * @returns whether any entry is applied. + */ +export function canStepBack(history: History): boolean { + return history.cursor > 0 +} + +/** + * Whether a step forward exists. + * @param history - the sequence so far. + * @returns whether a redo branch remains. + */ +export function canStepForward(history: History): boolean { + return history.cursor < history.entries.length +} + +/** + * The operations a sequence has recorded, redo branch included. + * @param history - the sequence so far. + * @returns every entry's operations, in recorded order. + */ +export function recordedOps(history: History): readonly LayoutOp[] { + return history.entries.flatMap(entry => entry.ops) +} + +/** + * Apply one intent's operations and record them as one entry, dropping any redo + * branch first. An intent with no operations records nothing. + * @param history - the sequence so far. + * @param state - the state the operations apply to. + * @param ops - the intent's operations, in application order. + * @returns the extended history and the state after the operations. + * @throws when an operation is invalid against the state it reaches; nothing is + * recorded. + */ +export function record(history: History, state: LayoutState, ops: readonly LayoutOp[]): HistoryStep { + if (ops.length === 0) return { history, state } + let next = state + const inverse: LayoutOp[] = [] + for (const op of ops) { + const result = applyOp(next, op) + next = result.state + // Undo runs the inverses in reverse operation order. + inverse.unshift(...result.inverse) + } + const kept = history.cursor === history.entries.length + ? history.entries + : history.entries.slice(0, history.cursor) + return { + history: { entries: [...kept, { ops, inverse }], cursor: history.cursor + 1 }, + state: next, + } +} + +/** + * Step back one intent, or one whole run of consecutive focus-only intents. + * @param history - the sequence so far. + * @param state - the current state. + * @returns the stepped-back pair, or `undefined` when nothing can be undone. + */ +export function stepBack(history: History, state: LayoutState): HistoryStep | undefined { + if (!canStepBack(history)) return undefined + let count = 1 + if (isFocusEntry(history, history.cursor - 1)) { + while (isFocusEntry(history, history.cursor - 1 - count)) count += 1 + } + let next = state + for (const entry of history.entries.slice(history.cursor - count, history.cursor).reverse()) { + for (const op of entry.inverse) next = applyOp(next, op).state + } + return { history: { entries: history.entries, cursor: history.cursor - count }, state: next } +} + +/** + * Step forward over the intents the matching step back undid. + * @param history - the sequence so far. + * @param state - the current state. + * @returns the stepped-forward pair, or `undefined` when nothing can be redone. + */ +export function stepForward(history: History, state: LayoutState): HistoryStep | undefined { + if (!canStepForward(history)) return undefined + let count = 1 + if (isFocusEntry(history, history.cursor)) { + while (isFocusEntry(history, history.cursor + count)) count += 1 + } + let next = state + for (const entry of history.entries.slice(history.cursor, history.cursor + count)) { + for (const op of entry.ops) next = applyOp(next, op).state + } + return { history: { entries: history.entries, cursor: history.cursor + count }, state: next } +} + +/** Layout state plus its history cursor, held here instead of by the embedder. */ +export class Sequencer { + private current: LayoutState + private recorded: History = EMPTY_HISTORY + + /** @param initial - state the sequence replays from; never mutated. */ + constructor(initial: LayoutState) { + this.current = initial + } + + /** Current state. */ + get state(): LayoutState { + return this.current + } + + /** The recorded sequence as plain data. */ + get history(): History { + return this.recorded + } + + /** The whole recorded sequence, including a redo branch that is not applied. */ + get ops(): readonly LayoutOp[] { + return recordedOps(this.recorded) + } + + /** How many recorded operations are currently applied. */ + get cursor(): number { + return this.recorded.cursor + } + + /** Whether a step back exists. */ + get canUndo(): boolean { + return canStepBack(this.recorded) + } + + /** Whether a step forward exists. */ + get canRedo(): boolean { + return canStepForward(this.recorded) + } + + /** + * Apply and record one operation as its own entry, dropping any redo branch first. + * @param op - the operation to record. + * @returns the state after it. + * @throws when the operation is invalid against the current state; the + * sequence is left untouched. + */ + dispatch(op: LayoutOp): LayoutState { + return this.dispatchAll([op]) + } + + /** + * Apply and record one intent's operations as one entry, dropping any redo + * branch first. + * @param ops - the intent's operations; none records nothing. + * @returns the state after them. + * @throws when an operation is invalid; the sequence is left untouched. + */ + dispatchAll(ops: readonly LayoutOp[]): LayoutState { + const stepped = record(this.recorded, this.current, ops) + this.recorded = stepped.history + this.current = stepped.state + return this.current + } + + /** + * Step back one intent, or one whole run of consecutive focus-only intents. + * @returns false when there is nothing to undo. + */ + undo(): boolean { + const stepped = stepBack(this.recorded, this.current) + if (stepped === undefined) return false + this.recorded = stepped.history + this.current = stepped.state + return true + } + + /** + * Step forward over the intents the matching undo stepped back. + * @returns false when there is nothing to redo. + */ + redo(): boolean { + const stepped = stepForward(this.recorded, this.current) + if (stepped === undefined) return false + this.recorded = stepped.history + this.current = stepped.state + return true + } +} diff --git a/packages/client/ui-dockkit/src/engine/tree.ts b/packages/client/ui-dockkit/src/engine/tree.ts new file mode 100644 index 0000000000..de71e580e1 --- /dev/null +++ b/packages/client/ui-dockkit/src/engine/tree.ts @@ -0,0 +1,323 @@ +/** + * Pure tree helpers over `LayoutState`. Every reader throws on a dangling id + * (the operation vocabulary is closed, so a miss is a caller defect), and every + * writer returns a new state that keeps untouched nodes at their old identity. + */ +import type { + FloatRect, LayoutNode, LayoutState, NodeId, PaneId, PaneNode, SplitNode, TabId, TabRecord, +} from '../contract/types.ts' + +/** + * Reject an unhandled discriminant at the end of a closed switch. + * @param value - the discriminant the switch did not handle. + * @param what - the union being switched on, for the message. + * @returns never; it throws. + */ +export function assertNever(value: never, what: string): never { + throw new Error(`${what}: unhandled ${JSON.stringify(value)}`) +} + +/** + * Read any node. + * @param state - current layout. + * @param id - the node. + * @returns the split or pane. + * @throws when `id` is not in the tree. + */ +export function getNode(state: LayoutState, id: NodeId): LayoutNode { + const node = state.nodes[id] + if (node === undefined) throw new Error(`layout: unknown node ${id}`) + return node +} + +/** + * Read a pane. + * @param state - current layout. + * @param id - the pane. + * @returns the pane node. + * @throws when `id` is missing or names a split. + */ +export function getPane(state: LayoutState, id: NodeId): PaneNode { + const node = getNode(state, id) + if (node.kind !== 'pane') throw new Error(`layout: ${id} is not a pane`) + return node +} + +/** + * Read a split. + * @param state - current layout. + * @param id - the split. + * @returns the split node. + * @throws when `id` is missing or names a pane. + */ +export function getSplit(state: LayoutState, id: NodeId): SplitNode { + const node = getNode(state, id) + if (node.kind !== 'split') throw new Error(`layout: ${id} is not a split`) + return node +} + +/** + * Read a tab record. + * @param state - current layout. + * @param id - the tab. + * @returns the record. + * @throws when `id` is not open. + */ +export function getTab(state: LayoutState, id: TabId): TabRecord { + const tab = state.tabs[id] + if (tab === undefined) throw new Error(`layout: unknown tab ${id}`) + return tab +} + +/** + * A floating pane's rectangle. + * @param pane - the pane. + * @returns its viewport rectangle. + * @throws when `pane` is docked. + */ +export function floatRect(pane: PaneNode): FloatRect { + if (pane.host !== 'float' || pane.rect === undefined) throw new Error(`layout: ${pane.id} is not floating`) + return pane.rect +} + +/** + * A floating pane's position in the z order. + * @param state - current layout. + * @param id - the floating pane. + * @returns its index in `floats`, bottom first. + * @throws when `id` is not listed in `floats`. + */ +export function floatIndex(state: LayoutState, id: PaneId): number { + const index = state.floats.indexOf(id) + if (index < 0) throw new Error(`layout: floating pane ${id} is not in the z order`) + return index +} + +/** + * The one tab a pane holds. + * @param pane - the pane. + * @returns its tab's id. + * @throws when `pane` holds any other number of tabs. + */ +export function onlyTabId(pane: PaneNode): TabId { + const tabId = pane.tabs[0] + if (tabId === undefined || pane.tabs.length !== 1) throw new Error(`layout: ${pane.id} does not hold exactly one tab`) + return tabId +} + +/** + * The split holding a node. + * @param state - current layout. + * @param id - the node. + * @returns its parent split, or `undefined` for the docked root and floating panes. + */ +export function findParent(state: LayoutState, id: NodeId): SplitNode | undefined { + for (const node of Object.values(state.nodes)) { + if (node.kind === 'split' && node.children.includes(id)) return node + } + return undefined +} + +/** + * The pane holding a tab. + * @param state - current layout. + * @param tabId - the tab. + * @returns the pane whose strip lists it. + * @throws when no pane lists it. + */ +export function findTabPane(state: LayoutState, tabId: TabId): PaneNode { + for (const node of Object.values(state.nodes)) { + if (node.kind === 'pane' && node.tabs.includes(tabId)) return node + } + throw new Error(`layout: tab ${tabId} has no pane`) +} + +/** + * Docked pane ids in visual order (depth-first through the split tree). + * @param state - current layout. + * @returns every docked pane's id; floating panes are absent. + */ +export function dockPaneIds(state: LayoutState): PaneId[] { + const out: PaneId[] = [] + const walk = (id: NodeId): void => { + const node = getNode(state, id) + if (node.kind === 'pane') { + out.push(node.id) + return + } + for (const child of node.children) walk(child) + } + walk(state.rootId) + return out +} + +/** + * Scale `sizes` so they sum to 1. Input that already sums to 1 is copied + * unchanged, so restoring recorded sizes never drifts. + * @param sizes - fractions or any positive weights. + * @returns the fractions, summing to 1. + * @throws when the input cannot be normalized. + */ +export function normalizeSizes(sizes: readonly number[]): number[] { + const total = sizes.reduce((sum, size) => sum + size, 0) + if (!(total > 0)) throw new Error('layout: sizes must sum above zero') + if (Math.abs(total - 1) < 1e-12) return [...sizes] + return sizes.map(size => size / total) +} + +/** + * `Object.entries` keeping the record's own key type: the keys were written from + * ids, so reading them back as ids is exact. + * @param record - an id-keyed record. + * @returns its entries with typed keys. + */ +export function entriesOf(record: Readonly>): readonly (readonly [K, V])[] { + return Object.entries(record) as [K, V][] +} + +/** + * `Object.keys` keeping the record's own key type; see {@link entriesOf}. + * @param record - an id-keyed record. + * @returns its keys, typed. + */ +export function keysOf(record: Readonly>): readonly K[] { + return Object.keys(record) as K[] +} + +/** + * Replace or delete nodes. + * @param state - current layout. + * @param updates - nodes by id; a `null` update deletes that id. + * @returns the layout with those nodes replaced; untouched nodes keep their identity. + */ +export function withNodes( + state: LayoutState, + updates: Readonly>, +): LayoutState { + const nodes: Record = {} + for (const [id, node] of entriesOf(state.nodes)) { + if (!(id in updates)) nodes[id] = node + } + for (const [id, node] of entriesOf(updates)) { + if (node !== null) nodes[id] = node + } + return { ...state, nodes } +} + +/** + * Replace or delete tab records. + * @param state - current layout. + * @param updates - records by id; a `null` update deletes that id. + * @returns the layout with those records replaced; untouched records keep their identity. + */ +export function withTabs( + state: LayoutState, + updates: Readonly>, +): LayoutState { + const tabs: Record = {} + for (const [id, tab] of entriesOf(state.tabs)) { + if (!(id in updates)) tabs[id] = tab + } + for (const [id, tab] of entriesOf(updates)) { + if (tab !== null) tabs[id] = tab + } + return { ...state, tabs } +} + +/** + * Insert a value into a list. + * @param items - the list. + * @param index - the slot, clamped to the list's bounds. + * @param value - what to insert. + * @returns a new list with the value at the slot. + */ +export function insertAt(items: readonly T[], index: number, value: T): T[] { + const at = Math.max(0, Math.min(index, items.length)) + return [...items.slice(0, at), value, ...items.slice(at)] +} + +/** + * Remove one entry from a list. + * @param items - the list. + * @param index - the entry to drop. + * @returns a new list without it. + */ +export function removeAt(items: readonly T[], index: number): T[] { + return [...items.slice(0, index), ...items.slice(index + 1)] +} + +/** + * Which tab a pane focuses after one leaves it. + * @param tabs - the strip before the removal. + * @param removedIndex - the leaving tab's slot. + * @returns the previous neighbour when one exists, otherwise the next, otherwise `undefined`. + */ +export function neighbourTabId(tabs: readonly TabId[], removedIndex: number): TabId | undefined { + const remaining = removeAt(tabs, removedIndex) + if (remaining.length === 0) return undefined + return remaining[Math.max(0, removedIndex - 1)] +} + +/** + * Copy a pane with a new tab list. + * @param pane - the pane. + * @param tabs - its new strip. + * @param activeTabId - the active tab, which the caller keeps consistent with `tabs`. + * @returns the copied pane. + */ +export function paneWithTabs(pane: PaneNode, tabs: readonly TabId[], activeTabId: TabId | undefined): PaneNode { + return { ...pane, tabs, activeTabId } +} + +/** + * Swap a node for another in its parent's slot, or make the replacement the docked root. + * @param state - current layout. + * @param targetId - the node to swap out. + * @param replacementId - the node taking its slot. + * @returns the layout with the slot rewritten. + * @throws when `targetId` is neither rooted nor parented. + */ +export function replaceInParent(state: LayoutState, targetId: NodeId, replacementId: NodeId): LayoutState { + const parent = findParent(state, targetId) + if (parent === undefined) { + if (state.rootId !== targetId) throw new Error(`layout: ${targetId} is neither rooted nor parented`) + return { ...state, rootId: replacementId } + } + const children = parent.children.map(child => child === targetId ? replacementId : child) + return withNodes(state, { [parent.id]: { ...parent, children } }) +} + +/** Walk from the docked root to a pane, taking the child `choose` names at every split. */ +function descend(state: LayoutState, choose: (split: SplitNode) => NodeId | undefined): PaneId { + let node = getNode(state, state.rootId) + while (node.kind === 'split') { + const next = choose(node) + /* v8 ignore next -- a split holds at least two children, so every choice names one. */ + if (next === undefined) throw new Error(`layout: split ${node.id} has no children`) + node = getNode(state, next) + } + return node.id +} + +/** + * The first docked pane in visual order: the docked root, or the first leaf + * under it. Focus falls back here when the focused pane is removed, and a new + * tab lands here when the focused pane floats. + * @param state - current layout. + * @returns the first docked pane's id. + */ +export function firstDockPaneId(state: LayoutState): PaneId { + return descend(state, split => split.children[0]) +} + +/** + * The docked pane in the top-right corner: from the root, the last child of + * every row split and the first child of every column split. Its tab strip is + * where an embedder's surface-wide controls sit, so they read as the surface's + * own top-right corner however the tree is divided. + * @param state - current layout. + * @returns the top-right docked pane's id. + */ +export function topRightPaneId(state: LayoutState): PaneId { + return descend(state, split => (split.axis === 'row' ? split.children.at(-1) : split.children[0])) +} diff --git a/packages/client/ui-dockkit/src/index.ts b/packages/client/ui-dockkit/src/index.ts new file mode 100644 index 0000000000..f72adb9271 --- /dev/null +++ b/packages/client/ui-dockkit/src/index.ts @@ -0,0 +1,68 @@ +/** + * A docking layout kit: a split tree of tabbed panes with invertible operations, + * and the React components that render and drive it. + * + * Two layers, and the boundary between them is the point of the package. The + * engine (`applyOp`, `Sequencer`, `DockController`) is pure TypeScript with no + * React, no DOM, and no host concepts; replaying a recorded sequence over the + * same initial state reproduces the same tree, because every operation carries + * the ids it creates. The components render a layout snapshot and report settled + * intents — one per gesture, never a drag frame — so the embedder's sequence + * stays the single source of truth. + * + * Nothing host-specific lives here: rendered strings arrive as `DockLabels`, tab + * bodies as a `TabRenderer`, and a tab's `kind` is an opaque string this kit + * never interprets. + * + * @module + */ + +// Model and operations. +export type { + ApplyResult, DockMode, DockZone, FloatRect, LayoutNode, LayoutOp, LayoutState, NodeId, + PaneAttachment, PaneHost, PaneId, PaneNode, SplitAxis, SplitDirection, SplitId, + SplitNode, TabId, TabRecord, +} from './contract/types.ts' +export { applyOp, replay } from './engine/operations.ts' +export { + canStepBack, canStepForward, EMPTY_HISTORY, isFocusOp, record, recordedOps, + Sequencer, stepBack, stepForward, +} from './engine/sequence.ts' +export type { History, HistoryEntry, HistoryStep } from './engine/sequence.ts' +export { + dockPaneIds, findParent, findTabPane, getNode, getPane, getSplit, getTab, topRightPaneId, +} from './engine/tree.ts' + +// Interaction limits and geometry. +export { + canSplit, clampSizes, DOCK_EDGE_FRACTION, DOCK_ZONES, dockPaneCount, + FLOAT_DEFAULT_SIZE, FLOAT_MIN_SIZE, MAX_DOCK_PANES, MIN_PANE_FRACTION, zoneAt, zoneSplit, +} from './engine/constraints.ts' +export { + containsPoint, dividerSizes, DRAG_THRESHOLD, floatRectAt, halvesFit, insertionIndex, + movedRect, passedThreshold, resizedRect, SPLIT_MINIMUMS, zoneInRect, +} from './engine/geometry.ts' +export type { DropTarget, HalvesFit, PaneMeasure, Rect, Size, SplitMinimums } from './engine/geometry.ts' + +// Intent planning: the shared decisions, as pure functions. +export { + activeDockPaneId, findContentTab, findPaneContentTab, planAddTab, planDropTab, planDuplicateTab, planFloatTab, + planOpenContent, planPlaceTab, planResizeSplit, planSetExpanded, planSetMode, + planSettle, planSplitPane, planUnfloatPane, +} from './engine/planner.ts' +export type { Mint, OpenContentInput, PlannedTab } from './engine/planner.ts' + +// The intent layer's stateful embedding, and its seeds. +export { DockController } from './engine/controller.ts' +export type { DockControllerOptions, DockSnapshot } from './engine/controller.ts' +export { createIdMinter, createInitialState } from './engine/initial.ts' +export type { IdMinter, TabFactory } from './engine/initial.ts' + +// Outward contracts. +export type { DockIntents, DockLabels, TabMenuExtras, TabRenderer } from './contract/adapter.ts' + +// React surface. +export { DockSurface } from './components/DockSurface.tsx' +export type { DockSurfaceProps } from './components/DockSurface.tsx' +export { FloatLayer } from './components/FloatLayer.tsx' +export type { FloatLayerProps } from './components/FloatLayer.tsx' diff --git a/packages/client/ui-dockkit/tests/apply.client.spec.ts b/packages/client/ui-dockkit/tests/apply.client.spec.ts new file mode 100644 index 0000000000..f385cfea8e --- /dev/null +++ b/packages/client/ui-dockkit/tests/apply.client.spec.ts @@ -0,0 +1,629 @@ +/** + * Model and operation-engine behavior: what each operation does to the tree, and + * that applying its inverse returns the exact state it started from. + */ +import { describe, expect, it } from 'vitest' +import { applyOp, replay } from '../src/engine/operations.ts' +import { createIdMinter, createInitialState } from '../src/engine/initial.ts' +import { asPane, asSplit, asTab, childAt, fileTab, firstTab, seedTab } from './fixtures.client.ts' +import { + assertNever, dockPaneIds, findParent, findTabPane, firstDockPaneId, floatIndex, floatRect, getNode, getPane, getSplit, + getTab, normalizeSizes, onlyTabId, replaceInParent, topRightPaneId, +} from '../src/engine/tree.ts' +import type { IdMinter } from '../src/engine/initial.ts' +import type { LayoutOp, LayoutState, PaneId, PaneNode, TabId } from '../src/contract/types.ts' + +interface Fixture { + readonly state: LayoutState + readonly minter: IdMinter + readonly paneId: PaneId + readonly guideTabId: TabId +} + +function fixture(): Fixture { + const minter = createIdMinter() + const state = createInitialState(minter, seedTab) + const paneId = getPane(state, state.rootId).id + const guideTabId = getPane(state, paneId).tabs[0] + if (guideTabId === undefined) throw new Error('fixture: initial pane has no guide tab') + return { state, minter, paneId, guideTabId } +} + +/** Apply `op`, then its inverse, and require the original state back. */ +function expectRoundTrip(state: LayoutState, op: LayoutOp): LayoutState { + const result = applyOp(state, op) + const back = result.inverse.reduce((current, inverse) => applyOp(current, inverse).state, result.state) + expect(back).toEqual(state) + return result.state +} + +/** Split `paneId` to the right, returning the state and the new pane id. */ +function split( + state: LayoutState, + minter: IdMinter, + paneId: PaneId, + axis: 'row' | 'column' = 'row', + direction: 'before' | 'after' = 'after', +): { state: LayoutState; newPaneId: PaneId } { + const newPaneId = minter.next('pane') + const op: LayoutOp = { type: 'split', paneId, axis, direction, newPaneId, newSplitId: minter.next('split') } + return { state: expectRoundTrip(state, op), newPaneId } +} + +describe('initial state', () => { + it('starts collapsed with one docked pane holding an active guide tab', () => { + const { state, paneId, guideTabId } = fixture() + expect(state.expanded).toBe(false) + expect(dockPaneIds(state)).toEqual([paneId]) + expect(state.floats).toEqual([]) + expect(state.activePaneId).toBe(paneId) + const pane = getPane(state, paneId) + expect(pane.tabs).toEqual([guideTabId]) + expect(pane.activeTabId).toBe(guideTabId) + expect(state.tabs[guideTabId]?.kind).toBe('seed') + }) +}) + +describe('split', () => { + it('wraps the reference pane in a new split and adds an empty sibling', () => { + const { state, minter, paneId } = fixture() + const after = split(state, minter, paneId).state + const root = getSplit(after, after.rootId) + expect(root.axis).toBe('row') + expect(root.children).toHaveLength(2) + expect(root.children[0]).toBe(paneId) + expect(root.sizes).toEqual([0.5, 0.5]) + expect(dockPaneIds(after)).toHaveLength(2) + const sibling = getPane(after, childAt(root, 1)) + expect(sibling.tabs).toEqual([]) + expect(sibling.activeTabId).toBeUndefined() + }) + + it('puts the new pane before the reference pane when asked', () => { + const { state, minter, paneId } = fixture() + const after = split(state, minter, paneId, 'row', 'before').state + expect(getSplit(after, after.rootId).children[1]).toBe(paneId) + }) + + it('joins an existing split of the same axis instead of nesting', () => { + const { state, minter, paneId } = fixture() + const first = split(state, minter, paneId) + const second = split(first.state, minter, paneId) + const root = getSplit(second.state, second.state.rootId) + expect(root.children).toHaveLength(3) + expect(root.sizes).toEqual([0.25, 0.25, 0.5]) + expect(dockPaneIds(second.state)).toHaveLength(3) + }) + + it('joins an existing split before the reference pane when asked', () => { + const { state, minter, paneId } = fixture() + const first = split(state, minter, paneId) + const second = split(first.state, minter, paneId, 'row', 'before') + const root = getSplit(second.state, second.state.rootId) + expect(root.children).toEqual([second.newPaneId, paneId, first.newPaneId]) + expect(root.sizes).toEqual([0.25, 0.25, 0.5]) + }) + + it('nests when the parent split runs on the other axis', () => { + const { state, minter, paneId } = fixture() + const first = split(state, minter, paneId) + const second = split(first.state, minter, first.newPaneId, 'column') + const root = getSplit(second.state, second.state.rootId) + expect(root.axis).toBe('row') + expect(root.children).toHaveLength(2) + const nested = getSplit(second.state, childAt(root, 1)) + expect(nested.axis).toBe('column') + expect(nested.children).toEqual([first.newPaneId, second.newPaneId]) + }) + + it('refuses an unknown pane and a floating pane', () => { + const { state, minter, paneId, guideTabId } = fixture() + expect(() => applyOp(state, { + type: 'split', paneId: asPane('nope'), axis: 'row', direction: 'after', newPaneId: asPane('p'), newSplitId: asSplit('s'), + })).toThrow(/unknown node/) + const floatId = minter.next('float') + const floated = applyOp(state, { + type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 }, + }).state + expect(() => applyOp(floated, { + type: 'split', paneId: floatId, axis: 'row', direction: 'after', newPaneId: asPane('p'), newSplitId: asSplit('s'), + })).toThrow(/docked pane/) + expect(paneId).toBeDefined() + }) +}) + +describe('merge', () => { + it('collapses a two-child split back into its surviving pane', () => { + const { state, minter, paneId } = fixture() + const after = split(state, minter, paneId) + const merged = expectRoundTrip(after.state, { type: 'merge', paneId: after.newPaneId }) + expect(merged.rootId).toBe(paneId) + expect(dockPaneIds(merged)).toEqual([paneId]) + expect(merged.nodes[after.newPaneId]).toBeUndefined() + }) + + it('drops one child of a wider split and renormalizes the rest', () => { + const { state, minter, paneId } = fixture() + const first = split(state, minter, paneId) + const second = split(first.state, minter, paneId) + const merged = expectRoundTrip(second.state, { type: 'merge', paneId: second.newPaneId }) + const root = getSplit(merged, merged.rootId) + expect(root.children).toHaveLength(2) + expect(root.sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1) + }) + + it('refuses a non-empty pane and the docked root pane', () => { + const { state, minter, paneId } = fixture() + expect(() => applyOp(state, { type: 'merge', paneId })).toThrow(/empty pane/) + const after = split(state, minter, paneId) + expect(() => applyOp(after.state, { type: 'merge', paneId: asPane(after.newPaneId + 'x') })).toThrow(/unknown node/) + const emptied = applyOp(after.state, { type: 'closeTab', tabId: firstTab(getPane(after.state, paneId)) }).state + const collapsed = applyOp(emptied, { type: 'merge', paneId }).state + expect(() => applyOp(collapsed, { type: 'merge', paneId: after.newPaneId })).toThrow(/root pane/) + }) + + it('reseats focus when the merged pane held it', () => { + const { state, minter, paneId } = fixture() + const after = split(state, minter, paneId) + const focused = applyOp(after.state, { type: 'focusPane', paneId: after.newPaneId }).state + expect(focused.activePaneId).toBe(after.newPaneId) + const merged = expectRoundTrip(focused, { type: 'merge', paneId: after.newPaneId }) + expect(merged.activePaneId).toBe(paneId) + }) +}) + +describe('tabs', () => { + it('opens a tab into a pane and focuses it', () => { + const { state, minter, paneId, guideTabId } = fixture() + const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt') + const after = expectRoundTrip(state, { type: 'openTab', paneId, tab, index: 0 }) + const pane = getPane(after, paneId) + expect(pane.tabs).toEqual([tab.id, guideTabId]) + expect(pane.activeTabId).toBe(tab.id) + expect(after.activePaneId).toBe(paneId) + }) + + it('refuses to reopen a live tab id', () => { + const { state, paneId, guideTabId } = fixture() + const clash = { id: guideTabId, kind: 'guide' as const, contentId: 'x', title: 'x' } + expect(() => applyOp(state, { type: 'openTab', paneId, tab: clash, index: 0 })).toThrow(/already exists/) + }) + + it('closes the active tab onto its previous neighbour', () => { + const { state, minter, paneId, guideTabId } = fixture() + const first = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt') + const second = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/b.txt', 'b.txt') + let current = applyOp(state, { type: 'openTab', paneId, tab: first, index: 1 }).state + current = applyOp(current, { type: 'openTab', paneId, tab: second, index: 2 }).state + expect(getPane(current, paneId).tabs).toEqual([guideTabId, first.id, second.id]) + const after = expectRoundTrip(current, { type: 'closeTab', tabId: second.id }) + const pane = getPane(after, paneId) + expect(pane.tabs).toEqual([guideTabId, first.id]) + expect(pane.activeTabId).toBe(first.id) + expect(after.tabs[second.id]).toBeUndefined() + }) + + it('closes a tab that is not the active one without moving focus', () => { + const { state, minter, paneId, guideTabId } = fixture() + const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt') + const opened = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state + const after = expectRoundTrip(opened, { type: 'closeTab', tabId: guideTabId }) + expect(getPane(after, paneId).tabs).toEqual([tab.id]) + expect(getPane(after, paneId).activeTabId).toBe(tab.id) + }) + + it('leaves an empty pane with no active tab', () => { + const { state, paneId, guideTabId } = fixture() + const after = expectRoundTrip(state, { type: 'closeTab', tabId: guideTabId }) + const pane = getPane(after, paneId) + expect(pane.tabs).toEqual([]) + expect(pane.activeTabId).toBeUndefined() + }) + + it('moves a tab across panes and focuses the destination', () => { + const { state, minter, paneId, guideTabId } = fixture() + const after = split(state, minter, paneId) + const moved = expectRoundTrip(after.state, { type: 'moveTab', tabId: guideTabId, toPaneId: after.newPaneId, index: 0 }) + expect(getPane(moved, paneId).tabs).toEqual([]) + expect(getPane(moved, after.newPaneId).tabs).toEqual([guideTabId]) + expect(moved.activePaneId).toBe(after.newPaneId) + }) + + it('moves a tab that is not the active one, leaving the source focus alone', () => { + const { state, minter, paneId, guideTabId } = fixture() + const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt') + const opened = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state + const after = split(opened, minter, paneId) + const moved = expectRoundTrip(after.state, { type: 'moveTab', tabId: guideTabId, toPaneId: after.newPaneId, index: 0 }) + expect(getPane(moved, paneId).tabs).toEqual([tab.id]) + expect(getPane(moved, paneId).activeTabId).toBe(tab.id) + expect(getPane(moved, after.newPaneId).activeTabId).toBe(guideTabId) + }) + + it('refuses a cross-pane move onto the tab own pane', () => { + const { state, paneId, guideTabId } = fixture() + expect(() => applyOp(state, { type: 'moveTab', tabId: guideTabId, toPaneId: paneId, index: 0 })) + .toThrow(/reorderTab/) + }) + + it('reorders inside one pane without changing the active tab', () => { + const { state, minter, paneId, guideTabId } = fixture() + const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt') + const opened = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state + const after = expectRoundTrip(opened, { type: 'reorderTab', tabId: tab.id, index: 0 }) + expect(getPane(after, paneId).tabs).toEqual([tab.id, guideTabId]) + expect(getPane(after, paneId).activeTabId).toBe(tab.id) + }) +}) + +describe('focus', () => { + it('focuses a tab and its pane', () => { + const { state, minter, paneId, guideTabId } = fixture() + const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt') + const opened = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state + const after = expectRoundTrip(opened, { type: 'focusTab', tabId: guideTabId }) + expect(getPane(after, paneId).activeTabId).toBe(guideTabId) + }) + + it('raises a floating pane when its tab takes focus', () => { + const { state, minter, paneId, guideTabId } = fixture() + const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt') + let current = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state + const firstFloat = minter.next('float') + const secondFloat = minter.next('float') + current = applyOp(current, { + type: 'float', tabId: guideTabId, newPaneId: firstFloat, rect: { x: 0, y: 0, width: 100, height: 100 }, + }).state + current = applyOp(current, { + type: 'float', tabId: tab.id, newPaneId: secondFloat, rect: { x: 10, y: 10, width: 100, height: 100 }, + }).state + const after = expectRoundTrip(current, { type: 'focusTab', tabId: guideTabId }) + expect(after.floats).toEqual([secondFloat, firstFloat]) + expect(after.activePaneId).toBe(firstFloat) + }) + + it('raises a floating pane when it takes focus', () => { + const { state, minter, paneId, guideTabId } = fixture() + const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt') + let current = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state + const firstFloat = minter.next('float') + const secondFloat = minter.next('float') + current = applyOp(current, { + type: 'float', tabId: guideTabId, newPaneId: firstFloat, rect: { x: 0, y: 0, width: 100, height: 100 }, + }).state + current = applyOp(current, { + type: 'float', tabId: tab.id, newPaneId: secondFloat, rect: { x: 10, y: 10, width: 100, height: 100 }, + }).state + expect(current.floats).toEqual([firstFloat, secondFloat]) + const after = expectRoundTrip(current, { type: 'focusPane', paneId: firstFloat }) + expect(after.floats).toEqual([secondFloat, firstFloat]) + expect(after.activePaneId).toBe(firstFloat) + }) +}) + +describe('resize', () => { + it('replaces divider sizes', () => { + const { state, minter, paneId } = fixture() + const after = split(state, minter, paneId) + const resized = expectRoundTrip(after.state, { type: 'resize', splitId: getSplit(after.state, after.state.rootId).id, sizes: [0.3, 0.7] }) + expect(getSplit(resized, resized.rootId).sizes).toEqual([0.3, 0.7]) + }) + + it('rejects the wrong count and non-positive sizes', () => { + const { state, minter, paneId } = fixture() + const after = split(state, minter, paneId) + const splitId = getSplit(after.state, after.state.rootId).id + expect(() => applyOp(after.state, { type: 'resize', splitId, sizes: [1] })).toThrow(/do not match/) + expect(() => applyOp(after.state, { type: 'resize', splitId, sizes: [0, 1] })).toThrow(/above zero/) + }) +}) + +describe('floating panes', () => { + it('takes a tab out of the docked tree onto the top of the z order', () => { + const { state, minter, paneId, guideTabId } = fixture() + const floatId = minter.next('float') + const rect = { x: 40, y: 50, width: 300, height: 200 } + const after = expectRoundTrip(state, { type: 'float', tabId: guideTabId, newPaneId: floatId, rect }) + expect(getPane(after, paneId).tabs).toEqual([]) + expect(after.floats).toEqual([floatId]) + expect(after.activePaneId).toBe(floatId) + const pane = getPane(after, floatId) + expect(pane.host).toBe('float') + expect(pane.tabs).toEqual([guideTabId]) + expect(pane.rect).toEqual(rect) + expect(findParent(after, floatId)).toBeUndefined() + }) + + it('returns a floating tab to a docked pane and drops the floating pane', () => { + const { state, minter, paneId, guideTabId } = fixture() + const floatId = minter.next('float') + const floated = applyOp(state, { + type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 }, + }).state + const docked = expectRoundTrip(floated, { type: 'unfloat', paneId: floatId, toPaneId: paneId, index: 0 }) + expect(docked.floats).toEqual([]) + expect(docked.nodes[floatId]).toBeUndefined() + expect(getPane(docked, paneId).tabs).toEqual([guideTabId]) + expect(docked.activePaneId).toBe(paneId) + }) + + it('destroys the floating pane together with its only tab', () => { + const { state, minter, paneId, guideTabId } = fixture() + const floatId = minter.next('float') + const floated = applyOp(state, { + type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 }, + }).state + const closed = expectRoundTrip(floated, { type: 'closeTab', tabId: guideTabId }) + expect(closed.floats).toEqual([]) + expect(closed.nodes[floatId]).toBeUndefined() + expect(closed.tabs[guideTabId]).toBeUndefined() + expect(closed.activePaneId).toBe(paneId) + }) + + it('records net drag and resize results, focusing and raising the pane they reshape', () => { + const { state, minter, paneId, guideTabId } = fixture() + const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt') + const floatId = minter.next('float') + const topId = minter.next('float') + let current = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state + current = applyOp(current, { + type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 }, + }).state + current = applyOp(current, { + type: 'float', tabId: tab.id, newPaneId: topId, rect: { x: 10, y: 10, width: 100, height: 100 }, + }).state + current = applyOp(current, { type: 'focusPane', paneId }).state + expect(current.floats).toEqual([floatId, topId]) + expect(current.activePaneId).toBe(paneId) + + // The lower, unfocused panel is dragged: it ends on top and focused; undo puts both facts back. + const moved = expectRoundTrip(current, { type: 'moveFloat', paneId: floatId, x: 25, y: 35 }) + expect(getPane(moved, floatId).rect).toEqual({ x: 25, y: 35, width: 100, height: 100 }) + expect(moved.floats).toEqual([topId, floatId]) + expect(moved.activePaneId).toBe(floatId) + + const lowered = applyOp(moved, { type: 'focusPane', paneId: topId }).state + const resized = expectRoundTrip(lowered, { + type: 'resizeFloat', paneId: floatId, rect: { x: 25, y: 35, width: 420, height: 260 }, + }) + expect(getPane(resized, floatId).rect).toEqual({ x: 25, y: 35, width: 420, height: 260 }) + expect(resized.floats).toEqual([topId, floatId]) + expect(resized.activePaneId).toBe(floatId) + expect(() => applyOp(moved, { + type: 'resizeFloat', paneId: floatId, rect: { x: 0, y: 0, width: 0, height: 10 }, + })).toThrow(/above zero/) + }) + + it('refuses to float a tab that already floats', () => { + const { state, minter, guideTabId } = fixture() + const floatId = minter.next('float') + const floated = applyOp(state, { + type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 }, + }).state + expect(() => applyOp(floated, { + type: 'float', tabId: guideTabId, newPaneId: minter.next('float'), rect: { x: 0, y: 0, width: 10, height: 10 }, + })).toThrow(/docked tab/) + }) +}) + +describe('presentation flags', () => { + it('round-trips the collapsed flag', () => { + const { state } = fixture() + const after = expectRoundTrip(state, { type: 'setExpanded', expanded: true }) + expect(after.expanded).toBe(true) + }) + + it('round-trips the presentation mode', () => { + const { state } = fixture() + expect(state.mode).toBe('push') + const after = expectRoundTrip(state, { type: 'setMode', mode: 'fullscreen' }) + expect(after.mode).toBe('fullscreen') + // The two flags are independent: switching one leaves the other alone. + expect(after.expanded).toBe(state.expanded) + }) + + it('starts in the presentation the embedder seeded', () => { + const minter = createIdMinter() + expect(createInitialState(minter, seedTab, 'fullscreen').mode).toBe('fullscreen') + }) +}) + +describe('tree readers', () => { + /** The seeded pane floated out, leaving the root pane empty. */ + function floated(): { state: LayoutState; paneId: PaneId; floatId: PaneId; guideTabId: TabId } { + const { state, minter, paneId, guideTabId } = fixture() + const floatId = minter.next('float') + const next = applyOp(state, { + type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 }, + }).state + return { state: next, paneId, floatId, guideTabId } + } + + it('throw on a dangling id or a node of the other kind', () => { + const { state, minter, paneId } = fixture() + const after = split(state, minter, paneId).state + expect(() => getNode(after, asPane('nope'))).toThrow(/unknown node nope/) + expect(() => getPane(after, after.rootId)).toThrow(/is not a pane/) + expect(() => getSplit(after, paneId)).toThrow(/is not a split/) + expect(() => getTab(after, asTab('nope'))).toThrow(/unknown tab nope/) + expect(() => findTabPane(after, asTab('nope'))).toThrow(/has no pane/) + expect(() => replaceInParent(after, asPane('nope'), paneId)).toThrow(/neither rooted nor parented/) + }) + + it('read a floating pane\'s rectangle, z position, and only tab, and refuse a docked one', () => { + const { state, paneId, floatId, guideTabId } = floated() + const pane = getPane(state, floatId) + expect(floatRect(pane)).toEqual({ x: 0, y: 0, width: 100, height: 100 }) + expect(floatIndex(state, floatId)).toBe(0) + expect(onlyTabId(pane)).toBe(guideTabId) + const docked = getPane(state, paneId) + expect(() => floatRect(docked)).toThrow(/is not floating/) + expect(() => floatIndex(state, paneId)).toThrow(/not in the z order/) + expect(() => onlyTabId(docked)).toThrow(/exactly one tab/) + const two: PaneNode = { ...pane, tabs: [guideTabId, asTab('other')] } + expect(() => onlyTabId(two)).toThrow(/exactly one tab/) + }) + + it('name the first and the top-right docked pane however the tree is divided', () => { + const { state, minter, paneId } = fixture() + expect(firstDockPaneId(state)).toBe(paneId) + expect(topRightPaneId(state)).toBe(paneId) + const right = split(state, minter, paneId) + const below = split(right.state, minter, right.newPaneId, 'column') + expect(firstDockPaneId(below.state)).toBe(paneId) + expect(topRightPaneId(below.state)).toBe(right.newPaneId) + const above = split(below.state, minter, paneId, 'column', 'before') + expect(firstDockPaneId(above.state)).toBe(above.newPaneId) + }) + + it('normalize sizes without drifting an exact sum, and refuse a zero total', () => { + const exact = [0.25, 0.75] + expect(normalizeSizes(exact)).toEqual(exact) + expect(normalizeSizes(exact)).not.toBe(exact) + expect(normalizeSizes([1, 3])).toEqual([0.25, 0.75]) + expect(() => normalizeSizes([0, 0])).toThrow(/sum above zero/) + }) + + it('assertNever names the value a closed switch failed to handle', () => { + expect(() => { assertNever('nope' as never, 'layout: thing') }).toThrow('layout: thing: unhandled "nope"') + }) +}) + +describe('operations refuse', () => { + /** A second docked pane plus a floating one holding a content tab. */ + function mixed(): { + state: LayoutState + minter: IdMinter + paneId: PaneId + secondPaneId: PaneId + floatId: PaneId + guideTabId: TabId + floatTabId: TabId + } { + const { state, minter, paneId, guideTabId } = fixture() + const second = split(state, minter, paneId) + const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt') + const opened = applyOp(second.state, { type: 'openTab', paneId, tab, index: 1 }).state + const floatId = minter.next('float') + const floated = applyOp(opened, { + type: 'float', tabId: tab.id, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 }, + }).state + return { state: floated, minter, paneId, secondPaneId: second.newPaneId, floatId, guideTabId, floatTabId: tab.id } + } + + it('opening or inserting a tab into a floating pane', () => { + const { state, minter, floatId } = mixed() + const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/b.txt', 'b.txt') + expect(() => applyOp(state, { type: 'openTab', paneId: floatId, tab, index: 0 })).toThrow(/docked pane/) + expect(() => applyOp(state, { type: 'insertTab', paneId: floatId, tab, index: 0 })).toThrow(/docked pane/) + }) + + it('moving a tab out of or into a floating pane, which is what unfloat and float are for', () => { + const { state, paneId, floatId, guideTabId, floatTabId } = mixed() + expect(() => applyOp(state, { type: 'moveTab', tabId: floatTabId, toPaneId: paneId, index: 0 })).toThrow(/use unfloat/) + expect(() => applyOp(state, { type: 'moveTab', tabId: guideTabId, toPaneId: floatId, index: 0 })).toThrow(/target must be docked/) + }) + + it('returning a floating tab anywhere but a docked pane, and treating a docked pane as floating', () => { + const { state, minter, paneId, floatId, guideTabId } = mixed() + const otherFloat = minter.next('float') + const twoFloats = applyOp(state, { + type: 'float', tabId: guideTabId, newPaneId: otherFloat, rect: { x: 5, y: 5, width: 100, height: 100 }, + }).state + expect(() => applyOp(twoFloats, { type: 'unfloat', paneId: floatId, toPaneId: otherFloat, index: 0 })) + .toThrow(/target must be docked/) + expect(() => applyOp(state, { type: 'unfloat', paneId, toPaneId: paneId, index: 0 })).toThrow(/is not floating/) + expect(() => applyOp(state, { type: 'moveFloat', paneId, x: 1, y: 1 })).toThrow(/is not floating/) + expect(() => applyOp(state, { type: 'resizeFloat', paneId, rect: { x: 0, y: 0, width: 10, height: 10 } })) + .toThrow(/is not floating/) + }) + + it('creating a node under an id that is already taken', () => { + const { state, minter, paneId, secondPaneId } = mixed() + expect(() => applyOp(state, { + type: 'split', paneId, axis: 'column', direction: 'after', newPaneId: secondPaneId, newSplitId: minter.next('split'), + })).toThrow(/already exists/) + expect(() => applyOp(state, { + type: 'split', paneId, axis: 'column', direction: 'after', newPaneId: minter.next('pane'), newSplitId: asSplit(state.rootId), + })).toThrow(/already exists/) + }) + + it('putting a pane back with records that do not match the pane or the split', () => { + const { state, minter, paneId, secondPaneId, floatId } = mixed() + const pane = getPane(state, secondPaneId) + const stray = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/c.txt', 'c.txt') + const fresh: PaneNode = { ...pane, id: minter.next('pane') } + const parent = getSplit(state, state.rootId) + expect(() => applyOp(state, { + type: 'insertPane', pane, tabs: [], attach: { mode: 'child', parentId: parent.id, index: 1, sizes: parent.sizes }, + })).toThrow(/already exists/) + expect(() => applyOp(state, { + type: 'insertPane', pane: fresh, tabs: [stray], attach: { mode: 'child', parentId: parent.id, index: 1, sizes: parent.sizes }, + })).toThrow(/do not match the pane/) + expect(() => applyOp(state, { + type: 'insertPane', pane: fresh, tabs: [], attach: { mode: 'child', parentId: parent.id, index: 1, sizes: parent.sizes }, + })).toThrow(/sizes do not match the split/) + expect(() => applyOp(state, { + type: 'insertPane', pane: fresh, tabs: [], attach: { mode: 'wrap', targetId: paneId, split: parent }, + })).toThrow(/does not list the pane/) + expect(() => applyOp(state, { + type: 'insertPane', pane: fresh, tabs: [], attach: { mode: 'float', index: 0 }, + })).toThrow(/requires a floating pane/) + expect(floatId).toBeDefined() + }) + + it('restoring focus facts that name a docked pane as floating or an unknown pane', () => { + const { state, paneId } = mixed() + expect(() => applyOp(state, { type: 'restoreFocus', activePaneId: paneId, floats: [paneId], paneActiveTabs: {} })) + .toThrow(/as floating/) + expect(() => applyOp(state, { type: 'restoreFocus', activePaneId: paneId, floats: [], paneActiveTabs: { [asPane('nope')]: undefined } })) + .toThrow(/unknown node/) + }) +}) + +describe('inverses the engine itself never records', () => { + it('refuse to return a docked pane with tab records: closeTab records those through insertTab', () => { + const { state, minter, paneId } = fixture() + const after = split(state, minter, paneId) + const parent = getSplit(after.state, after.state.rootId) + const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt') + const pane: PaneNode = { kind: 'pane', id: minter.next('pane'), host: 'dock', tabs: [tab.id], activeTabId: tab.id, rect: undefined } + expect(() => applyOp(after.state, { + type: 'insertPane', pane, tabs: [tab], attach: { mode: 'child', parentId: parent.id, index: 2, sizes: [0.25, 0.25, 0.5] }, + })).toThrow(/returns a docked pane empty/) + }) + + it('merge away an empty floating pane and restore it at its z position', () => { + const { state, minter } = fixture() + const pane: PaneNode = { + kind: 'pane', id: minter.next('float'), host: 'float', tabs: [], activeTabId: undefined, rect: { x: 1, y: 2, width: 50, height: 40 }, + } + const inserted = expectRoundTrip(state, { type: 'insertPane', pane, tabs: [], attach: { mode: 'float', index: 0 } }) + expect(inserted.floats).toEqual([pane.id]) + const merged = expectRoundTrip(inserted, { type: 'merge', paneId: pane.id }) + expect(merged.floats).toEqual([]) + expect(merged.nodes[pane.id]).toBeUndefined() + }) +}) + +describe('replay', () => { + it('rebuilds the same tree from the recorded operations', () => { + const { state, minter, paneId, guideTabId } = fixture() + const preview = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt') + const secondPaneId = minter.next('pane') + const floatId = minter.next('float') + const ops: LayoutOp[] = [ + { type: 'setExpanded', expanded: true }, + { type: 'openTab', paneId, tab: preview, index: 1 }, + { type: 'split', paneId, axis: 'row', direction: 'after', newPaneId: secondPaneId, newSplitId: minter.next('split') }, + { type: 'moveTab', tabId: preview.id, toPaneId: secondPaneId, index: 0 }, + { type: 'focusTab', tabId: guideTabId }, + { type: 'float', tabId: preview.id, newPaneId: floatId, rect: { x: 10, y: 20, width: 200, height: 150 } }, + { type: 'moveFloat', paneId: floatId, x: 60, y: 70 }, + ] + const direct = ops.reduce((current, op) => applyOp(current, op).state, state) + expect(replay(state, ops)).toEqual(direct) + expect(replay(state, ops)).toEqual(replay(state, ops)) + expect(findTabPane(direct, preview.id).id).toBe(floatId) + }) +}) diff --git a/packages/client/ui-dockkit/tests/components.client.spec.tsx b/packages/client/ui-dockkit/tests/components.client.spec.tsx new file mode 100644 index 0000000000..0ff855f694 --- /dev/null +++ b/packages/client/ui-dockkit/tests/components.client.spec.tsx @@ -0,0 +1,1150 @@ +// @vitest-environment jsdom +/** + * Component-level behaviour of the kit's React surface, driven by props alone: + * no cordis, no slot registry, no scaffold. These assert what a user sees and + * which settled intent each gesture reports — the layout maths itself is covered + * by the engine suites. + * + * jsdom lays nothing out, so the gesture specs hand the surface a layout: panes + * of one width side by side, each 600px tall with a 36px strip and 100px chips. + */ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DockIntents } from '../src/contract/adapter.ts' +import type { PaneId, TabId } from '../src/contract/types.ts' +import { DockController } from '../src/engine/controller.ts' +import { applyOp } from '../src/engine/operations.ts' +import { FLOAT_DEFAULT_SIZE, FLOAT_MIN_SIZE } from '../src/engine/constraints.ts' +import { floatRectAt } from '../src/engine/geometry.ts' +import { DockSurface, type DockSurfaceProps } from '../src/components/DockSurface.tsx' +import type { TabMenuExtras } from '../src/contract/adapter.ts' +import { FloatLayer } from '../src/components/FloatLayer.tsx' +import { dockPaneIds, getPane } from '../src/engine/tree.ts' +import { TEST_LABELS, asPane, asTab, fileTab, seededController } from './fixtures.client.ts' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +/** Every intent as a spy, so a spec can assert exactly which one fired. */ +function spyIntents(): DockIntents & Record> { + return { + focusTab: vi.fn(), + focusPane: vi.fn(), + splitPane: vi.fn(), + addTab: vi.fn(), + closeTab: vi.fn(), + duplicateTab: vi.fn(), + floatTab: vi.fn(), + unfloatPane: vi.fn(), + placeTab: vi.fn(), + dropTab: vi.fn(), + moveFloat: vi.fn(), + resizeFloat: vi.fn(), + resizeSplit: vi.fn(), + } as DockIntents & Record> +} + +/** Render the docked surface over a controller's current layout. */ +function renderSurface( + controller: DockController, + intents: DockIntents, + canSplit = true, + renderTabMenuItems?: TabMenuExtras, + options: Pick = {}, +) { + const snapshot = controller.getSnapshot() + return render( +

{tab.contentId}

} + {...renderTabMenuItems === undefined ? {} : { renderTabMenuItems }} + />, + ) +} + +const box = (x: number, y: number, width: number, height: number): DOMRect => + ({ x, y, width, height, top: y, left: x, right: x + width, bottom: y + height, toJSON: () => ({}) }) + +/** Height every laid-out pane takes. */ +const PANE_HEIGHT = 600 + +/** Width of one laid-out chip. */ +const CHIP_WIDTH = 100 + +/** + * Lay the surface out: `panes` in visual order, each `paneWidth` wide, side by + * side from the origin. Every pane's strip is 36px tall inside a 1px border, + * its chips 100px wide from the strip's left edge, its fixed controls 104px. + * Dividers are 4px wide; the surface and every split container span every + * pane, except the split containers `splits` gives their own box. + */ +function layOut(panes: readonly PaneId[], paneWidth: number, splits: Readonly> = {}): void { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.hasAttribute('data-dockkit-split')) { + return splits[this.getAttribute('data-dockkit-split') ?? ''] ?? box(0, 0, panes.length * paneWidth, PANE_HEIGHT) + } + if (this.hasAttribute('data-dockkit-surface')) return box(0, 0, panes.length * paneWidth, PANE_HEIGHT) + if (this.hasAttribute('data-dockkit-divider')) return box(0, 0, 4, PANE_HEIGHT) + const pane = this.closest('[data-dockkit-pane]') + const index = pane === null ? -1 : panes.indexOf(asPane(pane.dataset.dockkitPane ?? '')) + if (pane === null || index < 0) return box(0, 0, 0, 0) + const left = index * paneWidth + if (this === pane) return box(left, 0, paneWidth, PANE_HEIGHT) + const chips = [...pane.querySelectorAll('[data-dockkit-tab]')] + if (this.hasAttribute('data-dockkit-strip')) return box(left + 1, 1, paneWidth - 2, 36) + if (this.hasAttribute('data-dockkit-strip-tabs')) return box(left + 1, 1, chips.length * CHIP_WIDTH, 34) + if (this.hasAttribute('data-dockkit-strip-fill')) { + return box(left + 1 + chips.length * CHIP_WIDTH + 32, 1, Math.max(0, paneWidth - 2 - chips.length * CHIP_WIDTH - 104), 34) + } + if (this.hasAttribute('data-dockkit-tab')) return box(left + 1 + chips.indexOf(this) * CHIP_WIDTH, 1, CHIP_WIDTH, 34) + return box(0, 0, 0, 0) + }) +} + +/** Press `element` at a point and move to another, releasing there unless told not to. */ +function drag(element: Element, from: readonly [number, number], to: readonly [number, number], release = true): void { + fireEvent.pointerDown(element, { clientX: from[0], clientY: from[1], pointerId: 7, button: 0 }) + fireEvent.pointerMove(window, { pointerId: 7, clientX: to[0], clientY: to[1] }) + if (release) fireEvent.pointerUp(window, { pointerId: 7, clientX: to[0], clientY: to[1] }) +} + +/** Two seeded docked panes, the first also holding a content tab, with the layout state on a spied intent set. */ +function twoPanes( + paneWidth = 420, + canSplit = true, + options: Pick = {}, +): { + intents: ReturnType + first: PaneId + second: PaneId + seedTabId: TabId + fileTabId: TabId + chip: (tabId: TabId) => HTMLElement + unmount: () => void +} { + const controller = seededController() + controller.setExpanded(true) + controller.splitPane() + const [first, second] = dockPaneIds(controller.getSnapshot().state) + if (first === undefined || second === undefined) throw new Error('expected two docked panes') + const fileTabId = controller.openContent({ + contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file', paneId: first, + }) + const seedTabId = getPane(controller.getSnapshot().state, first).tabs[0] + if (seedTabId === undefined) throw new Error('expected the seeded tab') + layOut([first, second], paneWidth) + const intents = spyIntents() + const { unmount } = renderSurface(controller, intents, canSplit, undefined, options) + const chip = (tabId: TabId): HTMLElement => { + const element = document.querySelector(`[data-dockkit-tab="${tabId}"]`) + if (element === null) throw new Error(`no chip for ${tabId}`) + return element + } + return { intents, first, second, seedTabId, fileTabId, chip, unmount } +} + +/** The chip the layout puts second in the first pane: pressed at its centre. */ +const FILE_CHIP: readonly [number, number] = [151, 18] + +/** Which gesture intents fired, in order. */ +function gestureIntents(intents: ReturnType): string[] { + return (['placeTab', 'dropTab', 'floatTab', 'resizeSplit'] as const).filter(name => intents[name].mock.calls.length > 0) +} + +describe('DockSurface', () => { + it('renders each pane with its tabs and the active tab body', () => { + const controller = seededController() + controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + renderSurface(controller, spyIntents()) + + // The chip's text is its title alone: the close control is an icon. + expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Start', 'a.txt']) + expect(screen.getByTestId('body').textContent).toBe('dsh-resource://file/session/s/a.txt') + expect(screen.getByRole('tab', { name: /a\.txt/u }).getAttribute('aria-selected')).toBe('true') + }) + + it('shows the empty-pane label when a pane holds nothing', () => { + const controller = new DockController() + renderSurface(controller, spyIntents()) + expect(screen.getByText(TEST_LABELS.emptyPane)).toBeDefined() + }) + + it('reports one focus intent when a tab is clicked, not when it is pressed, and none for the pane', () => { + const controller = seededController() + controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + const intents = spyIntents() + renderSurface(controller, intents) + const tab = screen.getByRole('tab', { name: /Start/u }) + + fireEvent.pointerDown(tab) + expect(intents.focusTab).not.toHaveBeenCalled() + fireEvent.click(tab) + const seeded = Object.values(controller.getSnapshot().state.tabs)[0] + expect(intents.focusTab).toHaveBeenCalledTimes(1) + expect(intents.focusTab).toHaveBeenCalledWith(seeded?.id) + // The tab's own operation decides the active pane; the pane records nothing of its own. + expect(intents.focusPane).not.toHaveBeenCalled() + }) + + it('records nothing for a click on the active pane\'s selected chip, but selects that chip in another pane', () => { + const controller = seededController() + controller.setExpanded(true) + controller.splitPane() + const state = controller.getSnapshot().state + const [first, second] = dockPaneIds(state) + if (first === undefined || second === undefined) throw new Error('expected two docked panes') + expect(state.activePaneId).toBe(second) + const intents = spyIntents() + renderSurface(controller, intents) + const selectedChip = (paneId: PaneId): Element => { + const chip = document.querySelector(`[data-dockkit-pane="${paneId}"] [data-dockkit-tab][aria-selected="true"]`) + if (chip === null) throw new Error(`expected a selected chip in ${paneId}`) + return chip + } + fireEvent.click(selectedChip(second)) + expect(intents.focusTab).not.toHaveBeenCalled() + fireEvent.click(selectedChip(first)) + expect(intents.focusTab).toHaveBeenCalledWith(getPane(state, first).activeTabId) + expect(intents.focusPane).not.toHaveBeenCalled() + }) + + it('focuses a pane when its body or strip is clicked, unless it is the active pane already', () => { + const controller = seededController() + controller.setExpanded(true) + controller.splitPane() + const state = controller.getSnapshot().state + const [first, second] = dockPaneIds(state) + if (first === undefined || second === undefined) throw new Error('expected two docked panes') + expect(state.activePaneId).toBe(second) + const intents = spyIntents() + renderSurface(controller, intents) + const body = (paneId: PaneId): Element => { + const element = document.querySelector(`[data-dockkit-pane="${paneId}"] [data-testid="body"]`) + if (element === null) throw new Error(`expected a body in ${paneId}`) + return element + } + // The active pane: nothing would change, so nothing is recorded. + fireEvent.click(body(second)) + fireEvent.click(document.querySelector(`[data-dockkit-strip="${second}"]`) ?? body(second)) + expect(intents.focusPane).not.toHaveBeenCalled() + // The other pane: its body and its strip's blank area both focus it. + fireEvent.click(body(first)) + fireEvent.click(document.querySelector(`[data-dockkit-strip="${first}"]`) ?? body(first)) + expect(intents.focusPane).toHaveBeenCalledTimes(2) + expect(intents.focusPane).toHaveBeenCalledWith(first) + expect(intents.focusTab).not.toHaveBeenCalled() + }) + + it('reports a split intent from the pane control, and disables it when the budget is spent', () => { + const controller = seededController() + const intents = spyIntents() + const { unmount } = renderSurface(controller, intents) + fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.splitPane })) + expect(intents.splitPane).toHaveBeenCalledWith(controller.getSnapshot().state.rootId) + expect(intents.focusPane).not.toHaveBeenCalled() + unmount() + + renderSurface(controller, intents, false) + const disabled = screen.getByRole('button', { name: TEST_LABELS.splitPane }) + expect(disabled.hasAttribute('disabled')).toBe(true) + expect(disabled.getAttribute('title')).toBe(TEST_LABELS.splitPaneDisabled) + expect(disabled.getAttribute('data-dockkit-split-blocked')).toBe('budget') + }) + + it('hides capacity-blocked split controls when opted in and restores them when capacity returns', () => { + const controller = seededController() + controller.splitPane() + const state = controller.getSnapshot().state + layOut(dockPaneIds(state), 420) + const intents = spyIntents() + const props: DockSurfaceProps = { + state, canSplit: false, hideSplitAtCapacity: true, intents, labels: TEST_LABELS, renderTab: tab =>

{tab.title}

, + } + const view = render() + expect(screen.queryByRole('button', { name: TEST_LABELS.splitPane })).toBeNull() + expect(view.container.querySelectorAll('[data-dockkit-split-button]')).toHaveLength(0) + expect(view.container.querySelectorAll('[data-dockkit-pane]')).toHaveLength(2) + + view.rerender() + const buttons = screen.getAllByRole('button', { name: TEST_LABELS.splitPane }) + expect(buttons).toHaveLength(2) + expect(buttons.every(button => !button.hasAttribute('disabled'))).toBe(true) + fireEvent.click(buttons[0]!) + expect(intents.splitPane).toHaveBeenCalledExactlyOnceWith(dockPaneIds(state)[0]) + }) + + // jsdom lays nothing out, so the room rule reads the rectangles this spec + // hands it: two panes, one wide enough for two halves and one not. The + // strip's fixed part is 104px in both (the chrome pane's controls), the chip + // minimum falls back to the stylesheet's 59px. + it.each([false, true])('keeps the width-blocked split control and its title with hideSplitAtCapacity=%s', (hideSplitAtCapacity) => { + const controller = seededController() + controller.setExpanded(true) + controller.splitPane() + const snapshot = controller.getSnapshot() + const [wide, narrow] = dockPaneIds(snapshot.state) + if (wide === undefined || narrow === undefined) throw new Error('expected two docked panes') + const widths: Record = { [wide]: 420, [narrow]: 208 } + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + const paneWidth = widths[this.closest('[data-dockkit-pane]')?.dataset.dockkitPane ?? ''] ?? 0 + if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600) + if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36) + if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24) + if (this.hasAttribute('data-dockkit-strip-fill')) return box(0, 0, Math.max(0, paneWidth - 2 - 60 - 104), 24) + return box(0, 0, 0, 0) + }) + render( +

{tab.contentId}

} + />, + ) + const wideButton = document.querySelector(`[data-dockkit-split-button="${wide}"]`) + const narrowButton = document.querySelector(`[data-dockkit-split-button="${narrow}"]`) + expect(wideButton?.hasAttribute('disabled')).toBe(false) + expect(wideButton?.getAttribute('title')).toBe(TEST_LABELS.splitPane) + expect(narrowButton?.hasAttribute('disabled')).toBe(true) + expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow) + expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width') + }) + + it('reports the room readings through onRoom, and re-reads them when the surface resizes', () => { + class FakeResizeObserver implements ResizeObserver { + static latest: FakeResizeObserver | undefined + readonly observe = vi.fn() + readonly unobserve = vi.fn() + readonly disconnect = vi.fn() + constructor(private readonly callback: ResizeObserverCallback) { + FakeResizeObserver.latest = this + } + + /** What the platform does when the observed element's size changes. */ + fire(): void { + this.callback([], this) + } + } + vi.stubGlobal('ResizeObserver', FakeResizeObserver) + const controller = seededController() + controller.setExpanded(true) + controller.splitPane() + const snapshot = controller.getSnapshot() + const panes = dockPaneIds(snapshot.state) + let paneWidth = 420 + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600) + if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36) + if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24) + if (this.hasAttribute('data-dockkit-strip-fill')) return box(0, 0, Math.max(0, paneWidth - 2 - 60 - 104), 24) + return box(0, 0, 0, 0) + }) + const onRoom = vi.fn() + const { unmount } = render( +

{tab.contentId}

} + onRoom={onRoom} + />, + ) + const observer = FakeResizeObserver.latest + if (observer === undefined) throw new Error('expected the surface to observe its own size') + expect(observer.observe).toHaveBeenCalledWith(document.querySelector('[data-dockkit-surface]')) + expect(onRoom).toHaveBeenLastCalledWith(new Map(panes.map(id => [id, { row: true, column: true }]))) + const readings = onRoom.mock.calls.length + + // The same reading again renders nothing new. + act(() => { observer.fire() }) + expect(onRoom).toHaveBeenCalledTimes(readings) + + // The column narrowed: every pane loses the room for a row split. + paneWidth = 208 + act(() => { observer.fire() }) + expect(onRoom).toHaveBeenLastCalledWith(new Map(panes.map(id => [id, { row: false, column: true }]))) + unmount() + expect(observer.disconnect).toHaveBeenCalledTimes(1) + }) + + it('asks for the seeded tab from the strip\'s add control, naming the pane and nothing else', () => { + const controller = seededController() + const intents = spyIntents() + renderSurface(controller, intents) + fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.addTab })) + expect(intents.addTab).toHaveBeenCalledWith(controller.getSnapshot().state.rootId) + expect(intents.focusPane).not.toHaveBeenCalled() + }) + + it('draws the add control only where the embedder\'s canAddTab allows, pane by pane', () => { + const controller = seededController() + controller.setExpanded(true) + controller.splitPane() + const snapshot = controller.getSnapshot() + const [first, second] = dockPaneIds(snapshot.state) + if (first === undefined || second === undefined) throw new Error('expected two docked panes') + render( + paneId === second} + intents={controller} + labels={TEST_LABELS} + renderTab={tab =>

{tab.contentId}

} + chrome={} + />, + ) + expect(document.querySelector(`[data-dockkit-pane="${first}"] [data-dockkit-add-tab]`)).toBeNull() + expect(document.querySelector(`[data-dockkit-pane="${second}"] [data-dockkit-add-tab]`)).not.toBeNull() + // The strip's end controls stay: only the add control is missing. + const strip = document.querySelector(`[data-dockkit-pane="${first}"] [data-dockkit-strip]`) + expect(strip?.querySelector('[data-dockkit-split-button]')).not.toBeNull() + expect(strip?.querySelector('[data-dockkit-strip-tabs]')).not.toBeNull() + }) + + it('lets the embedder render a chip\'s title, and shows the record\'s text when it does not', () => { + const controller = seededController() + controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + const snapshot = controller.getSnapshot() + render( +

{tab.contentId}

} + renderTabTitle={tab => tab.kind === 'file' ? {tab.title.toUpperCase()} : undefined} + />, + ) + const titles = [...document.querySelectorAll('[data-dockkit-tab-title]')].map(el => el.textContent) + expect(titles).toEqual(['Start', 'A.TXT']) + expect(screen.getByTestId('rich-title').tagName).toBe('EM') + }) + + it('closes a tab from the chip\'s own control, without starting a drag', () => { + const controller = seededController() + const intents = spyIntents() + renderSurface(controller, intents) + const close = screen.getByRole('button', { name: TEST_LABELS.closeTab }) + // A press on the nested control must not reach the chip's gesture handler; + // if it did, the pointer would be captured and this click would never land. + fireEvent.pointerDown(close) + fireEvent.click(close) + expect(intents.closeTab).toHaveBeenCalledTimes(1) + expect(intents.focusTab).not.toHaveBeenCalled() + }) + + it('opens the context menu on a secondary press, offering close and closing after acting', () => { + const controller = seededController() + const intents = spyIntents() + renderSurface(controller, intents) + expect(screen.queryByRole('menu')).toBeNull() + fireEvent.contextMenu(screen.getByRole('tab')) + expect(screen.getByRole('menu')).toBeDefined() + fireEvent.click(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab })) + expect(intents.closeTab).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('toggles the menu closed on a second secondary press, and dismisses it on a press anywhere else', () => { + const controller = seededController() + renderSurface(controller, spyIntents()) + const tab = screen.getByRole('tab') + fireEvent.contextMenu(tab) + fireEvent.contextMenu(tab) + expect(screen.queryByRole('menu')).toBeNull() + + fireEvent.contextMenu(tab) + expect(screen.getByRole('menu')).toBeDefined() + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('menu')).toBeNull() + + // A press with no element target, dispatched to the window itself, counts as outside. + fireEvent.contextMenu(tab) + fireEvent.pointerDown(window) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('hangs the menu below the chip\'s left edge, or from its right edge when it would run off the viewport', () => { + const controller = seededController() + let left = 10 + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + return this.hasAttribute('data-dockkit-tab') ? box(left, 4, 60, 26) : box(0, 0, 0, 0) + }) + const { unmount } = renderSurface(controller, spyIntents()) + fireEvent.contextMenu(screen.getByRole('tab')) + expect(screen.getByRole('menu').style.top).toBe('34px') + expect(screen.getByRole('menu').style.left).toBe('10px') + unmount() + + left = window.innerWidth + 100 + renderSurface(controller, spyIntents()) + fireEvent.contextMenu(screen.getByRole('tab')) + expect(screen.getByRole('menu').style.left).toBe(`${left + 60}px`) + }) + + it('offers no copy or float item: those gestures are the embedder\'s API and the drag', () => { + const controller = seededController() + renderSurface(controller, spyIntents()) + fireEvent.contextMenu(screen.getByRole('tab')) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([TEST_LABELS.closeTab]) + expect(document.querySelector('[data-dockkit-tab-more]')).toBeNull() + }) + + it('appends embedder menu items after its own, and hands them the tab and a dismiss', () => { + const controller = seededController() + const acted = vi.fn<(contentId: string) => void>() + const extras: TabMenuExtras = (tab, dismiss) => ( + + ) + renderSurface(controller, spyIntents(), true, extras) + fireEvent.contextMenu(screen.getByRole('tab')) + + // Order is contract: the kit's own item stays in the same place in every + // menu, so an embedder item cannot displace it. + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([ + TEST_LABELS.closeTab, 'embedder item', + ]) + + fireEvent.click(screen.getByTestId('extra')) + expect(acted).toHaveBeenCalledWith('seed:start') + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('keeps a menu press from starting a tab drag', () => { + const controller = seededController() + const intents = spyIntents() + renderSurface(controller, intents) + fireEvent.contextMenu(screen.getByRole('tab')) + const item = screen.getByRole('menuitem', { name: TEST_LABELS.closeTab }) + + // A press inside the menu must not reach the chip's gesture handler; if it + // did, the pointer would be captured and this click would never land. + fireEvent.pointerDown(item) + fireEvent.click(item) + expect(intents.closeTab).toHaveBeenCalledTimes(1) + }) +}) + +describe('tab drags', () => { + it('previews a caret at the slot under the pointer and reports that slot on release', () => { + const { intents, first, fileTabId, chip } = twoPanes() + // Left of the first chip's midpoint: slot 0. + drag(chip(fileTabId), FILE_CHIP, [30, 18], false) + expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('0') + // Past both chips: the slot after the last one, drawn at the strip's end. + fireEvent.pointerMove(window, { pointerId: 7, clientX: 300, clientY: 18 }) + expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('2') + fireEvent.pointerUp(window, { pointerId: 7, clientX: 300, clientY: 18 }) + // The slot counts the dragged chip itself; the planner turns it into a reorder. + expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, first, 2) + expect(gestureIntents(intents)).toEqual(['placeTab']) + expect(document.querySelector('[data-dockkit-caret]')).toBeNull() + }) + + it('reports a slot in another pane\'s strip as a placement there', () => { + const { intents, second, fileTabId, chip } = twoPanes() + drag(chip(fileTabId), FILE_CHIP, [420 + 30, 18]) + expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, second, 0) + }) + + it('shows the dock hint on the pane under the pointer and reports the zone on release', () => { + const { intents, second, fileTabId, chip } = twoPanes() + drag(chip(fileTabId), FILE_CHIP, [420 + 210, 300], false) + const hint = document.querySelector(`[data-dockkit-pane="${second}"] [data-dockkit-dock-zone]`) + expect(hint?.getAttribute('data-dockkit-dock-zone')).toBe('center') + fireEvent.pointerMove(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 }) + expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('right') + fireEvent.pointerUp(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 }) + expect(intents.dropTab).toHaveBeenCalledWith(fileTabId, second, 'right') + expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull() + }) + + it('offers no edge zone once the pane budget is spent: a release there moves nothing', () => { + const { intents, fileTabId, chip } = twoPanes(420, false) + drag(chip(fileTabId), FILE_CHIP, [420 + 410, 300], false) + expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull() + fireEvent.pointerUp(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 }) + expect(gestureIntents(intents)).toEqual([]) + }) + + // 208px panes: room for a column split (600px tall) but not for a row split. + it('offers an edge zone only on the axis the pane has room to split on', () => { + const { intents, second, fileTabId, chip } = twoPanes(208) + drag(chip(fileTabId), FILE_CHIP, [208 + 200, 300], false) + expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull() + fireEvent.pointerMove(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 }) + expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('top') + fireEvent.pointerUp(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 }) + expect(intents.dropTab).toHaveBeenCalledWith(fileTabId, second, 'top') + }) + + it('floats a tab released clear of the surface, with the panel under the drop point', () => { + const { intents, fileTabId, chip } = twoPanes() + drag(chip(fileTabId), FILE_CHIP, [2000, 900]) + expect(intents.floatTab).toHaveBeenCalledWith(fileTabId, floatRectAt(2000, 900, FLOAT_DEFAULT_SIZE)) + expect(gestureIntents(intents)).toEqual(['floatTab']) + }) + + it('treats a press that never travels the threshold as no drag at all', () => { + const { intents, fileTabId, chip } = twoPanes() + drag(chip(fileTabId), FILE_CHIP, [FILE_CHIP[0] + 2, FILE_CHIP[1] + 1], false) + expect(document.querySelector('[data-dockkit-caret]')).toBeNull() + fireEvent.pointerUp(window, { pointerId: 7, clientX: 2000, clientY: 900 }) + expect(gestureIntents(intents)).toEqual([]) + }) + + it('ignores a secondary press: that is the menu, never a drag', () => { + const { intents, fileTabId, chip } = twoPanes() + fireEvent.pointerDown(chip(fileTabId), { clientX: FILE_CHIP[0], clientY: FILE_CHIP[1], pointerId: 7, button: 2 }) + fireEvent.pointerMove(window, { pointerId: 7, clientX: 2000, clientY: 900 }) + fireEvent.pointerUp(window, { pointerId: 7, clientX: 2000, clientY: 900 }) + expect(gestureIntents(intents)).toEqual([]) + }) + + it('drops the preview and reports nothing when the platform cancels the pointer', () => { + const { intents, fileTabId, chip } = twoPanes() + drag(chip(fileTabId), FILE_CHIP, [30, 18], false) + expect(document.querySelector('[data-dockkit-caret]')).not.toBeNull() + fireEvent.pointerCancel(window, { pointerId: 7 }) + expect(document.querySelector('[data-dockkit-caret]')).toBeNull() + fireEvent.pointerUp(window, { pointerId: 7, clientX: 30, clientY: 18 }) + expect(gestureIntents(intents)).toEqual([]) + }) + + it('lets a new press take over from a gesture still in flight', () => { + const { intents, first, seedTabId, fileTabId, chip } = twoPanes() + drag(chip(fileTabId), FILE_CHIP, [30, 18], false) + expect(document.querySelector('[data-dockkit-caret]')).not.toBeNull() + // The second press ends the first gesture: its preview clears and its release never reports. + fireEvent.pointerDown(chip(seedTabId), { clientX: 51, clientY: 18, pointerId: 8, button: 0 }) + expect(document.querySelector('[data-dockkit-caret]')).toBeNull() + fireEvent.pointerMove(window, { pointerId: 8, clientX: 300, clientY: 18 }) + fireEvent.pointerUp(window, { pointerId: 8, clientX: 300, clientY: 18 }) + expect(intents.placeTab).toHaveBeenCalledTimes(1) + expect(intents.placeTab).toHaveBeenCalledWith(seedTabId, first, 2) + }) + + it('stops following the pointer when unmounted mid-gesture', () => { + const { intents, fileTabId, chip, unmount } = twoPanes() + drag(chip(fileTabId), FILE_CHIP, [30, 18], false) + unmount() + fireEvent.pointerMove(window, { pointerId: 7, clientX: 300, clientY: 18 }) + fireEvent.pointerUp(window, { pointerId: 7, clientX: 300, clientY: 18 }) + expect(gestureIntents(intents)).toEqual([]) + }) + + it('follows only its own pointer: another pointer neither moves, releases, nor cancels the gesture', () => { + const { intents, first, fileTabId, chip } = twoPanes() + fireEvent.pointerDown(chip(fileTabId), { clientX: FILE_CHIP[0], clientY: FILE_CHIP[1], pointerId: 7, button: 0 }) + fireEvent.pointerMove(window, { pointerId: 9, clientX: 30, clientY: 18 }) + expect(document.querySelector('[data-dockkit-caret]')).toBeNull() + fireEvent.pointerUp(window, { pointerId: 9, clientX: 30, clientY: 18 }) + fireEvent.pointerCancel(window, { pointerId: 9 }) + expect(gestureIntents(intents)).toEqual([]) + // The gesture is still alive for its own pointer. + fireEvent.pointerMove(window, { pointerId: 7, clientX: 30, clientY: 18 }) + expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('0') + fireEvent.pointerUp(window, { pointerId: 7, clientX: 30, clientY: 18 }) + expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, first, 0) + expect(intents.placeTab).toHaveBeenCalledTimes(1) + }) + + it('captures the pointer on the pressed chip where the platform offers capture', () => { + const capture = vi.fn<(pointerId: number) => void>() + Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { value: capture, configurable: true, writable: true }) + try { + const { fileTabId, chip } = twoPanes() + fireEvent.pointerDown(chip(fileTabId), { clientX: FILE_CHIP[0], clientY: FILE_CHIP[1], pointerId: 7, button: 0 }) + expect(capture).toHaveBeenCalledWith(7) + } finally { + Reflect.deleteProperty(HTMLElement.prototype, 'setPointerCapture') + } + }) +}) + +describe('horizontal workbench drops', () => { + it.each([ + { x: 450, y: 300, zone: 'left' }, + { x: 810, y: 590, zone: 'right' }, + ] as const)('offers both halves and targets $zone at ($x, $y)', ({ x, y, zone }) => { + const { intents, second, fileTabId, chip } = twoPanes(420, true, { dropZones: 'horizontal' }) + drag(chip(fileTabId), FILE_CHIP, [x, y], false) + const hints = document.querySelectorAll('[data-dockkit-dock-zone]') + expect([...hints].map(hint => hint.getAttribute('data-dockkit-dock-zone'))).toEqual(['left', 'right']) + expect(document.querySelector('[data-dockkit-drop-active]')?.getAttribute('data-dockkit-dock-zone')).toBe(zone) + fireEvent.pointerUp(window, { pointerId: 7, clientX: x, clientY: y }) + expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(fileTabId, second, zone) + }) + + it('uses the entire other pane for moving once the split budget is spent', () => { + const { intents, second, fileTabId, chip } = twoPanes(420, false, { dropZones: 'horizontal' }) + drag(chip(fileTabId), FILE_CHIP, [425, 590]) + expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(fileTabId, second, 'center') + }) + + it('does not offer a new split when a pane cannot fit two halves', () => { + const { intents, second, fileTabId, chip } = twoPanes(300, true, { dropZones: 'horizontal' }) + drag(chip(fileTabId), FILE_CHIP, [450, 300], false) + expect(document.querySelectorAll('[data-dockkit-dock-zone]')).toHaveLength(1) + expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('center') + fireEvent.pointerUp(window, { pointerId: 7, clientX: 450, clientY: 300 }) + expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(fileTabId, second, 'center') + }) + + it('clamps the live divider preview and committed sizes to the requested minimum', () => { + const { intents } = twoPanes(420, false, { minPaneFraction: 0.2 }) + const divider = document.querySelector('[data-dockkit-divider]') + if (divider === null) throw new Error('expected a divider') + drag(divider, [420, 300], [0, 300], false) + const cells = document.querySelectorAll('[data-dockkit-cell]') + expect(Number(cells[0]?.style.flexGrow)).toBeCloseTo(0.2) + expect(Number(cells[1]?.style.flexGrow)).toBeCloseTo(0.8) + fireEvent.pointerUp(window, { pointerId: 7, clientX: 0, clientY: 300 }) + expect(intents.resizeSplit.mock.calls[0]?.[1]).toEqual([0.2, 0.8]) + }) +}) + +describe('keyboard tabs', () => { + /** One pane holding the seed and two files, the last opened selected, with the chips by title. */ + function threeChips(): { intents: ReturnType; chip: (title: string) => HTMLElement; ids: Record } { + const controller = seededController() + const a = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + const b = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' }) + const seed = getPane(controller.getSnapshot().state, controller.getSnapshot().state.rootId).tabs[0] + if (seed === undefined) throw new Error('expected the seeded tab') + const intents = spyIntents() + renderSurface(controller, intents) + const chip = (title: string): HTMLElement => screen.getByRole('tab', { name: new RegExp(title.replace('.', '\\.'), 'u') }) + return { intents, chip, ids: { Start: seed, 'a.txt': a, 'b.txt': b } } + } + + it('puts only the selected chip in the tab order', () => { + const { chip } = threeChips() + expect(chip('b.txt').tabIndex).toBe(0) + expect(chip('a.txt').tabIndex).toBe(-1) + expect(chip('Start').tabIndex).toBe(-1) + }) + + it('moves focus with Left and Right, wrapping, and with Home and End, without selecting', () => { + const { intents, chip } = threeChips() + chip('b.txt').focus() + fireEvent.keyDown(chip('b.txt'), { key: 'ArrowRight' }) + expect(document.activeElement).toBe(chip('Start')) + fireEvent.keyDown(chip('Start'), { key: 'ArrowLeft' }) + expect(document.activeElement).toBe(chip('b.txt')) + fireEvent.keyDown(chip('b.txt'), { key: 'ArrowLeft' }) + expect(document.activeElement).toBe(chip('a.txt')) + fireEvent.keyDown(chip('a.txt'), { key: 'Home' }) + expect(document.activeElement).toBe(chip('Start')) + fireEvent.keyDown(chip('Start'), { key: 'End' }) + expect(document.activeElement).toBe(chip('b.txt')) + expect(intents.focusTab).not.toHaveBeenCalled() + }) + + it('selects the focused chip with Enter or Space, once each, and ignores other keys', () => { + const { intents, chip, ids } = threeChips() + chip('b.txt').focus() + fireEvent.keyDown(chip('b.txt'), { key: 'ArrowLeft' }) + fireEvent.keyDown(chip('a.txt'), { key: 'Enter' }) + expect(intents.focusTab).toHaveBeenCalledWith(ids['a.txt']) + fireEvent.keyDown(chip('a.txt'), { key: 'Home' }) + fireEvent.keyDown(chip('Start'), { key: ' ' }) + expect(intents.focusTab).toHaveBeenCalledWith(ids.Start) + fireEvent.keyDown(chip('Start'), { key: 'a' }) + fireEvent.keyDown(chip('Start'), { key: 'Escape' }) + expect(intents.focusTab).toHaveBeenCalledTimes(2) + }) + + it('records nothing for Enter on the active pane\'s selected chip, and leaves keys on the close control alone', () => { + const { intents, chip } = threeChips() + fireEvent.keyDown(chip('b.txt'), { key: 'Enter' }) + fireEvent.keyDown(chip('b.txt'), { key: ' ' }) + const close = chip('a.txt').querySelector('[data-dockkit-tab-close]') + if (close === null) throw new Error('expected the close control') + fireEvent.keyDown(close, { key: 'Enter' }) + fireEvent.keyDown(close, { key: 'ArrowLeft' }) + expect(intents.focusTab).not.toHaveBeenCalled() + expect(intents.closeTab).not.toHaveBeenCalled() + }) +}) + +describe('divider drags', () => { + /** A row split whose second pane is itself split into a column: the column split's id and a spied intent set. */ + function withColumn(columnBox: DOMRect): { intents: ReturnType; columnId: string } { + const controller = seededController() + controller.setExpanded(true) + controller.splitPane() + const [first, second] = dockPaneIds(controller.getSnapshot().state) + if (first === undefined || second === undefined) throw new Error('expected two docked panes') + const opened = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file', paneId: second }) + controller.dropTab(opened, second, 'bottom') + const state = controller.getSnapshot().state + const root = state.nodes[state.rootId] + const columnId = root?.kind === 'split' ? root.children[1] : undefined + if (columnId === undefined || state.nodes[columnId]?.kind !== 'split') throw new Error('expected a column split') + layOut(dockPaneIds(state), 420, { [columnId]: columnBox }) + const intents = spyIntents() + renderSurface(controller, intents) + return { intents, columnId } + } + + it('moves a column split\'s divider along the vertical axis', () => { + const { intents, columnId } = withColumn(box(420, 0, 420, 600)) + const divider = document.querySelector(`[data-dockkit-divider="${columnId}:0"]`) + if (divider === null) throw new Error('expected the column divider') + drag(divider, [630, 300], [630, 360]) + const [reportedSplit, sizes] = intents.resizeSplit.mock.calls[0] as [string, readonly number[]] + expect(reportedSplit).toBe(columnId) + expect(sizes[0]).toBeCloseTo(0.6) + expect(sizes[1]).toBeCloseTo(0.4) + }) + + it('reports nothing for a release that left the fractions where they were', () => { + const { intents } = twoPanes() + const divider = document.querySelector('[data-dockkit-divider]') + if (divider === null) throw new Error('expected a divider between the two panes') + // A click on the divider. + drag(divider, [420, 300], [420, 300]) + // A drag returned to where it began. + drag(divider, [420, 300], [504, 300], false) + fireEvent.pointerMove(window, { pointerId: 7, clientX: 420, clientY: 300 }) + fireEvent.pointerUp(window, { pointerId: 7, clientX: 420, clientY: 300 }) + expect(intents.resizeSplit).not.toHaveBeenCalled() + // A real move still reports, once. + drag(divider, [420, 300], [504, 300]) + expect(intents.resizeSplit).toHaveBeenCalledTimes(1) + }) + + it('reports nothing along a split that has no measured extent', () => { + const { intents, columnId } = withColumn(box(420, 0, 420, 0)) + const divider = document.querySelector(`[data-dockkit-divider="${columnId}:0"]`) + if (divider === null) throw new Error('expected the column divider') + drag(divider, [630, 300], [630, 360]) + expect(intents.resizeSplit).not.toHaveBeenCalled() + }) + + it('previews the fractions while the divider moves and reports the net result once', () => { + const { intents, first, second } = twoPanes() + const divider = document.querySelector('[data-dockkit-divider]') + if (divider === null) throw new Error('expected a divider between the two panes') + const splitId = divider.getAttribute('data-dockkit-divider')?.split(':')[0] + // The split spans 840px, so 84px of travel is a tenth of it. + drag(divider, [420, 300], [504, 300], false) + const cell = (index: number): number => + Number(document.querySelector(`[data-dockkit-cell="${splitId}:${index}"]`)?.style.flexGrow) + expect(cell(0)).toBeCloseTo(0.6) + expect(cell(1)).toBeCloseTo(0.4) + fireEvent.pointerUp(window, { pointerId: 7, clientX: 504, clientY: 300 }) + expect(intents.resizeSplit).toHaveBeenCalledTimes(1) + const [reportedSplit, sizes] = intents.resizeSplit.mock.calls[0] as [string, readonly number[]] + expect(reportedSplit).toBe(splitId) + expect(sizes[0]).toBeCloseTo(0.6) + expect(sizes[1]).toBeCloseTo(0.4) + // The recorded fractions come back through the state; the preview is gone. + expect(cell(0)).toBeCloseTo(0.5) + expect(intents.placeTab).not.toHaveBeenCalled() + expect([first, second]).toHaveLength(2) + }) +}) + +describe('FloatLayer', () => { + /** Two floating panels, `lower` under `upper`, with `upper` active. */ + function twoFloats(): { + intents: ReturnType + lower: PaneId + upper: PaneId + panel: (paneId: PaneId) => HTMLElement + part: (attribute: string, paneId: PaneId) => Element + } { + const controller = seededController() + const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' }) + const lower = controller.floatTab(first, { x: 100, y: 80, width: 300, height: 200 }) + const upper = controller.floatTab(second, { x: 500, y: 80, width: 300, height: 200 }) + const intents = spyIntents() + render( null} />) + const part = (attribute: string, paneId: PaneId): Element => { + const element = document.querySelector(`[${attribute}="${paneId}"]`) + if (element === null) throw new Error(`expected ${attribute} of ${paneId}`) + return element + } + const panel = (paneId: PaneId): HTMLElement => { + const element = document.querySelector(`[data-dockkit-float="${paneId}"]`) + if (element === null) throw new Error(`expected the panel ${paneId}`) + return element + } + return { intents, lower, upper, panel, part } + } + + /** One floating panel over a spied intent set. */ + function floating(): { intents: ReturnType; paneId: PaneId; tabId: TabId; panel: HTMLElement } { + const controller = seededController() + const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + const paneId = controller.floatTab(tabId, { x: 100, y: 80, width: 300, height: 200 }) + const intents = spyIntents() + render( +

{tab.title}

} + />, + ) + const panel = document.querySelector(`[data-dockkit-float="${paneId}"]`) + if (panel === null) throw new Error('expected a floating panel') + return { intents, paneId, tabId, panel } + } + + it('draws one panel per floating pane, without a tab strip', () => { + const { intents } = floating() + expect(screen.getByTestId('float-body').textContent).toBe('a.txt') + expect(screen.queryByRole('tablist')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.dockFloat })) + expect(intents.unfloatPane).toHaveBeenCalledTimes(1) + }) + + it('closes a floating panel through its own control', () => { + const { intents, tabId } = floating() + fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.closeFloat })) + expect(intents.closeTab).toHaveBeenCalledWith(tabId) + }) + + it('raises a panel on a press on its body, but not from a press on its controls, grip, or corner', () => { + const { intents, lower, panel, part } = twoFloats() + fireEvent.pointerDown(panel(lower)) + expect(intents.focusPane).toHaveBeenCalledWith(lower) + fireEvent.pointerDown(part('data-dockkit-float-dock', lower)) + fireEvent.pointerDown(part('data-dockkit-float-close', lower)) + fireEvent.pointerDown(part('data-dockkit-float-grip', lower), { clientX: 150, clientY: 90, pointerId: 7, button: 0 }) + fireEvent.pointerDown(part('data-dockkit-float-resize', lower), { clientX: 400, clientY: 280, pointerId: 8, button: 0 }) + expect(intents.focusPane).toHaveBeenCalledTimes(1) + }) + + it('records nothing for a press on the panel that is active and on top already', () => { + const { intents, upper, panel, part } = twoFloats() + fireEvent.pointerDown(panel(upper)) + // A grip or corner press released in place is a click on the same panel. + drag(part('data-dockkit-float-grip', upper), [550, 90], [550, 90]) + drag(part('data-dockkit-float-resize', upper), [800, 280], [800, 280]) + expect(intents.focusPane).not.toHaveBeenCalled() + expect(intents.moveFloat).not.toHaveBeenCalled() + expect(intents.resizeFloat).not.toHaveBeenCalled() + }) + + it('raises the active panel when another has been drawn over it', () => { + const controller = seededController() + const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' }) + const lower = controller.floatTab(first, { x: 100, y: 80, width: 300, height: 200 }) + const upper = controller.floatTab(second, { x: 500, y: 80, width: 300, height: 200 }) + // Focus on the lower panel while the upper stays on top: only a recorded + // focus snapshot puts the layout there, as an undo does. + const buried = applyOp(controller.getSnapshot().state, { + type: 'restoreFocus', activePaneId: lower, floats: [lower, upper], paneActiveTabs: {}, + }).state + const intents = spyIntents() + render( null} />) + const panel = document.querySelector(`[data-dockkit-float="${lower}"]`) + if (panel === null) throw new Error('expected the lower panel') + fireEvent.pointerDown(panel) + expect(intents.focusPane).toHaveBeenCalledWith(lower) + }) + + it('moves a panel by its grip as one intent, previewing the position and the panel on top', () => { + const controller = seededController() + const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' }) + const lower = controller.floatTab(first, { x: 100, y: 80, width: 300, height: 200 }) + controller.floatTab(second, { x: 500, y: 80, width: 300, height: 200 }) + const intents = spyIntents() + render( null} />) + const panel = document.querySelector(`[data-dockkit-float="${lower}"]`) + const grip = document.querySelector(`[data-dockkit-float-grip="${lower}"]`) + if (panel === null || grip === null) throw new Error('expected the lower panel') + expect(panel.style.zIndex).toBe('1') + + drag(grip, [150, 90], [180, 110], false) + expect(panel.style.left).toBe('130px') + expect(panel.style.top).toBe('100px') + // Mid-gesture the panel draws on top, as the move it records will leave it. + expect(panel.style.zIndex).toBe('3') + fireEvent.pointerUp(window, { pointerId: 7, clientX: 180, clientY: 110 }) + expect(intents.moveFloat).toHaveBeenCalledWith(lower, 130, 100) + expect(intents.moveFloat).toHaveBeenCalledTimes(1) + expect(intents.focusPane).not.toHaveBeenCalled() + // The recorded rectangle and depth are drawn again once the preview clears. + expect(panel.style.left).toBe('100px') + expect(panel.style.zIndex).toBe('1') + }) + + it('treats a press on the grip or corner released where it began as a click that raises the panel', () => { + const { intents, lower, part } = twoFloats() + drag(part('data-dockkit-float-grip', lower), [150, 90], [150, 90]) + drag(part('data-dockkit-float-resize', lower), [400, 280], [400, 280]) + expect(intents.focusPane).toHaveBeenCalledTimes(2) + expect(intents.focusPane).toHaveBeenCalledWith(lower) + expect(intents.moveFloat).not.toHaveBeenCalled() + expect(intents.resizeFloat).not.toHaveBeenCalled() + }) + + it('resizes from the corner as one intent, holding the minimum size', () => { + const { intents, paneId, panel } = floating() + const corner = document.querySelector(`[data-dockkit-float-resize="${paneId}"]`) + if (corner === null) throw new Error('expected a resize corner') + drag(corner, [400, 280], [440, 310], false) + expect(panel.style.width).toBe('340px') + expect(panel.style.height).toBe('230px') + fireEvent.pointerMove(window, { pointerId: 7, clientX: -1000, clientY: -1000 }) + expect(panel.style.width).toBe(`${FLOAT_MIN_SIZE.width}px`) + fireEvent.pointerUp(window, { pointerId: 7, clientX: -1000, clientY: -1000 }) + expect(intents.resizeFloat).toHaveBeenCalledWith(paneId, { x: 100, y: 80, ...FLOAT_MIN_SIZE }) + expect(intents.resizeFloat).toHaveBeenCalledTimes(1) + expect(intents.focusPane).not.toHaveBeenCalled() + }) + + it('stacks panels in the model z order', () => { + const controller = seededController() + const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' }) + controller.floatTab(first) + controller.floatTab(second) + render( + null} + />, + ) + const zIndexes = screen.getAllByRole('banner').map(header => header.parentElement?.style.zIndex) + expect(zIndexes).toEqual(['1', '2']) + }) +}) + +describe('surface chrome', () => { + it('renders the embedder\'s controls once, at the end of the top-right pane\'s strip', () => { + const controller = seededController() + controller.setExpanded(true) + // A row split puts the new pane to the right; a bottom drop under it puts a + // pane below that. The top-right pane is the right column's upper one. + controller.splitPane() + const right = controller.getSnapshot().state + const root = right.nodes[right.rootId] + const rightPaneId = root?.kind === 'split' ? root.children[1] : undefined + if (rightPaneId === undefined) throw new Error('expected a row split') + const seeded = controller.getSnapshot().state.nodes[rightPaneId] + if (seeded === undefined || seeded.kind !== 'pane' || seeded.tabs[0] === undefined) throw new Error('expected a seeded pane') + const opened = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file', paneId: seeded.id }) + controller.dropTab(opened, seeded.id, 'bottom') + + const snapshot = controller.getSnapshot() + render( +

{tab.contentId}

} + chrome={} + />, + ) + const hosts = screen.getAllByRole('button', { name: 'chrome' }) + expect(hosts).toHaveLength(1) + const pane = hosts[0]?.closest('[data-dockkit-pane]') + // The upper of the two right-hand panes, not the lower one the drop created. + expect(pane?.getAttribute('data-dockkit-pane')).toBe(rightPaneId) + // At the strip's end: after the split control. + const strip = pane?.querySelector('[data-dockkit-strip]') + const children = [...(strip?.children ?? [])] + expect(children.at(-1)?.hasAttribute('data-dockkit-strip-chrome')).toBe(true) + }) + + it('keeps a click on the chrome from focusing the pane: the embedder\'s controls report their own intents', () => { + const controller = seededController() + const intents = spyIntents() + render( +

{tab.contentId}

} + chrome={} + />, + ) + fireEvent.click(screen.getByRole('button', { name: 'chrome' })) + expect(intents.focusPane).not.toHaveBeenCalled() + }) + + it('renders no chrome host when the embedder supplies none', () => { + const controller = seededController() + renderSurface(controller, spyIntents()) + expect(document.querySelector('[data-dockkit-strip-chrome]')).toBeNull() + }) + + // Layout itself is the stylesheet's; what the DOM promises is that every + // chip and drop caret sits inside the strip's one shrinking box, and that the + // controls after it are the strip's own children, in this order. + it('keeps the chips in their own box ahead of the add, split, and chrome controls', () => { + const controller = seededController() + controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + const snapshot = controller.getSnapshot() + render( +

{tab.contentId}

} + chrome={} + />, + ) + const strip = document.querySelector('[data-dockkit-strip]') + const role = (child: Element): string => { + if (child.hasAttribute('data-dockkit-strip-tabs')) return 'tabs' + if (child.hasAttribute('data-dockkit-add-tab')) return 'add' + if (child.hasAttribute('data-dockkit-split-button')) return 'split' + if (child.hasAttribute('data-dockkit-strip-chrome')) return 'chrome' + return 'fill' + } + expect([...(strip?.children ?? [])].map(role)).toEqual(['tabs', 'add', 'fill', 'split', 'chrome']) + expect(strip?.querySelectorAll('[data-dockkit-strip-tabs] [data-dockkit-tab]')).toHaveLength(2) + expect(strip?.querySelectorAll(':scope > [data-dockkit-tab]')).toHaveLength(0) + }) +}) + +describe('a controller satisfies the intent contract', () => { + it('accepts a DockController wherever DockIntents is required', () => { + const controller = seededController() + const intents: DockIntents = controller + renderSurface(controller, intents) + fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.splitPane })) + // One click, one entry: the split seeded its new pane, and that seating is + // what decides the active pane — the control's click reports nothing else. + expect(controller.ops.map(op => op.type)).toEqual(['split', 'openTab']) + }) +}) + +describe('fileTab', () => { + it('builds a content tab the kit treats as opaque', () => { + expect(fileTab(asTab('t1'), 'dsh-resource://file/session/s/x', 'x').kind).toBe('file') + }) +}) diff --git a/packages/client/ui-dockkit/tests/constraints.client.spec.ts b/packages/client/ui-dockkit/tests/constraints.client.spec.ts new file mode 100644 index 0000000000..0b116ee229 --- /dev/null +++ b/packages/client/ui-dockkit/tests/constraints.client.spec.ts @@ -0,0 +1,131 @@ +/** + * Interaction limits: the four-pane cap, the five dock regions, and divider + * clamping. The model itself stays unbounded; these rules gate dispatch. + */ +import { describe, expect, it } from 'vitest' +import { applyOp } from '../src/engine/operations.ts' +import { + canSplit, clampSizes, DOCK_ZONES, dockPaneCount, MAX_DOCK_PANES, MIN_PANE_FRACTION, zoneAt, zoneSplit, +} from '../src/engine/constraints.ts' +import { createIdMinter, createInitialState } from '../src/engine/initial.ts' +import { seedTab } from './fixtures.client.ts' +import { getPane } from '../src/engine/tree.ts' +import type { LayoutState } from '../src/contract/types.ts' + +/** Split the root pane repeatedly until the docked grid holds `count` panes. */ +function grid(count: number): LayoutState { + const minter = createIdMinter() + let state = createInitialState(minter, seedTab) + while (dockPaneCount(state) < count) { + state = applyOp(state, { + type: 'split', + paneId: state.activePaneId, + axis: 'row', + direction: 'after', + newPaneId: minter.next('pane'), + newSplitId: minter.next('split'), + }).state + } + return state +} + +describe('pane cap', () => { + it('allows splitting up to four docked panes and no further', () => { + expect(MAX_DOCK_PANES).toBe(4) + expect(canSplit(createInitialState(createIdMinter(), seedTab))).toBe(true) + expect(dockPaneCount(grid(4))).toBe(4) + expect(canSplit(grid(4))).toBe(false) + expect(canSplit(grid(3))).toBe(true) + }) + + it('ignores floating panes when counting the grid', () => { + const minter = createIdMinter() + const state = createInitialState(minter, seedTab) + const guideTabId = getPane(state, state.rootId).tabs[0] + if (guideTabId === undefined) throw new Error('fixture: no guide tab') + const floated = applyOp(state, { + type: 'float', tabId: guideTabId, newPaneId: minter.next('float'), rect: { x: 0, y: 0, width: 10, height: 10 }, + }).state + expect(floated.floats).toHaveLength(1) + expect(dockPaneCount(floated)).toBe(1) + expect(canSplit(floated)).toBe(true) + }) +}) + +describe('dock regions', () => { + it('offers five regions', () => { + expect([...DOCK_ZONES].sort()).toEqual(['bottom', 'center', 'left', 'right', 'top']) + }) + + it('reads the centre of a pane as a move, not a split', () => { + expect(zoneAt(0.5, 0.5)).toBe('center') + expect(zoneSplit('center')).toBeUndefined() + }) + + it('reads each edge band as its own region', () => { + expect(zoneAt(0.05, 0.5)).toBe('left') + expect(zoneAt(0.95, 0.5)).toBe('right') + expect(zoneAt(0.5, 0.05)).toBe('top') + expect(zoneAt(0.5, 0.95)).toBe('bottom') + }) + + it('takes the closest edge in a corner', () => { + expect(zoneAt(0.02, 0.10)).toBe('left') + expect(zoneAt(0.10, 0.02)).toBe('top') + }) + + it('maps regions to the split they create', () => { + expect(zoneSplit('left')).toEqual({ axis: 'row', direction: 'before' }) + expect(zoneSplit('right')).toEqual({ axis: 'row', direction: 'after' }) + expect(zoneSplit('top')).toEqual({ axis: 'column', direction: 'before' }) + expect(zoneSplit('bottom')).toEqual({ axis: 'column', direction: 'after' }) + }) +}) + +describe('divider clamping', () => { + it('honours an embedder\'s twenty-percent minimum', () => { + expect(clampSizes([0.01, 0.99], 0.2)).toEqual([0.2, 0.8]) + expect(clampSizes([0.99, 0.01], 0.2)).toEqual([0.8, 0.2]) + expect(clampSizes([0.5, 0.5], 0.2)).toEqual([0.5, 0.5]) + }) + + it('keeps sizes summing to one', () => { + const sizes = clampSizes([0.6, 0.4]) + expect(sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1) + expect(sizes[0]).toBeCloseTo(0.6) + }) + + it('lifts a pane dragged under the minimum', () => { + const sizes = clampSizes([0.01, 0.99]) + expect(sizes[0]).toBeGreaterThanOrEqual(MIN_PANE_FRACTION * 0.9) + expect(sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1) + }) + + it('accepts unnormalized pixel-like input', () => { + const sizes = clampSizes([300, 100]) + expect(sizes[0]).toBeCloseTo(0.75) + expect(sizes[1]).toBeCloseTo(0.25) + }) + + it('treats a negative size as zero and shares an all-zero input equally', () => { + expect(clampSizes([])).toEqual([]) + const lifted = clampSizes([-1, 1]) + expect(lifted[0]).toBeCloseTo(MIN_PANE_FRACTION) + expect(lifted[1]).toBeCloseTo(1 - MIN_PANE_FRACTION) + expect(clampSizes([0, 0])).toEqual([0.5, 0.5]) + }) + + it('pins a share that drops under the minimum only after the first pass', () => { + // 0.13 clears the floor until the first pin takes its 0.11 from everyone. + const sizes = clampSizes([0.01, 0.13, 0.86]) + expect(sizes[0]).toBeCloseTo(MIN_PANE_FRACTION) + expect(sizes[1]).toBeCloseTo(MIN_PANE_FRACTION) + expect(sizes[2]).toBeCloseTo(1 - 2 * MIN_PANE_FRACTION) + }) + + it('never lifts a pane above an equal share when the minimum would not fit them all', () => { + const sizes = clampSizes(Array.from({ length: 10 }, (_, index) => (index === 0 ? 0.001 : 1))) + expect(sizes[0]).toBeCloseTo(0.1) + expect(sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1) + }) +}) diff --git a/packages/client/ui-dockkit/tests/controller.client.spec.ts b/packages/client/ui-dockkit/tests/controller.client.spec.ts new file mode 100644 index 0000000000..5c296763b6 --- /dev/null +++ b/packages/client/ui-dockkit/tests/controller.client.spec.ts @@ -0,0 +1,360 @@ +/** + * Controller behavior: the interaction vocabulary the UI calls, including tab + * identity (focus an existing tab, or open an explicit copy), the guide tab a + * split seats, drop resolution, floating, and the notification contract. + */ +import { describe, expect, it } from 'vitest' +import { DockController } from '../src/engine/controller.ts' +import { dockPaneCount, FLOAT_DEFAULT_SIZE } from '../src/engine/constraints.ts' +import { SEED_CONTENT_ID, childAt, firstTab, seededController } from './fixtures.client.ts' +import { dockPaneIds, findTabPane, getPane, getSplit } from '../src/engine/tree.ts' +import type { LayoutState, PaneId } from '../src/contract/types.ts' + +/** Tab titles of one pane, in strip order. */ +function titles(state: LayoutState, paneId: PaneId): string[] { + return getPane(state, paneId).tabs.map(id => state.tabs[id]?.title ?? '?') +} + +/** A controller expanded once, with its first pane id. */ +function expanded(): { controller: DockController; paneId: PaneId } { + const controller = seededController() + controller.setExpanded(true) + const { state } = controller.getSnapshot() + return { controller, paneId: getPane(state, state.rootId).id } +} + +describe('expand', () => { + it('starts collapsed with a guide tab already seated', () => { + const controller = seededController() + const snapshot = controller.getSnapshot() + expect(snapshot.state.expanded).toBe(false) + expect(snapshot.canUndo).toBe(false) + expect(snapshot.canSplit).toBe(true) + expect(titles(snapshot.state, getPane(snapshot.state, snapshot.state.rootId).id)).toHaveLength(1) + const guide = Object.values(snapshot.state.tabs)[0] + expect(guide?.contentId).toBe(SEED_CONTENT_ID) + }) + + it('records a toggle and ignores a redundant set', () => { + const controller = seededController() + controller.toggleExpanded() + expect(controller.getSnapshot().state.expanded).toBe(true) + expect(controller.ops).toHaveLength(1) + controller.setExpanded(true) + expect(controller.ops).toHaveLength(1) + controller.toggleExpanded() + expect(controller.getSnapshot().state.expanded).toBe(false) + expect(controller.ops).toHaveLength(2) + }) +}) + +describe('presentation', () => { + it('records a mode switch once and ignores a redundant one', () => { + const controller = seededController() + controller.setMode('fullscreen') + expect(controller.getSnapshot().state.mode).toBe('fullscreen') + controller.setMode('fullscreen') + expect(controller.ops.map(op => op.type)).toEqual(['setMode']) + }) +}) + +describe('splitting', () => { + it('splits the active pane to the right and seats a guide tab there', () => { + const { controller, paneId } = expanded() + expect(controller.splitPane()).toBe(true) + const state = controller.getSnapshot().state + const root = getSplit(state, state.rootId) + expect(root.axis).toBe('row') + const newPaneId = getPane(state, childAt(root, 1)).id + expect(newPaneId).not.toBe(paneId) + expect(titles(state, newPaneId)).toHaveLength(1) + expect(state.tabs[firstTab(getPane(state, newPaneId))]?.contentId).toBe(SEED_CONTENT_ID) + expect(controller.ops.map(op => op.type)).toEqual(['setExpanded', 'split', 'openTab']) + }) + + it('seats the pane factory\'s tab at the end of a strip from the add control, and nothing without one', () => { + const { controller, paneId } = expanded() + expect(controller.addTab(paneId)).toBe(true) + expect(titles(controller.getSnapshot().state, paneId)).toEqual(['Start', 'Start']) + const bare = new DockController({ makeInitialTab: id => ({ id, kind: 'seed', contentId: SEED_CONTENT_ID, title: 'Start' }) }) + expect(bare.addTab(getPane(bare.getSnapshot().state, bare.getSnapshot().state.rootId).id)).toBe(false) + }) + + it('names the pane a new tab lands in: the active pane, or the first docked one while a panel floats', () => { + const { controller, paneId } = expanded() + expect(controller.activeDockPaneId()).toBe(paneId) + const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + const floatId = controller.floatTab(tabId) + expect(controller.getSnapshot().state.activePaneId).toBe(floatId) + expect(controller.activeDockPaneId()).toBe(paneId) + }) + + it('stops at four panes without recording anything', () => { + const { controller } = expanded() + expect(controller.splitPane()).toBe(true) + expect(controller.splitPane()).toBe(true) + expect(controller.splitPane()).toBe(true) + expect(dockPaneCount(controller.getSnapshot().state)).toBe(4) + const before = controller.ops.length + expect(controller.splitPane()).toBe(false) + expect(controller.ops).toHaveLength(before) + expect(controller.getSnapshot().canSplit).toBe(false) + }) +}) + +describe('tab identity', () => { + it('focuses the existing tab when the same content is opened twice', () => { + const { controller, paneId } = expanded() + const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + controller.splitPane() + const again = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + expect(again).toBe(first) + const state = controller.getSnapshot().state + expect(getPane(state, paneId).activeTabId).toBe(first) + expect(state.activePaneId).toBe(paneId) + expect(Object.values(state.tabs).filter(tab => tab.contentId === 'dsh-resource://file/session/s/a.txt')).toHaveLength(1) + }) + + it('opens an explicit second copy beside the original', () => { + const { controller, paneId } = expanded() + const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + const copy = controller.duplicateTab(first) + expect(copy).not.toBe(first) + const state = controller.getSnapshot().state + expect(state.tabs[copy]?.contentId).toBe('dsh-resource://file/session/s/a.txt') + expect(getPane(state, paneId).tabs.indexOf(copy)).toBe(getPane(state, paneId).tabs.indexOf(first) + 1) + }) + + it('closes a tab and leaves the pane in place', () => { + const { controller, paneId } = expanded() + const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + controller.closeTab(tabId) + const state = controller.getSnapshot().state + expect(state.tabs[tabId]).toBeUndefined() + expect(dockPaneIds(state)).toEqual([paneId]) + }) +}) + +describe('drops', () => { + it('moves a tab into the pane under the pointer', () => { + const { controller, paneId } = expanded() + const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + controller.splitPane() + const target = getSplit(controller.getSnapshot().state, controller.getSnapshot().state.rootId).children[1] + if (target === undefined) throw new Error('expected a second pane') + expect(controller.dropTab(tabId, getPane(controller.getSnapshot().state, target).id, 'center')).toBe(true) + const state = controller.getSnapshot().state + expect(getPane(state, target).tabs).toContain(tabId) + expect(getPane(state, paneId).tabs).not.toContain(tabId) + expect(state.activePaneId).toBe(target) + }) + + it('rejects a centre drop on the tab own pane', () => { + const { controller, paneId } = expanded() + const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + const before = controller.ops.length + expect(controller.dropTab(tabId, paneId, 'center')).toBe(false) + expect(controller.ops).toHaveLength(before) + }) + + it('splits on an edge drop and lands the tab in the new pane', () => { + const { controller, paneId } = expanded() + const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + expect(controller.dropTab(tabId, paneId, 'bottom')).toBe(true) + const state = controller.getSnapshot().state + const root = getSplit(state, state.rootId) + expect(root.axis).toBe('column') + const created = root.children[1] + if (created === undefined) throw new Error('expected a split child') + expect(getPane(state, created).tabs).toEqual([tabId]) + expect(dockPaneCount(state)).toBe(2) + }) + + it('refuses an edge drop once the grid is full', () => { + const { controller, paneId } = expanded() + const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + controller.splitPane() + controller.splitPane() + controller.splitPane() + const before = controller.ops.length + expect(controller.dropTab(tabId, paneId, 'right')).toBe(false) + expect(controller.ops).toHaveLength(before) + }) + + it('reorders inside one pane', () => { + const { controller, paneId } = expanded() + const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + controller.reorderTab(first, 0) + expect(getPane(controller.getSnapshot().state, paneId).tabs[0]).toBe(first) + }) +}) + +describe('explicit tab placement', () => { + it('reorders when the slot is in the tab own pane', () => { + const { controller, paneId } = expanded() + const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + expect(controller.placeTab(first, paneId, 0)).toBe(true) + expect(getPane(controller.getSnapshot().state, paneId).tabs[0]).toBe(first) + expect(controller.ops.at(-1)?.type).toBe('reorderTab') + }) + + it('reports no change when the slot is where the tab already sits', () => { + const { controller, paneId } = expanded() + const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + const index = getPane(controller.getSnapshot().state, paneId).tabs.indexOf(tabId) + const before = controller.ops.length + expect(controller.placeTab(tabId, paneId, index)).toBe(false) + expect(controller.ops).toHaveLength(before) + }) + + it('moves across panes into the requested slot', () => { + const { controller, paneId } = expanded() + const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + controller.splitPane() + const target = getSplit(controller.getSnapshot().state, controller.getSnapshot().state.rootId).children[1] + if (target === undefined) throw new Error('expected a second pane') + expect(controller.placeTab(tabId, getPane(controller.getSnapshot().state, target).id, 0)).toBe(true) + const state = controller.getSnapshot().state + expect(getPane(state, target).tabs[0]).toBe(tabId) + expect(getPane(state, paneId).tabs).not.toContain(tabId) + }) + + it('returns a floating tab when placed into a docked strip', () => { + const { controller, paneId } = expanded() + const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + const floatId = controller.floatTab(tabId) + expect(controller.placeTab(tabId, paneId, 0)).toBe(true) + const state = controller.getSnapshot().state + expect(state.floats).toEqual([]) + expect(state.nodes[floatId]).toBeUndefined() + expect(getPane(state, paneId).tabs[0]).toBe(tabId) + }) + + it('refuses a floating pane as the destination', () => { + const { controller } = expanded() + const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'text-preview' }) + const floatId = controller.floatTab(first) + const before = controller.ops.length + expect(controller.placeTab(second, floatId, 0)).toBe(false) + expect(controller.ops).toHaveLength(before) + }) +}) + +describe('floating', () => { + it('takes a tab out and cascades each new panel', () => { + const { controller, paneId } = expanded() + const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'text-preview' }) + const firstFloat = controller.floatTab(first) + const secondFloat = controller.floatTab(second) + const state = controller.getSnapshot().state + expect(state.floats).toEqual([firstFloat, secondFloat]) + const one = getPane(state, firstFloat) + const two = getPane(state, secondFloat) + expect(one.rect?.width).toBe(FLOAT_DEFAULT_SIZE.width) + expect(two.rect?.x).toBeGreaterThan(one.rect?.x ?? 0) + expect(getPane(state, paneId).tabs).toHaveLength(1) + }) + + it('is unaffected by collapsing the column', () => { + const { controller } = expanded() + const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + const floatId = controller.floatTab(tabId) + controller.setExpanded(false) + const state = controller.getSnapshot().state + expect(state.expanded).toBe(false) + expect(state.floats).toEqual([floatId]) + expect(getPane(state, floatId).tabs).toEqual([tabId]) + }) + + it('sends a floating panel back into the grid', () => { + const { controller, paneId } = expanded() + const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + const floatId = controller.floatTab(tabId) + controller.unfloatPane(floatId) + const state = controller.getSnapshot().state + expect(state.floats).toEqual([]) + expect(findTabPane(state, tabId).id).toBe(paneId) + }) + + it('treats a centre drop from a floating panel as a return', () => { + const { controller, paneId } = expanded() + const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + const floatId = controller.floatTab(tabId) + expect(controller.dropTab(tabId, paneId, 'center')).toBe(true) + const state = controller.getSnapshot().state + expect(state.floats).toEqual([]) + expect(state.nodes[floatId]).toBeUndefined() + expect(findTabPane(state, tabId).id).toBe(paneId) + }) + + it('records net drag and resize results', () => { + const { controller } = expanded() + const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + const floatId = controller.floatTab(tabId) + controller.moveFloat(floatId, 300, 210) + controller.resizeFloat(floatId, { x: 300, y: 210, width: 500, height: 400 }) + expect(getPane(controller.getSnapshot().state, floatId).rect) + .toEqual({ x: 300, y: 210, width: 500, height: 400 }) + expect(controller.ops.map(op => op.type).slice(-2)).toEqual(['moveFloat', 'resizeFloat']) + }) + + it('raises a panel to the top when focused', () => { + const { controller } = expanded() + const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' }) + const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'text-preview' }) + const firstFloat = controller.floatTab(first) + const secondFloat = controller.floatTab(second) + controller.focusPane(firstFloat) + expect(controller.getSnapshot().state.floats).toEqual([secondFloat, firstFloat]) + }) +}) + +describe('dividers', () => { + it('clamps a drag that would starve a pane', () => { + const { controller } = expanded() + controller.splitPane() + const splitId = getSplit(controller.getSnapshot().state, controller.getSnapshot().state.rootId).id + controller.resizeSplit(splitId, [0.01, 0.99]) + const sizes = getSplit(controller.getSnapshot().state, splitId).sizes + expect(sizes[0]).toBeGreaterThan(0.05) + expect(sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1) + }) +}) + +describe('notification', () => { + it('notifies subscribers and keeps the snapshot stable between changes', () => { + const controller = seededController() + let notifications = 0 + const dispose = controller.subscribe(() => { notifications += 1 }) + const before = controller.getSnapshot() + expect(controller.getSnapshot()).toBe(before) + + controller.setExpanded(true) + expect(notifications).toBe(1) + expect(controller.getSnapshot()).not.toBe(before) + + controller.setExpanded(true) + expect(notifications).toBe(1) + + dispose() + controller.setExpanded(false) + expect(notifications).toBe(1) + }) + + it('steps one compound command as one entry, however many operations it recorded', () => { + expect(seededController().undo()).toBe(false) + const { controller } = expanded() + const before = controller.ops.length + controller.splitPane() + // The split and the seeded tab are two operations of one intent. + expect(controller.ops.length - before).toBe(2) + expect(controller.getSnapshot().canUndo).toBe(true) + expect(controller.undo()).toBe(true) + expect(dockPaneCount(controller.getSnapshot().state)).toBe(1) + expect(controller.redo()).toBe(true) + expect(dockPaneCount(controller.getSnapshot().state)).toBe(2) + expect(controller.redo()).toBe(false) + }) +}) diff --git a/packages/client/ui-dockkit/tests/fixtures.client.ts b/packages/client/ui-dockkit/tests/fixtures.client.ts new file mode 100644 index 0000000000..a4c87a52e8 --- /dev/null +++ b/packages/client/ui-dockkit/tests/fixtures.client.ts @@ -0,0 +1,68 @@ +/** + * Shared spec fixtures. The kit has no notion of a guide or a preview tab, so + * the suites supply their own content families — exactly as an embedder does. + */ +import type { NodeId, PaneId, PaneNode, SplitId, SplitNode, TabId, TabRecord } from '../src/contract/types.ts' +import { createIdMinter, createInitialState, DockController, type IdMinter } from '../src/index.ts' +import type { DockLabels } from '../src/contract/adapter.ts' +import type { LayoutState } from '../src/contract/types.ts' + +/** Consistency id of the seeded tab these suites use. */ +export const SEED_CONTENT_ID = 'seed:start' + +/** Title of the seeded tab. */ +export const SEED_TITLE = 'Start' + +/** The seeded tab an embedder would put in a fresh pane. */ +export function seedTab(id: TabId): TabRecord { + return { id, kind: 'seed', contentId: SEED_CONTENT_ID, title: SEED_TITLE } +} + +/** A content tab addressed by a file URL, the shape the first embedder uses. */ +export function fileTab(id: TabId, contentId: string, title: string): TabRecord { + return { id, kind: 'file', contentId, title } +} + +/** A controller seeded on both the initial pane and every pane a split creates. */ +export function seededController(): DockController { + return new DockController({ makeInitialTab: seedTab, makePaneTab: seedTab }) +} + +/** An initial state holding one seeded tab, plus the minter that produced it. */ +export function seededState(): { state: LayoutState; minter: IdMinter } { + const minter = createIdMinter() + return { state: createInitialState(minter, seedTab), minter } +} + +/** Labels a spec can pass without caring what they say. */ +export const TEST_LABELS: DockLabels = { + emptyPane: 'empty pane', + splitPane: 'split right', + splitPaneDisabled: 'pane budget spent', + splitPaneNarrow: 'too narrow to split', + closeTab: 'close', + addTab: 'new tab', + dockFloat: 'dock', + closeFloat: 'close panel', +} + +/** Brand a literal a spec spells out: an id the kit would have minted. */ +export const asPane = (id: string): PaneId => id as PaneId +/** See {@link asPane}. */ +export const asSplit = (id: string): SplitId => id as SplitId +/** See {@link asPane}. */ +export const asTab = (id: string): TabId => id as TabId + +/** The first tab of a pane the spec knows is not empty. */ +export function firstTab(pane: PaneNode): TabId { + const id = pane.tabs[0] + if (id === undefined) throw new Error(`fixture: pane ${pane.id} holds no tab`) + return id +} + +/** A split's child at an index the spec knows exists. */ +export function childAt(split: SplitNode, index: number): NodeId { + const id = split.children[index] + if (id === undefined) throw new Error(`fixture: split ${split.id} has no child ${String(index)}`) + return id +} diff --git a/packages/client/ui-dockkit/tests/geometry.client.spec.ts b/packages/client/ui-dockkit/tests/geometry.client.spec.ts new file mode 100644 index 0000000000..69f1e821b8 --- /dev/null +++ b/packages/client/ui-dockkit/tests/geometry.client.spec.ts @@ -0,0 +1,144 @@ +/** + * Drag geometry: point tests, dock-zone resolution against a real rectangle, + * tab-strip insertion slots, the drag threshold, and divider arithmetic. + */ +import { describe, expect, it } from 'vitest' +import { + containsPoint, dividerSizes, DRAG_THRESHOLD, floatRectAt, insertionIndex, movedRect, + passedThreshold, resizedRect, zoneInRect, +} from '../src/engine/geometry.ts' +import type { PaneMeasure, Rect } from '../src/engine/geometry.ts' +import { halvesFit, SPLIT_MINIMUMS } from '../src/engine/geometry.ts' +import { FLOAT_DEFAULT_SIZE, FLOAT_MIN_SIZE } from '../src/index.ts' + +const PANE: Rect = { x: 100, y: 200, width: 400, height: 300 } + +describe('containsPoint', () => { + it('includes the edges and excludes anything outside', () => { + expect(containsPoint(PANE, 100, 200)).toBe(true) + expect(containsPoint(PANE, 500, 500)).toBe(true) + expect(containsPoint(PANE, 300, 350)).toBe(true) + expect(containsPoint(PANE, 99, 350)).toBe(false) + expect(containsPoint(PANE, 300, 501)).toBe(false) + }) +}) + +describe('zoneInRect', () => { + it('reads the middle of a pane as its centre', () => { + expect(zoneInRect(PANE, 300, 350)).toBe('center') + }) + + it('reads each edge band as its own region', () => { + expect(zoneInRect(PANE, 110, 350)).toBe('left') + expect(zoneInRect(PANE, 490, 350)).toBe('right') + expect(zoneInRect(PANE, 300, 210)).toBe('top') + expect(zoneInRect(PANE, 300, 490)).toBe('bottom') + }) + + it('treats a collapsed rectangle as all centre', () => { + expect(zoneInRect({ x: 0, y: 0, width: 0, height: 0 }, 0, 0)).toBe('center') + }) +}) + +describe('insertionIndex', () => { + const tabs: Rect[] = [ + { x: 0, y: 0, width: 100, height: 30 }, + { x: 100, y: 0, width: 100, height: 30 }, + { x: 200, y: 0, width: 100, height: 30 }, + ] + + it('places before a tab while left of its midpoint', () => { + expect(insertionIndex(tabs, 10)).toBe(0) + expect(insertionIndex(tabs, 49)).toBe(0) + }) + + it('places after a tab once past its midpoint', () => { + expect(insertionIndex(tabs, 51)).toBe(1) + expect(insertionIndex(tabs, 151)).toBe(2) + expect(insertionIndex(tabs, 400)).toBe(3) + }) + + it('places at the start of an empty strip', () => { + expect(insertionIndex([], 400)).toBe(0) + }) +}) + +describe('passedThreshold', () => { + it('ignores travel under the threshold on both axes', () => { + expect(passedThreshold(10, 10, 10, 10)).toBe(false) + expect(passedThreshold(10, 10, 10 + DRAG_THRESHOLD - 1, 10)).toBe(false) + expect(passedThreshold(10, 10, 10 + DRAG_THRESHOLD, 10)).toBe(true) + expect(passedThreshold(10, 10, 10, 10 - DRAG_THRESHOLD)).toBe(true) + }) +}) + +describe('dividerSizes', () => { + it('moves the change between the two neighbours only', () => { + expect(dividerSizes([0.5, 0.5], 0, 0.1)).toEqual([0.6, 0.4]) + expect(dividerSizes([0.25, 0.25, 0.5], 1, -0.05)).toEqual([0.25, 0.2, 0.55]) + }) + + it('leaves the fractions alone at a boundary that has no pair', () => { + expect(dividerSizes([0.5, 0.5], 1, 0.1)).toEqual([0.5, 0.5]) + }) +}) + +describe('floating rectangles', () => { + const rect = { x: 100, y: 80, width: 300, height: 200 } + + it('moves without changing the size', () => { + expect(movedRect(rect, 25, -15)).toEqual({ x: 125, y: 65, width: 300, height: 200 }) + }) + + it('resizes from the origin and honours the minimum', () => { + expect(resizedRect(rect, 40, 30, FLOAT_MIN_SIZE)) + .toEqual({ x: 100, y: 80, width: 340, height: 230 }) + expect(resizedRect(rect, -1000, -1000, FLOAT_MIN_SIZE)) + .toEqual({ x: 100, y: 80, ...FLOAT_MIN_SIZE }) + }) + + it('places a new panel with its header under the drop point', () => { + const dropped = floatRectAt(400, 300, FLOAT_DEFAULT_SIZE) + expect(dropped.width).toBe(FLOAT_DEFAULT_SIZE.width) + expect(dropped.x).toBeLessThan(400) + expect(dropped.y).toBeLessThan(300) + expect(floatRectAt(5, 5, FLOAT_DEFAULT_SIZE)).toMatchObject({ x: 0, y: 0 }) + }) +}) + +describe('halvesFit — the room rule', () => { + /** A pane whose strip shows `fixed` px of controls and one chip box of 60px; the fill takes the rest. */ + const measure = (width: number, height: number, fixed: number): PaneMeasure => { + const strip = { x: 1, y: 1, width: width - 2, height: 36 } + return { pane: { x: 0, y: 0, width, height }, strip, chipsWidth: 60, fillWidth: Math.max(0, strip.width - 60 - fixed) } + } + + it('lets an unmeasured pane split: the rule only blocks on a positive reading', () => { + expect(halvesFit(measure(0, 0, 104))).toEqual({ row: true, column: true }) + }) + + it('needs each half to hold the strip\'s fixed controls plus one minimum chip', () => { + // 420px: halves of 206px inside the borders, against 104 + 59. + expect(halvesFit(measure(420, 600, 104)).row).toBe(true) + // 208px: halves of 100px, short of 163. + expect(halvesFit(measure(208, 600, 104)).row).toBe(false) + // The boundary is inclusive: 2 * (163 + 2 borders) + 4 divider = 334. + expect(halvesFit(measure(334, 600, 104)).row).toBe(true) + expect(halvesFit(measure(333, 600, 104)).row).toBe(false) + // A strip with fewer controls needs less. + expect(halvesFit(measure(208, 600, 44)).row).toBe(false) + expect(halvesFit(measure(214, 600, 44)).row).toBe(true) + }) + + it('needs each half to hold the strip plus a minimum body for a column split', () => { + // Halves of (h - 4) / 2 - 2 against 36 + 48 = 84. + expect(halvesFit(measure(420, 176, 104)).column).toBe(true) + expect(halvesFit(measure(420, 175, 104)).column).toBe(false) + }) + + it('takes the minimums it is given, and the stylesheet\'s by default', () => { + expect(SPLIT_MINIMUMS).toEqual({ divider: 4, chip: 59, body: 48 }) + expect(halvesFit(measure(208, 600, 104), { divider: 0, chip: 0, body: 0 }).row).toBe(false) + expect(halvesFit(measure(220, 600, 104), { divider: 0, chip: 0, body: 0 }).row).toBe(true) + }) +}) diff --git a/packages/client/ui-dockkit/tests/measure.client.spec.ts b/packages/client/ui-dockkit/tests/measure.client.spec.ts new file mode 100644 index 0000000000..52f30d3237 --- /dev/null +++ b/packages/client/ui-dockkit/tests/measure.client.spec.ts @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +/** + * The DOM side of the room rule, over a hand-built pane tree: which rectangles + * and computed styles feed `halvesFit`, and what an unmeasured pane reads as. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { fitOf, measurePaneFits, paneElements, sameFits } from '../src/components/measure.ts' +import type { HalvesFit, Rect } from '../src/engine/geometry.ts' +import { asPane } from './fixtures.client.ts' + +/** Give one element a fixed rectangle, as a browser's layout would. */ +function lay(element: HTMLElement, rect: Rect): void { + element.getBoundingClientRect = (): DOMRect => ({ + ...rect, top: rect.y, left: rect.x, right: rect.x + rect.width, bottom: rect.y + rect.height, toJSON: () => ({}), + }) +} + +/** A pane element with the parts the measurement reads; `width` is the pane's, `fixed` the strip's controls. */ +function pane(id: string, width: number, fixed = 104): HTMLElement { + const element = document.createElement('section') + element.dataset.dockkitPane = id + lay(element, { x: 0, y: 0, width, height: 600 }) + const strip = document.createElement('div') + strip.dataset.dockkitStrip = id + lay(strip, { x: 1, y: 1, width: width - 2, height: 36 }) + const chips = document.createElement('div') + chips.dataset.dockkitStripTabs = id + lay(chips, { x: 1, y: 1, width: 60, height: 34 }) + const fill = document.createElement('div') + fill.dataset.dockkitStripFill = '' + lay(fill, { x: 61, y: 1, width: Math.max(0, width - 2 - 60 - fixed), height: 34 }) + strip.append(chips, fill) + element.append(strip) + return element +} + +/** A chip with the given inline box styles, appended to `strip`'s chip box. */ +function chip(host: HTMLElement, style: Partial): HTMLElement { + const element = document.createElement('div') + element.dataset.dockkitTab = 't' + Object.assign(element.style, style) + host.querySelector('[data-dockkit-strip-tabs]')?.append(element) + return element +} + +/** A surface root in the document, so computed styles resolve. */ +function surface(): HTMLElement { + const root = document.createElement('div') + document.body.append(root) + return root +} + +afterEach(() => { document.body.replaceChildren() }) + +describe('measurePaneFits', () => { + it('keys every pane by the id it carries, and reads an unlaid pane as fitting', () => { + const root = surface() + const bare = document.createElement('section') + bare.dataset.dockkitPane = 'bare' + root.append(pane('wide', 420), bare) + expect(paneElements(root).map(([id]) => id)).toEqual(['wide', 'bare']) + const fits = measurePaneFits(root) + expect(fits.get(asPane('wide'))).toEqual({ row: true, column: true }) + expect(fits.get(asPane('bare'))).toEqual({ row: true, column: true }) + }) + + // 308px: halves of 150px inside the borders, against 104px of controls plus one chip. + it('reads the chip minimum from a rendered chip\'s computed style, padding included for a content box', () => { + const root = surface() + const narrow = pane('p', 308) + root.append(narrow) + // No chip rendered: the stylesheet's 59px, so 163 > 150. + expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false) + // 44px of content plus 4px + 4px of padding is 52: 156 > 150. + const rendered = chip(narrow, { minWidth: '44px', paddingLeft: '4px', paddingRight: '4px', boxSizing: 'content-box' }) + expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false) + // The same declaration as a border box is the whole footprint: 148 fits. + rendered.style.boxSizing = 'border-box' + expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(true) + // Unstyled or zero minimum: back to the stylesheet's figure. + rendered.style.minWidth = '0px' + expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false) + rendered.style.minWidth = '' + expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false) + }) + + // 336px: halves of 164px against 163 with the stylesheet's 4px divider, 162 with an 8px one. + it('reads the divider\'s thickness from a rendered divider, and the stylesheet\'s before one exists', () => { + const root = surface() + root.append(pane('p', 336)) + expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(true) + const divider = document.createElement('div') + divider.dataset.dockkitDivider = 's:0' + root.append(divider) + lay(divider, { x: 0, y: 0, width: 8, height: 600 }) + expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false) + // A divider without layout yet falls back too. + lay(divider, { x: 0, y: 0, width: 0, height: 0 }) + expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(true) + }) +}) + +describe('fitOf', () => { + it('answers from the map, and takes an unmeasured pane to fit', () => { + const blocked: HalvesFit = { row: false, column: true } + const fits = new Map([[asPane('p'), blocked]]) + expect(fitOf(fits, asPane('p'))).toBe(blocked) + expect(fitOf(fits, asPane('q'))).toEqual({ row: true, column: true }) + }) +}) + +describe('sameFits', () => { + const fit: HalvesFit = { row: true, column: false } + + it('agrees only when both name the same panes with the same readings', () => { + expect(sameFits(new Map([[asPane('a'), fit]]), new Map([[asPane('a'), { ...fit }]]))).toBe(true) + expect(sameFits(new Map([[asPane('a'), fit]]), new Map())).toBe(false) + expect(sameFits(new Map([[asPane('a'), fit]]), new Map([[asPane('b'), fit]]))).toBe(false) + expect(sameFits(new Map([[asPane('a'), fit]]), new Map([[asPane('a'), { row: false, column: false }]]))).toBe(false) + expect(sameFits(new Map([[asPane('a'), fit]]), new Map([[asPane('a'), { row: true, column: true }]]))).toBe(false) + }) +}) diff --git a/packages/client/ui-dockkit/tests/planner.client.spec.ts b/packages/client/ui-dockkit/tests/planner.client.spec.ts new file mode 100644 index 0000000000..16d7fcb339 --- /dev/null +++ b/packages/client/ui-dockkit/tests/planner.client.spec.ts @@ -0,0 +1,385 @@ +/** + * The two properties the intent layer must not lose in refactoring. + * + * Determinism: a planner reads only the state and the minter it is handed, so the + * same inputs plan the same operations and replay stays exact. Nothing here may + * reach a clock or a random source. + * + * One undo behaviour: focus-run merging and redo-branch discarding live in the + * pure history functions, so the stateful `Sequencer` embedding and an embedder + * driving `record`/`stepBack` directly cannot drift apart. + */ +import { describe, expect, it } from 'vitest' +import { applyOp } from '../src/engine/operations.ts' +import { DockController } from '../src/engine/controller.ts' +import { createIdMinter, createInitialState } from '../src/engine/initial.ts' +import { + activeDockPaneId, findContentTab, findPaneContentTab, planDropTab, planDuplicateTab, planFloatTab, planOpenContent, + planPlaceTab, planSetExpanded, planSetMode, planSettle, planSplitPane, planUnfloatPane, planAddTab, +} from '../src/engine/planner.ts' +import { + EMPTY_HISTORY, record, recordedOps, stepBack, stepForward, type History, +} from '../src/engine/sequence.ts' +import { dockPaneIds, findTabPane, getPane, getSplit } from '../src/engine/tree.ts' +import type { LayoutOp, LayoutState, PaneId, TabId } from '../src/contract/types.ts' +import type { Mint } from '../src/engine/planner.ts' +import { asTab, fileTab, firstTab, seedTab, seededState } from './fixtures.client.ts' + +/** A deep snapshot, for proving a planner left its input alone. */ +function frozen(state: LayoutState): string { + return JSON.stringify(state) +} + +/** Apply a plan the way any embedder does, and return the resulting state. */ +function applyAll(state: LayoutState, ops: readonly LayoutOp[]): LayoutState { + return ops.reduce((current, op) => applyOp(current, op).state, state) +} + +describe('planner determinism', () => { + it('plans the same operations from the same state and a fresh minter', () => { + const first = seededState() + const second = seededState() + const planOne = planSplitPane(first.state, first.minter.next, undefined, seedTab) + const planTwo = planSplitPane(second.state, second.minter.next, undefined, seedTab) + expect(planOne).toEqual(planTwo) + expect(planOne.length).toBeGreaterThan(0) + }) + + it('leaves the state it was handed untouched', () => { + const { state, minter } = seededState() + const before = frozen(state) + const mint = minter.next + + planSetExpanded(state, true) + planSplitPane(state, mint, undefined, seedTab) + planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + planFloatTab(state, mint, firstTab(getPane(state, state.rootId))) + expect(frozen(state)).toBe(before) + }) + + it('plans nothing for an intent that changes nothing', () => { + const { state, minter } = seededState() + const mint = minter.next + const tabId = getPane(state, state.rootId).tabs[0] + if (tabId === undefined) throw new Error('fixture: seeded tab missing') + + // Already collapsed; already in that mode; already in that slot; centre + // release on its own pane. + expect(planSetExpanded(state, false)).toEqual([]) + expect(planSetMode(state, 'push')).toEqual([]) + expect(planSetMode(state, 'fullscreen')).toEqual([{ type: 'setMode', mode: 'fullscreen' }]) + expect(planPlaceTab(state, tabId, getPane(state, state.rootId).id, 0)).toEqual([]) + expect(planDropTab(state, mint, tabId, getPane(state, state.rootId).id, 'center')).toEqual([]) + }) + + it('refuses to plan past the pane budget', () => { + const seeded = seededState() + let { state } = seeded + const { minter } = seeded + const mint = minter.next + for (let index = 0; index < 3; index += 1) { + state = applyAll(state, planSplitPane(state, mint, state.activePaneId, seedTab)) + } + expect(planSplitPane(state, mint, state.activePaneId, seedTab)).toEqual([]) + // The root is a split once panes exist, so address a real pane. + const firstPane = dockPaneIds(state)[0] + if (firstPane === undefined) throw new Error('fixture: no docked pane') + const tabId = getPane(state, firstPane).tabs[0] + if (tabId === undefined) throw new Error('fixture: seeded tab missing') + expect(planDropTab(state, mint, tabId, firstPane, 'right')).toEqual([]) + }) +}) + +describe('planPlaceTab', () => { + /** One docked pane holding three tabs, in strip order. */ + function threeTabs(): { state: LayoutState; paneId: PaneId; tabs: readonly [TabId, TabId, TabId] } { + const { state, minter } = seededState() + const mint = minter.next + const paneId = getPane(state, state.rootId).id + const b = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/b.txt', title: 'b', kind: 'file' }) + const withB = applyAll(state, b.ops) + const c = planOpenContent(withB, mint, { contentId: 'dsh-resource://file/session/s/c.txt', title: 'c', kind: 'file' }) + const withC = applyAll(withB, c.ops) + const a = getPane(withC, paneId).tabs[0] + if (a === undefined) throw new Error('fixture: seeded tab missing') + return { state: withC, paneId, tabs: [a, b.tabId, c.tabId] } + } + + /** Strip order after placing `tabId` at caret slot `index` in its own pane. */ + function after(tabId: TabId, index: number): readonly TabId[] { + const { state, paneId } = threeTabs() + return getPane(applyAll(state, planPlaceTab(state, tabId, paneId, index)), paneId).tabs + } + + it('reads the slot as the caret over the strip as drawn, the dragged chip included', () => { + const { tabs: [a, b, c] } = threeTabs() + // Rightward: the caret between b and c is slot 2, and a lands between them. + expect(after(a, 2)).toEqual([b, a, c]) + expect(after(a, 3)).toEqual([b, c, a]) + // Leftward: the chip sits after the caret, so the slot is the final index. + expect(after(c, 0)).toEqual([c, a, b]) + expect(after(c, 1)).toEqual([a, c, b]) + }) + + it('plans nothing for the slot on either side of the dragged chip', () => { + const { state, paneId, tabs: [a, b, c] } = threeTabs() + expect(planPlaceTab(state, a, paneId, 0)).toEqual([]) + expect(planPlaceTab(state, a, paneId, 1)).toEqual([]) + expect(planPlaceTab(state, b, paneId, 1)).toEqual([]) + expect(planPlaceTab(state, b, paneId, 2)).toEqual([]) + expect(planPlaceTab(state, c, paneId, 2)).toEqual([]) + expect(planPlaceTab(state, c, paneId, 3)).toEqual([]) + }) +}) + +describe('planDropTab on the tab\'s own pane', () => { + it('plans nothing for a pane\'s only tab released on any of its own edges', () => { + const { state, minter } = seededState() + const mint = minter.next + const tabId = getPane(state, state.rootId).tabs[0] + if (tabId === undefined) throw new Error('fixture: seeded tab missing') + for (const zone of ['left', 'right', 'top', 'bottom'] as const) { + expect(planDropTab(state, mint, tabId, getPane(state, state.rootId).id, zone)).toEqual([]) + } + }) + + it('splits the pane when it keeps another tab', () => { + const { state, minter } = seededState() + const mint = minter.next + const opened = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + const two = applyAll(state, opened.ops) + const ops = planDropTab(two, mint, opened.tabId, getPane(two, two.rootId).id, 'right') + expect(ops.map(op => op.type)).toEqual(['split', 'moveTab']) + const split = applyAll(two, ops) + expect(dockPaneIds(split)).toHaveLength(2) + expect(getPane(split, two.rootId).tabs).toHaveLength(1) + }) +}) + +describe('floating panes as planner arguments', () => { + /** A docked pane holding the seed, plus one content tab floated out of it. */ + function withFloat(): { state: LayoutState; mint: Mint; floatId: PaneId; tabId: TabId } { + const { state, minter } = seededState() + const mint = minter.next + const opened = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + const docked = applyAll(state, opened.ops) + const floated = planFloatTab(docked, mint, opened.tabId) + return { state: applyAll(docked, floated.ops), mint, floatId: floated.paneId, tabId: opened.tabId } + } + + it('plans nothing into a floating pane: no split, no seeded tab, no drop', () => { + const { state, mint, floatId } = withFloat() + expect(planSplitPane(state, mint, floatId, seedTab)).toEqual([]) + expect(planAddTab(state, mint, floatId, seedTab)).toEqual([]) + const seeded = getPane(state, state.rootId).tabs[0] + if (seeded === undefined) throw new Error('fixture: seeded tab missing') + expect(planDropTab(state, mint, seeded, floatId, 'center')).toEqual([]) + }) + + it('copies a floating tab into the active docked pane, at its end', () => { + const { state, mint, tabId } = withFloat() + expect(state.activePaneId).not.toBe(state.rootId) + expect(activeDockPaneId(state)).toBe(state.rootId) + const copy = planDuplicateTab(state, mint, tabId) + expect(copy.ops[0]).toMatchObject({ type: 'openTab', paneId: state.rootId, index: 1 }) + expect(getPane(applyAll(state, copy.ops), state.rootId).tabs.at(-1)).toBe(copy.tabId) + }) +}) + +describe('planSettle', () => { + /** Two seeded docked panes side by side. */ + function twoPanes(): { state: LayoutState; mint: Mint; left: PaneId; right: PaneId } { + const { state, minter } = seededState() + const mint = minter.next + const split = applyAll(state, planSplitPane(state, mint, undefined, seedTab)) + const [left, right] = dockPaneIds(split) + if (left === undefined || right === undefined) throw new Error('fixture: expected two panes') + return { state: split, mint, left, right } + } + + it('plans nothing while every docked pane holds a tab', () => { + const { state, mint } = twoPanes() + expect(planSettle(state, mint, seedTab)).toEqual([]) + }) + + it('merges away each pane an intent emptied, one after another', () => { + const { state, mint, left, right } = twoPanes() + const third = applyAll(state, planSplitPane(state, mint, right, seedTab)) + const emptied = dockPaneIds(third).flatMap(id => getPane(third, id).tabs) + .filter(tabId => findTabPane(third, tabId).id !== left) + .reduce((current, tabId) => applyAll(current, [{ type: 'closeTab', tabId }]), third) + const ops = planSettle(emptied, mint, seedTab) + expect(ops.map(op => op.type)).toEqual(['merge', 'merge']) + expect(dockPaneIds(applyAll(emptied, ops))).toEqual([left]) + }) + + it('reseeds an emptied root pane through the factory, and leaves it empty without one', () => { + const { state, minter } = seededState() + const mint = minter.next + const tabId = getPane(state, state.rootId).tabs[0] + if (tabId === undefined) throw new Error('fixture: seeded tab missing') + const emptied = applyAll(state, [{ type: 'closeTab', tabId }]) + const reseeded = planSettle(emptied, mint, seedTab) + expect(reseeded.map(op => op.type)).toEqual(['openTab']) + expect(getPane(applyAll(emptied, reseeded), emptied.rootId).tabs).toHaveLength(1) + expect(planSettle(emptied, mint)).toEqual([]) + }) +}) + +describe('planOpenContent placement and identity', () => { + it('identifies content by (kind, contentId): the same address under another kind opens another tab', () => { + const { state, minter } = seededState() + const mint = minter.next + const first = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + const opened = applyAll(state, first.ops) + expect(findContentTab(opened, 'dsh-resource://file/session/s/a.txt')).toBe(first.tabId) + expect(findContentTab(opened, 'dsh-resource://file/session/s/a.txt', 'file')).toBe(first.tabId) + expect(findContentTab(opened, 'dsh-resource://file/session/s/a.txt', 'hex')).toBeUndefined() + // The pane-level lookup answers for one pane only. + const [pane] = dockPaneIds(opened) + if (pane === undefined) throw new Error('expected a docked pane') + expect(findPaneContentTab(opened, pane, 'dsh-resource://file/session/s/a.txt')).toBe(first.tabId) + expect(findPaneContentTab(opened, pane, 'dsh-resource://file/session/s/a.txt', 'hex')).toBeUndefined() + expect(findPaneContentTab(opened, pane, 'dsh-resource://file/session/s/nowhere.txt')).toBeUndefined() + const again = planOpenContent(opened, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + expect(again.ops.map(op => op.type)).toEqual(['focusTab']) + const other = planOpenContent(opened, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'hex' }) + expect(other.ops.map(op => op.type)).toEqual(['openTab']) + }) + + it('opens another tab when told not to reveal the existing one', () => { + const { state, minter } = seededState() + const mint = minter.next + const opened = applyAll(state, planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }).ops) + const copy = planOpenContent(opened, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file', revealIfOpened: false }) + expect(copy.ops.map(op => op.type)).toEqual(['openTab']) + const both = applyAll(opened, copy.ops) + expect(Object.values(both.tabs).filter(tab => tab.contentId === 'dsh-resource://file/session/s/a.txt')).toHaveLength(2) + }) + + it('seats a new tab at an explicit strip slot', () => { + const { state, minter } = seededState() + const mint = minter.next + const paneId = getPane(state, state.rootId).id + const planned = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file', paneId, index: 0 }) + expect(planned.ops[0]).toMatchObject({ type: 'openTab', paneId, index: 0 }) + expect(getPane(applyAll(state, planned.ops), paneId).tabs[0]).toBe(planned.tabId) + }) +}) + +describe('planAddTab', () => { + it('seats the factory\'s tab at the end of the pane, and plans nothing without a factory', () => { + const { state, minter } = seededState() + const mint = minter.next + const paneId = getPane(state, state.rootId).id + const ops = planAddTab(state, mint, paneId, seedTab) + expect(ops.map(op => op.type)).toEqual(['openTab']) + expect(ops[0]).toMatchObject({ paneId, index: getPane(state, paneId).tabs.length }) + expect(planAddTab(state, mint, paneId)).toEqual([]) + }) +}) + +describe('one undo behaviour across both embeddings', () => { + /** Drive a script of intents through the pure history functions. */ + function driveByHand(): { history: History; state: LayoutState } { + const minter = createIdMinter() + const mint = minter.next + let state = createInitialState(minter, seedTab) + let history = EMPTY_HISTORY + const run = (ops: readonly LayoutOp[]): void => { + const stepped = record(history, state, ops) + history = stepped.history + state = stepped.state + } + run(planSetExpanded(state, true)) + run(planSplitPane(state, mint, undefined, seedTab)) + const opened = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + run(opened.ops) + const copy = planDuplicateTab(state, mint, opened.tabId) + run(copy.ops) + run([{ type: 'focusTab', tabId: opened.tabId }]) + run([{ type: 'focusTab', tabId: copy.tabId }]) + run([{ type: 'focusTab', tabId: opened.tabId }]) + return { history, state } + } + + /** The same script through the stateful embedding. */ + function driveByController(): DockController { + const controller = new DockController({ makeInitialTab: seedTab, makePaneTab: seedTab }) + controller.setExpanded(true) + controller.splitPane() + const opened = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + const copy = controller.duplicateTab(opened) + controller.focusTab(opened) + controller.focusTab(copy) + controller.focusTab(opened) + return controller + } + + it('records the same sequence either way', () => { + const byHand = driveByHand() + const controller = driveByController() + expect(recordedOps(byHand.history)).toEqual(controller.ops) + expect(byHand.state).toEqual(controller.getSnapshot().state) + }) + + it('merges a focus run into one step in both embeddings', () => { + const byHand = driveByHand() + const controller = driveByController() + + const stepped = stepBack(byHand.history, byHand.state) + if (stepped === undefined) throw new Error('expected a step back') + expect(controller.undo()).toBe(true) + // Three consecutive focus moves collapse to one step on both paths. + expect(byHand.history.cursor - stepped.history.cursor).toBe(3) + expect(stepped.history.cursor).toBe(controller.getSnapshot().cursor) + expect(stepped.state).toEqual(controller.getSnapshot().state) + + const forward = stepForward(stepped.history, stepped.state) + if (forward === undefined) throw new Error('expected a step forward') + expect(controller.redo()).toBe(true) + expect(forward.history.cursor).toBe(controller.getSnapshot().cursor) + expect(forward.state).toEqual(controller.getSnapshot().state) + }) + + it('discards the redo branch on both paths when a new operation lands', () => { + const byHand = driveByHand() + const controller = driveByController() + const stepped = stepBack(byHand.history, byHand.state) + if (stepped === undefined) throw new Error('expected a step back') + expect(controller.undo()).toBe(true) + + const reopened = record(stepped.history, stepped.state, [{ type: 'setExpanded', expanded: false }]) + controller.setExpanded(false) + expect(recordedOps(reopened.history)).toEqual(controller.ops) + expect(stepForward(reopened.history, reopened.state)).toBeUndefined() + expect(controller.getSnapshot().canRedo).toBe(false) + }) +}) + +describe('planned intents survive replay', () => { + it('rebuilds the same tree from the operations a planner produced', () => { + const { state, minter } = seededState() + const mint = minter.next + const ops: LayoutOp[] = [] + let current = state + + const push = (planned: readonly LayoutOp[]): void => { + ops.push(...planned) + current = applyAll(current, planned) + } + push(planSetExpanded(current, true)) + push(planSplitPane(current, mint, undefined, seedTab)) + const opened = planOpenContent(current, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + push(opened.ops) + const second = getSplit(current, current.rootId).children[1] + if (second === undefined) throw new Error('expected a second pane') + push(planPlaceTab(current, opened.tabId, getPane(current, second).id, 0)) + const floated = planFloatTab(current, mint, opened.tabId) + push(floated.ops) + push(planUnfloatPane(current, floated.paneId)) + + expect(applyAll(state, ops)).toEqual(current) + expect(fileTab(asTab('t'), 'dsh-resource://file/session/s/a.txt', 'a.txt').kind).toBe('file') + }) +}) diff --git a/packages/client/ui-dockkit/tests/sequence.client.spec.ts b/packages/client/ui-dockkit/tests/sequence.client.spec.ts new file mode 100644 index 0000000000..76449d2069 --- /dev/null +++ b/packages/client/ui-dockkit/tests/sequence.client.spec.ts @@ -0,0 +1,203 @@ +/** + * Sequence behavior: linear history, exact inverses, the focus-run undo step, and + * the redo branch a new operation discards. + */ +import { describe, expect, it } from 'vitest' +import { replay } from '../src/engine/operations.ts' +import { createIdMinter, createInitialState } from '../src/engine/initial.ts' +import { asPane, asTab, fileTab, seedTab } from './fixtures.client.ts' +import { EMPTY_HISTORY, isFocusOp, record, Sequencer } from '../src/engine/sequence.ts' +import { dockPaneIds, getPane, getSplit } from '../src/engine/tree.ts' +import type { LayoutState, PaneId, TabId } from '../src/contract/types.ts' + +interface Fixture { + readonly initial: LayoutState + readonly sequencer: Sequencer + readonly minter: ReturnType + readonly paneId: PaneId + readonly guideTabId: TabId +} + +function fixture(): Fixture { + const minter = createIdMinter() + const initial = createInitialState(minter, seedTab) + const guideTabId = getPane(initial, initial.rootId).tabs[0] + if (guideTabId === undefined) throw new Error('fixture: initial pane has no guide tab') + return { initial, sequencer: new Sequencer(initial), minter, paneId: getPane(initial, initial.rootId).id, guideTabId } +} + +describe('isFocusOp', () => { + it('names the operations that only move focus', () => { + expect(isFocusOp({ type: 'focusPane', paneId: asPane('p') })).toBe(true) + expect(isFocusOp({ type: 'focusTab', tabId: asTab('t') })).toBe(true) + expect(isFocusOp({ type: 'restoreFocus', activePaneId: asPane('p'), floats: [], paneActiveTabs: {} })).toBe(true) + expect(isFocusOp({ type: 'setExpanded', expanded: true })).toBe(false) + }) +}) + +describe('recording', () => { + it('starts with nothing to step through', () => { + const { sequencer, initial } = fixture() + expect(sequencer.state).toBe(initial) + expect(sequencer.canUndo).toBe(false) + expect(sequencer.canRedo).toBe(false) + expect(sequencer.undo()).toBe(false) + expect(sequencer.redo()).toBe(false) + }) + + it('records every operation, focus moves included', () => { + const { sequencer, minter, paneId, guideTabId } = fixture() + const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt') + sequencer.dispatch({ type: 'openTab', paneId, tab, index: 1 }) + sequencer.dispatch({ type: 'focusTab', tabId: guideTabId }) + sequencer.dispatch({ type: 'focusTab', tabId: tab.id }) + expect(sequencer.ops.map(op => op.type)).toEqual(['openTab', 'focusTab', 'focusTab']) + expect(sequencer.cursor).toBe(3) + }) + + it('exposes the recorded sequence as plain history, and records nothing for an empty intent', () => { + const { sequencer, initial, paneId } = fixture() + expect(sequencer.history).toBe(EMPTY_HISTORY) + sequencer.dispatch({ type: 'focusPane', paneId }) + expect(sequencer.history.entries).toHaveLength(1) + expect(sequencer.history.cursor).toBe(1) + const unchanged = record(EMPTY_HISTORY, initial, []) + expect(unchanged.history).toBe(EMPTY_HISTORY) + expect(unchanged.state).toBe(initial) + expect(sequencer.dispatchAll([])).toBe(sequencer.state) + expect(sequencer.history.entries).toHaveLength(1) + }) + + it('leaves the sequence untouched when an operation is invalid', () => { + const { sequencer } = fixture() + expect(() => sequencer.dispatch({ type: 'focusPane', paneId: asPane('nope') })).toThrow(/unknown node/) + expect(sequencer.ops).toHaveLength(0) + expect(sequencer.canUndo).toBe(false) + }) +}) + +describe('stepping back and forward', () => { + it('returns to the exact previous state', () => { + const { sequencer, initial, minter, paneId } = fixture() + const newPaneId = minter.next('pane') + sequencer.dispatch({ + type: 'split', paneId, axis: 'row', direction: 'after', newPaneId, newSplitId: minter.next('split'), + }) + expect(dockPaneIds(sequencer.state)).toHaveLength(2) + expect(sequencer.undo()).toBe(true) + expect(sequencer.state).toEqual(initial) + expect(sequencer.canUndo).toBe(false) + expect(sequencer.canRedo).toBe(true) + expect(sequencer.redo()).toBe(true) + expect(dockPaneIds(sequencer.state)).toHaveLength(2) + }) + + it('collapses a run of consecutive focus moves into one step', () => { + const { sequencer, minter, paneId, guideTabId } = fixture() + const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt') + sequencer.dispatch({ type: 'openTab', paneId, tab, index: 1 }) + const afterOpen = sequencer.state + sequencer.dispatch({ type: 'focusTab', tabId: guideTabId }) + sequencer.dispatch({ type: 'focusTab', tabId: tab.id }) + sequencer.dispatch({ type: 'focusTab', tabId: guideTabId }) + expect(sequencer.cursor).toBe(4) + + expect(sequencer.undo()).toBe(true) + expect(sequencer.cursor).toBe(1) + expect(sequencer.state).toEqual(afterOpen) + + expect(sequencer.redo()).toBe(true) + expect(sequencer.cursor).toBe(4) + expect(getPane(sequencer.state, paneId).activeTabId).toBe(guideTabId) + }) + + it('steps one structural operation even when focus moves precede it', () => { + const { sequencer, paneId, guideTabId } = fixture() + sequencer.dispatch({ type: 'focusPane', paneId }) + sequencer.dispatch({ type: 'focusTab', tabId: guideTabId }) + sequencer.dispatch({ type: 'setExpanded', expanded: true }) + expect(sequencer.undo()).toBe(true) + expect(sequencer.cursor).toBe(2) + expect(sequencer.state.expanded).toBe(false) + expect(sequencer.undo()).toBe(true) + expect(sequencer.cursor).toBe(0) + }) + + it('undoes a whole session back to the initial state', () => { + const { sequencer, initial, minter, paneId, guideTabId } = fixture() + const newPaneId = minter.next('pane') + const floatId = minter.next('float') + sequencer.dispatch({ type: 'setExpanded', expanded: true }) + sequencer.dispatch({ + type: 'split', paneId, axis: 'row', direction: 'after', newPaneId, newSplitId: minter.next('split'), + }) + sequencer.dispatch({ type: 'moveTab', tabId: guideTabId, toPaneId: newPaneId, index: 0 }) + sequencer.dispatch({ + type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 5, y: 6, width: 200, height: 100 }, + }) + sequencer.dispatch({ type: 'moveFloat', paneId: floatId, x: 50, y: 60 }) + while (sequencer.undo()) { /* step back to the beginning */ } + expect(sequencer.state).toEqual(initial) + expect(sequencer.cursor).toBe(0) + }) +}) + +describe('a floating panel\'s drag as one step', () => { + it('raises and focuses the panel with the move, and steps both back and forward together', () => { + const { sequencer, minter, paneId, guideTabId } = fixture() + const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt') + const lower = minter.next('float') + const upper = minter.next('float') + sequencer.dispatch({ type: 'openTab', paneId, tab, index: 1 }) + sequencer.dispatch({ type: 'float', tabId: guideTabId, newPaneId: lower, rect: { x: 0, y: 0, width: 200, height: 100 } }) + sequencer.dispatch({ type: 'float', tabId: tab.id, newPaneId: upper, rect: { x: 20, y: 20, width: 200, height: 100 } }) + sequencer.dispatch({ type: 'focusPane', paneId }) + const before = sequencer.state + expect(before.floats).toEqual([lower, upper]) + + sequencer.dispatch({ type: 'moveFloat', paneId: lower, x: 50, y: 60 }) + expect(sequencer.state.floats).toEqual([upper, lower]) + expect(sequencer.state.activePaneId).toBe(lower) + + // One step back restores the rectangle, the z order, and the focus; the + // preceding focus-only entry is its own step. + expect(sequencer.undo()).toBe(true) + expect(sequencer.state).toEqual(before) + expect(sequencer.redo()).toBe(true) + expect(sequencer.state.floats).toEqual([upper, lower]) + expect(getPane(sequencer.state, lower).rect).toEqual({ x: 50, y: 60, width: 200, height: 100 }) + expect(sequencer.state.activePaneId).toBe(lower) + }) +}) + +describe('linear history', () => { + it('drops the redo branch when a new operation lands after an undo', () => { + const { sequencer, minter, paneId } = fixture() + sequencer.dispatch({ type: 'setExpanded', expanded: true }) + sequencer.dispatch({ + type: 'split', paneId, axis: 'row', direction: 'after', newPaneId: minter.next('pane'), newSplitId: minter.next('split'), + }) + expect(sequencer.undo()).toBe(true) + expect(sequencer.canRedo).toBe(true) + sequencer.dispatch({ type: 'focusPane', paneId }) + expect(sequencer.canRedo).toBe(false) + expect(sequencer.ops.map(op => op.type)).toEqual(['setExpanded', 'focusPane']) + expect(dockPaneIds(sequencer.state)).toHaveLength(1) + }) + + it('keeps the applied prefix replayable at any cursor', () => { + const { sequencer, initial, minter, paneId, guideTabId } = fixture() + const newPaneId = minter.next('pane') + sequencer.dispatch({ type: 'setExpanded', expanded: true }) + sequencer.dispatch({ + type: 'split', paneId, axis: 'row', direction: 'after', newPaneId, newSplitId: minter.next('split'), + }) + sequencer.dispatch({ type: 'moveTab', tabId: guideTabId, toPaneId: newPaneId, index: 0 }) + sequencer.dispatch({ type: 'resize', splitId: getSplit(sequencer.state, sequencer.state.rootId).id, sizes: [0.7, 0.3] }) + sequencer.undo() + sequencer.undo() + expect(replay(initial, sequencer.ops.slice(0, sequencer.cursor))).toEqual(sequencer.state) + sequencer.redo() + expect(replay(initial, sequencer.ops.slice(0, sequencer.cursor))).toEqual(sequencer.state) + }) +}) diff --git a/packages/client/ui-dockkit/tsconfig.json b/packages/client/ui-dockkit/tsconfig.json new file mode 100644 index 0000000000..2139bcf7ba --- /dev/null +++ b/packages/client/ui-dockkit/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../util/brand" + } + ] +} diff --git a/packages/client/ui-dockkit/tsdown.config.ts b/packages/client/ui-dockkit/tsdown.config.ts new file mode 100644 index 0000000000..7e4e7ba807 --- /dev/null +++ b/packages/client/ui-dockkit/tsdown.config.ts @@ -0,0 +1,6 @@ +import { staticLinked } from '../tsdown.client.ts' + +export default staticLinked( + '@deepseek-ai/dsh-client-ui-dockkit', + ['lib/types/index.js'], +) diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index bc3ad62e70..1a44a08707 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-goal", "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index d768577314..b01992a297 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-input-trigger", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index 035d243e22..5bec169696 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-jobs", "description": "Session-header background-job list: live registry state mirrored from session/jobs frames", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 17187a8179..eb1cbebbee 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/README.i18n.yaml @@ -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-layout/README.md -README.md: aa00242f1d63924b990cfec23bd0438332662b06 -README.zh.md: 801346967f9fbb031e5e6c2612dff84af79f2d45 +README.md: 0e353e21f39ba8a84e661f1c540db2c2424bcbfa +README.zh.md: 16471fdabb46bbfb35158ef85aa2544db4ceae93 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index aa00242f1d..0e353e21f3 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -1,5 +1,5 @@ --- -description: "Shell layout for the Web GUI: the three-column AppFrame with drag handles, concession behavior, the panel-geometry service, and theme presentation; for users and maintainers of the window chrome." +description: "Shell layout for the Web GUI: the three-column AppFrame whose right column is a track for an edge-anchored panel, the panel-geometry service, and theme presentation; for users and maintainers of the window chrome." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package provides the shell layout of the Web GUI: a three-column AppFrame with resizable sidebar and details panels, a concession chain that shrinks the details column and then auto-closes it when space runs out, and the `ctx.layout` panel-geometry service other plugins call to open or close the details column. It also seats the theme presenter, which projects the resolved color scheme, alias tokens, content font size, and `theme-color` metadata onto the document. Choose it for the standard window chrome; panel geometry is transient and resets on reload. +This package provides the Web GUI's three-column AppFrame, edge-column widths, and `ctx.layout` presentation control. The right column concedes space before the center; its occupant renders fullscreen while the frame retains the wide-screen track underneath. The theme presenter owns color scheme, alias tokens, content font size, and document metadata. Layout state resets on reload. ## Table of Contents @@ -25,7 +25,7 @@ This package provides the shell layout of the Web GUI: a three-column AppFrame w ## Use this package -Mount this plugin at the root slot; it then renders the app frame around whatever occupies the sidebar, conversation, and details columns. Users resize the sidebar by dragging its invisible hit strip and the details panel by dragging its floating pill; when the window narrows, only details shrinks, then auto-closes. A closed sidebar retains a 56px control rail; details closes to zero width. +The root slot composes the sidebar, conversation, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen. ### Theme presentation @@ -39,7 +39,7 @@ The presenter consumes resolved theme snapshots and projects them onto the docum
Implementation internals — click to expand -One `register()` call contributes `AppFrame` into the runtime's built-in `'root'` slot and, in the same breath, declares the four child slots (`sidebar`, `conversation`, `details`, `shell.overlay`), seats the layout store (panel geometry), and wires the `ctx.layout` panel-action service. The transient layout store starts the sidebar at its default width and details closed, and never reads or writes `localStorage`. AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. It projects the selected Session title over the build-configured product title or the localized `common.brand.localBuild` fallback, so locale revisions update document metadata with the root entry. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. +One registration declares four child slots and binds `ctx.layout` methods `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. The store owns the single frame-width measurement, width preferences, and the occupant's presentation report. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame always mounts the conversation and right columns; a connected Session renders through `SessionProvider`, and without one the right column is an empty zero-width track. It projects the selected Session title over the build-configured product title or the localized `common.brand.localBuild` fallback, so locale revisions update document metadata with the root entry. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions.
@@ -51,7 +51,8 @@ One `register()` call contributes `AppFrame` into the runtime's built-in `'root' Read these pages when the layout surface is not enough. They move from the frame to the columns it renders and the theme it presents. - [ui-sidebar](../ui-sidebar/README.md) — occupies the `sidebar` column and its seats. -- [ui-conversation](../ui-conversation/README.md) — occupies the `conversation` and `details` columns. +- [ui-conversation](../ui-conversation/README.md) — occupies the `conversation` column. +- [ui-sidebar-right](../ui-sidebar-right/README.md) — occupies the `rightbar` column with one docking surface per session. - [ui-theme](../ui-theme/README.md) — the theme seam whose resolved snapshots the presenter consumes. - [Web client architecture](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — how browser plugin rows load and register slots. @@ -73,8 +74,9 @@ None; this package neither assembles nor sends a provider request. These limits define the current layout behavior. They are current package constraints, not a general window-manager comparison or a task backlog. -- **Panel geometry is transient** — reload restores the sidebar default and details closed; switching between distinct Session ids also closes details and forgets its dragged width, while unselected surfaces render details at zero width without modifying geometry. -- **Concession-chain auto-close derives a zero width without touching the preferred width** — the panel restores itself when the window widens; consumers must not read the stored details width as the rendered truth. +- **Panel geometry is transient** — reload restores the sidebar default and the right panel hidden; each dragged width is one frame-wide preference, not a per-Session fact. +- **Extremely narrow windows** — after the right panel closes, the center may still fall below 400px; the left 56px rail remains. +- **Track and panel travel on one shared curve** — the frame's track transition and the occupant's slide read the same duration and easing variables; an occupant that used its own would detach the panel's edge from the conversation's while squeezing. - **No scroll anchoring during squeeze reflow** — layout changes may move the reader's viewport. @@ -87,4 +89,4 @@ None. -**Runtime invariant:** No companion is published. The shell viewing-state store behind ctx.layout emits no cordis events; clamp/prune/concession-chain sequencing is asserted directly by this package's columns and service specs. +**Runtime invariant:** No companion is published. The shell viewing-state store behind ctx.layout emits no cordis events; clamp and track sequencing is asserted directly by this package's columns and service specs. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 801346967f..16471fdabb 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web GUI 的外壳布局:三栏 AppFrame、拖动手柄与让步行为、面板几何服务与主题呈现;供窗口外观的用户与维护者阅读。" +description: "Web GUI 的外壳布局:三栏 AppFrame——其右栏是贴边面板的轨道——面板几何服务与主题呈现;供窗口外观的用户与维护者阅读。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包提供 Web GUI 的外壳布局:一个三栏 AppFrame,带可缩放的侧栏与详情面板;一条让步链,在空间不足时先收缩详情栏、随后自动关闭它;以及 `ctx.layout` 面板几何服务,供其他插件调用以打开或关闭详情栏。它还承载主题呈现器,把解析后的配色方案、别名 token、正文字号与 `theme-color` 元数据投影到 document。需要标准窗口外观时选择它;面板几何是瞬时的,重新加载即重置。 +本包提供 Web GUI 的三栏 AppFrame、左右栏宽度与 `ctx.layout` 呈现控制。右栏先让步以保护中栏空间,全屏由占用方呈现,框架保留宽屏底层轨道。主题呈现器负责配色、别名 token、正文字号与 document 元数据;布局状态在刷新后重置。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在 root 槽位挂载本插件;它随即围绕占据侧栏、会话与详情栏的内容渲染应用框架。用户拖动不可见命中条带缩放侧栏、拖动浮动胶囊缩放详情面板;窗口变窄时只有详情栏收缩,随后自动关闭。关闭的侧栏保留 56px 控制栏;详情栏关闭到零宽度。 +本插件在 root slot 组合侧栏、会话与右栏。左栏为264~420px,默认280px,收起后保留56px;窗口低于1024px时自动收起,打开右栏也会收起手动展开的左栏。右栏首次打开使用窗口宽度的45%,之后保留用户像素偏好,上限为70%;中栏不足400px时先把右栏压到300px,仍不足则通知占用方收起,最后才继续压缩中栏。拖拽跟手且无过渡延迟,关闭或全屏时不显示右栏拖拽区。 ### 主题呈现 @@ -39,7 +39,7 @@ kind: "package-reference"
实现细节——点击展开 -一次 `register()` 调用把 `AppFrame` 贡献进运行时的内建 `'root'` 槽位,并在同一刻声明四个子槽位(`sidebar`、`conversation`、`details`、`shell.overlay`)、安放布局 store(面板几何)并接好 `ctx.layout` 面板动作服务。瞬时布局 store 以默认宽度启动侧栏、保持详情栏关闭,从不读写 `localStorage`。AppFrame 始终挂载会话与详情两栏;已连接 Session 经 `SessionProvider` 渲染。它把所选 Session 标题投影到构建配置的产品标题或本地化 `common.brand.localBuild` 回退值之上,因此 locale revision 会随根 entry 一起更新文档元数据。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 +一次注册声明四个子slot并绑定 `ctx.layout` 的 `toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。store持有唯一的frame宽度测量、左右栏偏好及占用方报告的呈现状态。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 始终挂载会话与右栏;已连接 Session 经 `SessionProvider` 渲染,没有 Session 时右栏是一条空的零宽轨道。它把所选 Session 标题投影到构建配置的产品标题或本地化 `common.brand.localBuild` 回退值之上,因此 locale revision 会随根 entry 一起更新文档元数据。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。 退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。
@@ -51,7 +51,8 @@ kind: "package-reference" 当布局面不够用时阅读以下页面。它们从框架进入它所渲染的栏与它所呈现的主题。 - [ui-sidebar](../ui-sidebar/README.zh.md)——占据 `sidebar` 栏及其座位。 -- [ui-conversation](../ui-conversation/README.zh.md)——占据 `conversation` 与 `details` 栏。 +- [ui-conversation](../ui-conversation/README.zh.md)——占据 `conversation` 栏。 +- [ui-sidebar-right](../ui-sidebar-right/README.zh.md)——以每会话一个停靠面占据 `rightbar` 栏。 - [ui-theme](../ui-theme/README.zh.md)——呈现器消费其解析快照的主题 seam。 - [Web 客户端架构](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——浏览器插件行如何加载并注册槽位。 @@ -73,8 +74,9 @@ kind: "package-reference" 这些限制界定了当前布局行为。它们是当前包约束,不是通用窗口管理器对比或任务积压。 -- **面板几何是瞬时状态**——重新加载会恢复侧栏默认值并保持详情栏关闭;在不同会话 id 之间切换同样会关闭详情栏并忘记拖动后的宽度,而未选中表面以零宽度渲染详情栏却不修改几何。 -- **让步链自动关闭通过推导零宽度实现,不触碰偏好宽度**——窗口变宽时面板自行恢复;消费方不得把 store 中的详情宽度当作渲染真值。 +- **面板几何是瞬时状态**——重新加载会恢复侧栏默认值并隐藏右侧面板;每个拖出的宽度都是一份框架级偏好,不是按 Session 的事实。 +- **极窄窗口**——右栏关闭后,中栏仍可能小于400px;左侧56px控制栏保留。 +- **轨道与面板沿同一条曲线运动**——框架的轨道过渡和占位方的滑入读取同一组时长与缓动变量;占位方若自用一套,挤压时面板边缘就会与对话边缘脱开。 - **挤压重排期间无滚动锚定**——布局变化可能移动读者的视口。 @@ -87,4 +89,4 @@ kind: "package-reference" -**运行时不变式:** 不发布伴生入口。`ctx.layout` 后的 viewing-state store 不发出 Cordis 事件;clamp、prune 与 concession-chain 顺序由本包测试覆盖。 +**运行时不变式:** 不发布伴生入口。`ctx.layout` 后的 viewing-state store 不发出 Cordis 事件;clamp 与轨道的时序由本包的 columns 与 service 规格直接断言。 diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 51d2ee2c0e..d2fe553dc8 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-layout", "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index 9ba4e82be6..5a4f131e5b 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -36,21 +36,12 @@ overflow: hidden; } -.detailsCol { - min-width: 0; - overflow: hidden; - border-left: 0.5px solid var(--dsw-alias-border-l3); -} - -/* The details subtree stays mounted at zero width, so its border must not paint - a 1px seam. The collapsed sidebar instead retains a bordered compact rail. */ -.frame[data-details-collapsed] .detailsCol { - border-left: none; -} - /* Drag handles are frame children (columns clip overflow): an 8px hit strip - centered on the column border via inline left, above column content. Details - adds a visible 12x32 pill at vertical center; sidebar keeps only the hit strip. */ + centered on the column border via inline left, above column content. No + handle draws a visible pill: the details column was the only one that did, + and the right column never had one. The right handle sits above the right + panel (which states z-index 10) so it stays grabbable when the panel hangs + over the centre. */ .handle { position: absolute; top: 0; @@ -58,7 +49,7 @@ width: 8px; margin-left: -4px; cursor: col-resize; - z-index: 2; + z-index: 11; touch-action: none; /* Rides the same curve as the tracks so the pill stays on the moving border during collapse/expand; paused while dragging (frame rule). */ @@ -69,42 +60,31 @@ transition: none; } +/* Fullscreen covers the frame while its underlying columns enter or exit. */ +.frame[data-rightbar-fullscreen], +.frame[data-rightbar-fullscreen] .handle, +.frame[data-rightbar-instant], +.frame[data-rightbar-instant] .handle { + transition: none; +} + @media (prefers-reduced-motion: reduce) { .handle { transition: none; } } -.handle[data-side='details']::after { - content: ''; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 12px; - height: 32px; - border-radius: 10px; - box-sizing: border-box; - background: var(--dsw-alias-button-floating-fill); - border: 0.5px solid var(--dsw-alias-border-l2-darkmode-thin); - /* Hover affordance: the details pill hides until the pointer is over its - column, the strip itself, or a drag. */ - opacity: 0; - transition: - opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out), - background var(--ds-transition-duration-slow) var(--ds-ease-in-out); -} - -.detailsCol:hover ~ .handle[data-side='details']::after, -.handle[data-side='details']:hover::after, -.handle[data-side='details'][data-dragging='true']::after { - opacity: 1; -} - -.handle[data-side='details']:hover::after, -.handle[data-side='details'][data-dragging='true']::after { - background: var(--dsw-alias-button-floating-hover); - border-color: var(--dsw-alias-border-l3); +/* + * The right column never clips: its occupant anchors a fixed-width panel to the + * column's right edge (the frame's edge, which never moves) and lets the track + * decide whether the centre makes room. With no track the panel hangs over the + * centre from a zero-width column, and while the track animates the panel stays + * where it is. The occupant draws its own left border. + */ +.rightbarCol { + position: relative; + min-width: 0; + overflow: visible; } .overlayLayer { diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index cbef893aed..905341f503 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -1,21 +1,26 @@ /** * Three-column shell frame, registered into the built-in 'root' slot (the web * shell renders only 'root'). Owns the grid tracks (sidebar | center | - * details), the drag handles (pointer capture + rAF throttle), the concession - * chain (columns.ts), and the child-slot render decisions: the sidebar slot - * renders HERE with live parameters from the concession solve, and the - * session-aware occupants render in fixed column positions; strict entries - * gate themselves on current-session availability while session-maybe - * entries retain identity. Pure component: everything arrives - * through the three framework shares — zero cordis or framework imports, - * zero self-made hooks. + * rightbar), the drag handles (pointer capture + rAF throttle), the column + * solve (columns.ts), and the child-slot render decisions: the sidebar slot + * renders HERE with live parameters from that solve, and the session-aware + * occupants render in fixed column positions; the strict right-column entry + * gates itself on current-session availability while the session-maybe + * conversation retains identity. + * + * The right column is a track, not a box: its occupant draws its panel anchored + * to the frame's right edge at the resolved normal width, and the + * track only decides whether the centre makes room for it. The occupant reports + * shown/track/fullscreen through `ctx.layout`; fullscreen keeps the reported + * track but hides the outer resize handle. Everything arrives through the framework + * shares — zero cordis or framework imports, zero self-made hooks. */ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import type { PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, } from '@deepseek-ai/dsh-client-ui-slots' -import { computeColumns, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT } from './columns.ts' +import { computeColumns, RIGHTBAR_DEFAULT_RATIO, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT } from './columns.ts' import { DocumentTitle } from './DocumentTitle.tsx' import type { createLayoutStore } from './stores.ts' import css from './AppFrame.module.css' @@ -23,7 +28,7 @@ import css from './AppFrame.module.css' /** Full composed props: runtime share + child-slot render share + store share. */ export type AppFrameProps = & PropsRuntime<'root'> - & PropsRenderSlots<'sidebar' | 'conversation' | 'details' | 'shell.overlay'> + & PropsRenderSlots<'sidebar' | 'conversation' | 'rightbar' | 'shell.overlay'> & PropsStore> & PropsLocale<'common'> @@ -32,33 +37,51 @@ function CenterColumn(props: { children?: ReactNode }) { return
{props.children}
} -/** Details column grid item; width 0 keeps the subtree mounted (never unmount on close). */ -function DetailsColumn(props: { children?: ReactNode }) { - return
{props.children}
+/** + * Right column grid item. Zero-width unless the occupant asked for a track; the + * occupant's panel is positioned against the column's right edge, which never + * moves, so it can hang over the centre when there is no track. + */ +function RightbarColumn(props: { children?: ReactNode }) { + return
{props.children}
} /** * One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. * `side` keys the hover-reveal CSS to the owning column. */ -function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) { +function DragHandle(props: { side: 'sidebar' | 'rightbar'; left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) { const [dragging, setDragging] = useState(false) const origin = useRef(0) const latest = useRef(0) const frame = useRef(null) + const capture = useRef<{ element: HTMLDivElement; id: number } | null>(null) const callbacks = useRef({ onStart: props.onStart, onDrag: props.onDrag, onEnd: props.onEnd }) callbacks.current = { onStart: props.onStart, onDrag: props.onDrag, onEnd: props.onEnd } + const endDrag = useCallback(() => { + const active = capture.current + if (active === null) return + capture.current = null + if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null } + if (active.element.hasPointerCapture(active.id)) active.element.releasePointerCapture(active.id) + setDragging(false) + callbacks.current.onEnd() + }, []) + useEffect(() => endDrag, [endDrag]) + const onPointerDown = useCallback((e: React.PointerEvent) => { + if (e.button !== 0 || capture.current !== null) return e.preventDefault() e.currentTarget.setPointerCapture(e.pointerId) + capture.current = { element: e.currentTarget, id: e.pointerId } origin.current = e.clientX latest.current = e.clientX callbacks.current.onStart() setDragging(true) }, []) const onPointerMove = useCallback((e: React.PointerEvent) => { - if (!e.currentTarget.hasPointerCapture(e.pointerId)) return + if (capture.current?.id !== e.pointerId) return latest.current = e.clientX frame.current ??= requestAnimationFrame(() => { frame.current = null @@ -66,13 +89,13 @@ function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: }) }, []) const onPointerUp = useCallback((e: React.PointerEvent) => { - if (!e.currentTarget.hasPointerCapture(e.pointerId)) return - e.currentTarget.releasePointerCapture(e.pointerId) - if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null } - callbacks.current.onDrag(latest.current - origin.current) - setDragging(false) - callbacks.current.onEnd() - }, []) + if (capture.current?.id !== e.pointerId) return + callbacks.current.onDrag(e.clientX - origin.current) + endDrag() + }, [endDrag]) + const onPointerCancel = useCallback((e: React.PointerEvent) => { + if (capture.current?.id === e.pointerId) endDrag() + }, [endDrag]) return (
) } @@ -97,78 +122,71 @@ export function AppFrame({ t, }: AppFrameProps) { const panels = useStore(s => s) - const detailsSession = useSessions((s) => { - const current = s.current - return current !== undefined && s.byId[current]?.blank === false ? current : undefined - }) const documentTitle = useSessions((s) => { const current = s.current return current === undefined ? undefined : s.byId[current]?.title }) const frameRef = useRef(null) - const [viewport, setViewport] = useState(() => window.innerWidth) - - const lastSession = useRef(detailsSession) - useLayoutEffect(() => { - if (detailsSession === undefined) return - if (lastSession.current !== undefined && lastSession.current !== detailsSession) { - actions.closeDetails() - } - lastSession.current = detailsSession - }, [actions, detailsSession]) + const viewport = panels.viewportWidth // Track the frame's own box (not the window): rAF-throttled ResizeObserver. - useEffect(() => { + useLayoutEffect(() => { const el = frameRef.current /* v8 ignore next -- the ref is always attached by effect time: the frame div renders unconditionally. */ if (el === null) return let raf: number | null = null + let disposed = false + const measure = () => { + const width = el.getBoundingClientRect().width + if (width > 0) actions.setViewportWidth(width) + } + measure() const observer = new ResizeObserver(() => { + if (disposed) return raf ??= requestAnimationFrame(() => { raf = null - const width = el.getBoundingClientRect().width - if (width > 0) setViewport(width) + measure() }) }) observer.observe(el) return () => { + disposed = true observer.disconnect() if (raf !== null) cancelAnimationFrame(raf) } - }, []) + }, [actions]) - // Narrow viewports auto-collapse the sidebar; the store mirror keeps - // toggleSidebar's semantics right (narrow toggles flip the manual - // re-expand override, stores.ts). Collapsed is decided here, so the - // solver stays breakpoint-free: a narrow re-expand passes the preference - // (or the default when the wide preference is closed) and the center - // absorbs the squeeze. const narrow = viewport < SIDEBAR_AUTO_COLLAPSE - useEffect(() => { actions.setNarrow(narrow) }, [actions, narrow]) const sidebarCollapsed = narrow ? !panels.narrowExpanded : panels.sidebar === 0 const sidebarPreference = sidebarCollapsed ? 0 : panels.sidebar === 0 ? SIDEBAR_DEFAULT : panels.sidebar - const cols = computeColumns(viewport, sidebarPreference, detailsSession === undefined ? 0 : panels.details) + const rightbarPreference = panels.rightbar ?? viewport * RIGHTBAR_DEFAULT_RATIO + // Opening on a narrow frame collapses the left sidebar. Eligibility must + // include that space before the occupant's first shown report arrives. + const normal = computeColumns(viewport, !panels.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference) + const cols = computeColumns(viewport, sidebarPreference, panels.rightbarTrack ? rightbarPreference : 0) const colsRef = useRef(cols) colsRef.current = cols + const rightbarWidth = useRef(normal.rightbar) + rightbarWidth.current = normal.rightbar // The drag base is the rendered width captured at drag start (grabbing a // concession-clamped panel must not jump back to the stored preference); // it stays frozen for the whole gesture so dx deltas do not compound. const sidebarBase = useRef(0) - const detailsBase = useRef(0) + const rightbarBase = useRef(0) // Track-level transitions pause for the whole gesture: eased tracks would // detach the column edge from the pointer (AppFrame.module.css). const [dragging, setDragging] = useState(false) const onDragEnd = useCallback(() => { setDragging(false) }, []) const onSidebarStart = useCallback(() => { sidebarBase.current = colsRef.current.sidebar; setDragging(true) }, []) - const onDetailsStart = useCallback(() => { detailsBase.current = colsRef.current.details; setDragging(true) }, []) const onSidebarDrag = useCallback((dx: number) => { actions.setSidebar(sidebarBase.current + dx) }, [actions]) - const onDetailsDrag = useCallback((dx: number) => { - actions.setDetails(detailsBase.current - dx) + const onRightbarStart = useCallback(() => { rightbarBase.current = rightbarWidth.current; setDragging(true) }, []) + const onRightbarDrag = useCallback((dx: number) => { + actions.setRightbar(rightbarBase.current - dx) }, [actions]) const productTitle = process.env.DSH_CLIENT_TITLE ?? t('brand.localBuild') @@ -176,9 +194,14 @@ export function AppFrame({
{/* Both column occupants stay at fixed tree positions from first paint — no loading gate: a bare status line reads worse than - the shell's own pending rendering. The conversation - is session-maybe; SessionProvider withholds the strict details + the shell's own pending rendering. The conversation is + session-maybe; SessionProvider withholds the strict right-column entry while no session is current. */} {renderSlot('conversation', {})} - - {renderSlot('details', {})} - + + {/* Strict session entry: with no session there is no surface, and the + column is an empty zero-width track. The occupant receives the + panel width it should draw at; the track is the frame's business. */} + + {renderSlot('rightbar', { width: normal.rightbar, viewportWidth: viewport, canShow: normal.rightbar > 0 })} + +
{renderSlot('shell.overlay', {})}
{/* The collapsed rail is fixed-width: no resize handle while closed. */} {!sidebarCollapsed && } - {cols.details > 0 && } + {panels.rightbarShown && !panels.rightbarFullscreen && normal.rightbar > 0 && ( + + )}
) } diff --git a/packages/client/ui-layout/src/client/columns.ts b/packages/client/ui-layout/src/client/columns.ts index 374ce64703..a58b284255 100644 --- a/packages/client/ui-layout/src/client/columns.ts +++ b/packages/client/ui-layout/src/client/columns.ts @@ -1,24 +1,14 @@ /** - * Pure concession-chain column solver for the three-column AppFrame. - * Chain order is fixed by contract: keep center >= CENTER_MIN by shrinking - * details, then auto-closing it (derived zero width — preferred width - * preferences are never rewritten, so widening the window restores them). - * The sidebar never concedes: its rendered width is always the drag - * preference (or the collapsed rail), and center absorbs any remaining - * deficit as the last resort. Inputs are the layout store's plain width - * preferences (0 = closed); a closed sidebar resolves to the fixed - * SIDEBAR_COLLAPSED control rail while closed details resolve to zero width. - * The SIDEBAR_AUTO_COLLAPSE breakpoint is consumed by AppFrame, which decides - * the effective sidebar preference before solving; the solver itself stays - * breakpoint-free. + * Normal column geometry: the right column shrinks, then loses its track, + * before the center drops below its minimum. The sidebar never concedes here; + * AppFrame supplies its effective preference after responsive collapse. */ -/** Resolved widths for one frame; center may drop below CENTER_MIN only at the final fallback. */ -export interface Columns { sidebar: number; center: number; details: number } +/** Resolved widths for one frame. */ +export interface Columns { sidebar: number; center: number; rightbar: number } -// Contract-frozen geometry: the three-column concession chain's fixed points. -/** Center column floor; only the final fallback may go below it. */ -export const CENTER_MIN = 640 +/** Center width protected while the normal right column is open. */ +export const CENTER_MIN = 400 /** Sidebar drag clamp floor. */ export const SIDEBAR_MIN = 264 /** Sidebar drag clamp ceiling. */ @@ -31,12 +21,12 @@ export const SIDEBAR_COLLAPSED = 56 * LG breakpoint); a manual toggle below it re-expands over the squeezed center * (stores.ts narrowExpanded). */ export const SIDEBAR_AUTO_COLLAPSE = 1024 -/** Details drag clamp floor. */ -export const DETAILS_MIN = 300 -/** Details drag clamp ceiling. */ -export const DETAILS_MAX = 520 -/** Details width before any user drag. */ -export const DETAILS_DEFAULT = 360 +/** Right column drag clamp floor. */ +export const RIGHTBAR_MIN = 300 +/** Maximum normal right panel width as a fraction of the frame. */ +export const RIGHTBAR_MAX_RATIO = 0.7 +/** First-open right panel preference as a fraction of the frame. */ +export const RIGHTBAR_DEFAULT_RATIO = 0.45 /** * Clamp a panel width into its contract range. @@ -50,28 +40,18 @@ export function clampWidth(px: number, min: number, max: number): number { } /** - * Solve the three column widths for one viewport frame. Pure: no hysteresis — - * the output is a function of (viewport, preferences) only, so recovery on - * re-widening is automatic. Preferences re-clamp here because they cross the - * store boundary and callers may still supply stale ranges. + * Solve the three column widths for one viewport frame. * @param viewport - available frame width in px. * @param sidebar - sidebar width preference in px (0 = closed). - * @param details - details width preference in px (0 = closed). - * @returns resolved widths; details 0 means visually closed (never unmounted), while a closed sidebar keeps its compact rail. + * @param rightbar - requested right panel width in px (0 = no track). + * @returns actual widths after shrinking or removing the right track; only + * without that track may the center fall below its minimum, down to zero. */ -export function computeColumns(viewport: number, sidebar: number, details: number): Columns { - // The sidebar is fixed at its preference (or the rail) — it never concedes. +export function computeColumns(viewport: number, sidebar: number, rightbar: number): Columns { const s = sidebar === 0 ? SIDEBAR_COLLAPSED : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX) - const d0 = details === 0 ? 0 : clampWidth(details, DETAILS_MIN, DETAILS_MAX) - - // Step 1: everything fits at preferred widths. - if (s + d0 + CENTER_MIN <= viewport) return { sidebar: s, center: viewport - s - d0, details: d0 } - - // Step 2: shrink details toward its minimum. - const d1 = d0 === 0 ? 0 : Math.max(DETAILS_MIN, viewport - s - CENTER_MIN) - if (s + d1 + CENTER_MIN <= viewport) return { sidebar: s, center: CENTER_MIN, details: d1 } - - // Step 3: auto-close details (derived — preferences untouched); center - // absorbs any remaining deficit (may drop below CENTER_MIN). - return { sidebar: s, center: Math.max(0, viewport - s), details: 0 } + const available = viewport - s - CENTER_MIN + const r = rightbar === 0 || available < RIGHTBAR_MIN + ? 0 + : Math.min(available, clampWidth(rightbar, RIGHTBAR_MIN, viewport * RIGHTBAR_MAX_RATIO)) + return { sidebar: s, center: Math.max(0, viewport - s - r), rightbar: r } } diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 56afec15e3..649215b96d 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -64,15 +64,19 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ 'conversation': { kind: 'single'; scope: 'session-maybe'; owner: ConvOwnerProps } /** - * The right details column, shown when the layout opens it. OCCUPIED by - * ui-conversation's DetailsPanel, which declares the tool-details seat - * inside it — registering here replaces the column and takes that seat - * with it. Absent an occupant the column renders nothing. + * The right column: a track the centre makes room for, or nothing. OCCUPIED + * by the right Sidebar, which uses the resolved column width in normal + * mode and covers the viewport in fullscreen, retaining the wide-screen + * column reservation underneath. * - * No owner props: the framework injects the session id and hooks for the - * `session` scope, and `ctx.layout` owns whether the column is open. + * Whether the panel is shown, and whether it takes a track, is the + * occupant's own recorded business — it reports the composition of its + * expanded and presentation state through `ctx.layout`, and the frame sizes + * the track and places the resize handle from that. The expand control is + * not this column's: it is a button in the conversation header. With no + * current session nothing is mounted here. */ - 'details': { kind: 'single'; scope: 'session'; owner: DetailsOwnerProps } + 'rightbar': { kind: 'single'; scope: 'session'; owner: RightbarOwnerProps } /** * Frame-wide floating layer, above every column and outside their scroll * containers. Deliberately generic and unowned by any feature: a badge, a @@ -104,8 +108,18 @@ export interface SidebarOwnerProps { /** Conversation owner share: business state and actions belong to the registrant. */ export interface ConvOwnerProps {} -/** Details owner share: empty — sessionId arrives as a framework-standard prop. */ -export interface DetailsOwnerProps {} +/** Right column owner share: resolved normal geometry and opening eligibility. */ +export interface RightbarOwnerProps { + /** Resolved normal panel width in px, not the saved preference; zero if it cannot fit. */ + width: number + /** Current frame width in px. */ + viewportWidth: number + /** + * Whether a normal right panel can retain 300px beside a 400px center. + * Before a narrow opening, includes the space from collapsing the left sidebar. + */ + canShow: boolean +} /** Required services (cordis fiber inject — the loader passes all module exports as an object plugin). */ export const inject = ['slots', 'theme', 'locale'] @@ -126,7 +140,7 @@ export function apply(ctx: ClientContext): void { children: { 'sidebar': { kind: 'single', scope: 'root' }, 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, + 'rightbar': { kind: 'single', scope: 'session' }, 'shell.overlay': { kind: 'list', scope: 'root' }, }, // Exclusive store: the factory itself — the framework instantiates per diff --git a/packages/client/ui-layout/src/client/service.ts b/packages/client/ui-layout/src/client/service.ts index 2b2e50e5d5..46369f0346 100644 --- a/packages/client/ui-layout/src/client/service.ts +++ b/packages/client/ui-layout/src/client/service.ts @@ -5,8 +5,8 @@ * the per-session active view dissolved into ui-conversation's session store * (its only consumer). What remains here is the contract other plugins' * apply worlds reach for panel transitions (sidebar toggle from ui-sidebar, - * details open/close from ui-conversation) — writes stay inside the store's - * declared action set, delivered as the registration's bound actions. + * right-panel show/hide from ui-sidebar-right) — writes stay inside the + * store's declared action set, delivered as the registration's bound actions. */ import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { createLayoutStore } from './stores.ts' @@ -23,10 +23,16 @@ export type PanelActions = BoundActions> export interface ILayout { /** Toggle the sidebar panel (closed ⟷ contract default width). */ toggleSidebar(): void - /** Open the details panel (no-op when already open). */ - openDetails(): void - /** Close the details panel. */ - closeDetails(): void + /** + * Report the right panel's presentation without changing its expanded state. + * @param track - whether the normal panel width reserves a grid track, + * including beneath a fullscreen overlay. + * @param fullscreen - whether the panel covers the frame and hides its outer + * resize handle; independent of the underlying grid track. + */ + openRightbar(track: boolean, fullscreen: boolean): void + /** Report the right panel as hidden: no track, no handle. */ + closeRightbar(): void } /** Cross-plugin panel-action face (ctx.layout). */ @@ -49,14 +55,14 @@ export class LayoutController implements ILayout { this.#require().toggleSidebar() } - /** Open the details panel (no-op when already open). */ - openDetails(): void { - this.#require().openDetails() + /** Report the right panel's track and fullscreen presentation. */ + openRightbar(track: boolean, fullscreen: boolean): void { + this.#require().openRightbar(track, fullscreen) } - /** Close the details panel. */ - closeDetails(): void { - this.#require().closeDetails() + /** Report the right panel as hidden: no track, no handle. */ + closeRightbar(): void { + this.#require().closeRightbar() } #require(): PanelActions { diff --git a/packages/client/ui-layout/src/client/stores.ts b/packages/client/ui-layout/src/client/stores.ts index 7b6e2807ee..9191745a66 100644 --- a/packages/client/ui-layout/src/client/stores.ts +++ b/packages/client/ui-layout/src/client/stores.ts @@ -1,26 +1,46 @@ /** - * The root entry's transient layout store: panel geometry as plain widths in - * px (0 = closed). Module level exports the factory only — a module-level - * handle would pin the store's identity in the module - * cache (a de-facto singleton surviving plugin reloads). register() receives - * the factory (exclusive use: the framework instantiates per entry), AppFrame - * derives its PropsStore share from the return type, and the service face - * receives the bound actions through the registration's inject hook. + * Root-owned frame measurement, panel preferences, and presentation reports. + * The registration supplies a fresh store and binds its actions to ctx.layout. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' import { - clampWidth, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN, - SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN, + clampWidth, RIGHTBAR_DEFAULT_RATIO, RIGHTBAR_MAX_RATIO, RIGHTBAR_MIN, + SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN, } from './columns.ts' /** - * Layout store state: panel width preferences in px (0 = closed), plus the - * narrow-viewport pair — `narrow` mirrors AppFrame's breakpoint reading - * (viewport < SIDEBAR_AUTO_COLLAPSE) so toggleSidebar can pick semantics, and - * `narrowExpanded` is the manual override that re-expands the auto-collapsed - * sidebar over the squeezed center without rewriting the width preference. + * Transient layout preferences. Responsive concessions never rewrite widths; + * the right panel's expanded state belongs to its occupant. */ -type LayoutState = { sidebar: number; details: number; narrow: boolean; narrowExpanded: boolean } +type LayoutState = { + sidebar: number + /** Last positive frame measurement; window width bootstraps the first render. */ + viewportWidth: number + narrowExpanded: boolean + /** + * Saved right panel width in px, or null before its first opening. Resizing + * the frame and closing the panel preserve this preference. + */ + rightbar: number | null + /** + * Whether the right panel is drawn at all, in either presentation. + * + * Derived chrome, not a source of truth: whether the right surface is + * expanded is a recorded fact owned by that surface, reported here so the + * frame can place the panel's resize handle. The occupant reports it; nothing + * else writes it. + */ + rightbarShown: boolean + /** + * Whether the normal panel width reserves a grid track, including beneath + * fullscreen. Reported by the occupant; always false while hidden. + */ + rightbarTrack: boolean + /** Reported fullscreen presentation; hides the outer resize handle. */ + rightbarFullscreen: boolean + /** Suppress transitions for a fullscreen exit until another geometry action. */ + rightbarInstant: boolean +} /** * Annotation twin of the actions literal below (the export needs a declared @@ -28,44 +48,76 @@ type LayoutState = { sidebar: number; details: number; narrow: boolean; narrowEx */ type LayoutActions = { setSidebar: (draft: LayoutState, px: number) => void - setDetails: (draft: LayoutState, px: number) => void toggleSidebar: (draft: LayoutState) => void - setNarrow: (draft: LayoutState, narrow: boolean) => void - openDetails: (draft: LayoutState) => void - closeDetails: (draft: LayoutState) => void + setViewportWidth: (draft: LayoutState, width: number) => void + setRightbar: (draft: LayoutState, px: number) => void + openRightbar: (draft: LayoutState, track: boolean, fullscreen: boolean) => void + closeRightbar: (draft: LayoutState) => void } /** - * Create the layout panel store handle. The preference IS the width, so - * closing a panel forgets its drag width — reopening restores the contract - * default. Actions are the complete write set: drag writes clamp - * into the panel's contract range and never cross the open/closed line; - * open/close transitions write 0 / the default explicitly. Below the - * auto-collapse breakpoint (AppFrame feeds setNarrow) the sidebar toggle - * flips the narrowExpanded override instead of the preference. + * Create the layout panel store handle. For the sidebar the preference IS the + * width, so closing it forgets its drag width — reopening restores the contract + * default. The right panel initializes at 45% of the frame on first opening + * and keeps that px preference across resizes and close. Drag writes clamp to + * the current frame's range. Narrow sidebar toggles change only the expansion + * override; opening the right panel clears that override. * @returns the store handle (spec + type + identity + factory in one). */ export function createLayoutStore(): EngineStoreHandle { const handle = defineStore({ - init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0, narrow: false, narrowExpanded: false }), + init: (): LayoutState => ({ + sidebar: SIDEBAR_DEFAULT, + viewportWidth: window.innerWidth, + narrowExpanded: false, + rightbar: null, + rightbarShown: false, + rightbarTrack: false, + rightbarFullscreen: false, + rightbarInstant: false, + }), actions: { - setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) }, - setDetails: (d, px: number) => { d.details = clampWidth(px, DETAILS_MIN, DETAILS_MAX) }, + setSidebar: (d, px: number) => { + d.rightbarInstant = false + d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) + }, // Narrow toggles flip only the override: the width preference survives // untouched, so re-widening restores the pre-squeeze layout. toggleSidebar: (d) => { - if (d.narrow) d.narrowExpanded = !d.narrowExpanded + d.rightbarInstant = false + if (d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.narrowExpanded = !d.narrowExpanded else d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0 }, // Crossing the breakpoint in either direction drops the override: the // narrow default is auto-collapsed, the wide state is the preference. - setNarrow: (d, narrow: boolean) => { - if (d.narrow === narrow) return - d.narrow = narrow - d.narrowExpanded = false + setViewportWidth: (d, width: number) => { + if (d.viewportWidth === width) return + d.rightbarInstant = false + if ((d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) !== (width < SIDEBAR_AUTO_COLLAPSE)) { + d.narrowExpanded = false + } + d.viewportWidth = width + }, + setRightbar: (d, px: number) => { + d.rightbarInstant = false + d.rightbar = clampWidth(px, RIGHTBAR_MIN, Math.max(RIGHTBAR_MIN, d.viewportWidth * RIGHTBAR_MAX_RATIO)) + }, + openRightbar: (d, track: boolean, fullscreen: boolean) => { + if (!d.rightbarShown || d.rightbarTrack !== track || d.rightbarFullscreen !== fullscreen) { + d.rightbarInstant = d.rightbarFullscreen && !fullscreen + } + if (!d.rightbarShown && d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.narrowExpanded = false + d.rightbar ??= Math.max(RIGHTBAR_MIN, Math.round(d.viewportWidth * RIGHTBAR_DEFAULT_RATIO)) + d.rightbarShown = true + d.rightbarTrack = track + d.rightbarFullscreen = fullscreen + }, + closeRightbar: (d) => { + if (d.rightbarShown) d.rightbarInstant = d.rightbarFullscreen + d.rightbarShown = false + d.rightbarTrack = false + d.rightbarFullscreen = false }, - openDetails: (d) => { if (d.details === 0) d.details = DETAILS_DEFAULT }, - closeDetails: (d) => { d.details = 0 }, }, }) return handle diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx index a640480360..62623b9b81 100644 --- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx @@ -1,436 +1,574 @@ // @vitest-environment jsdom -/** - * AppFrame interaction spec under the four-share props form: real layout - * store instance (createLayoutStore().create() — the test-sanctioned engine - * path), a recording renderSlot stub, and a SessionProvider component stub - * (the real one is framework-wired to the renderer host; its own behavior is - * ui-renderer's spec territory). Drag sequences (pointer capture + rAF flush), - * concession response to viewport change, and details staying mounted at - * zero width are the preserved behavior assertions. jsdom has no layout - * engine, so the frame width comes from a mocked getBoundingClientRect and - * resizes are driven through the ResizeObserver stub. - */ +/** Frame interactions with a real store and explicitly driven browser measurements. */ +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' import { useSyncExternalStore } from 'react' -import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx' -import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx' -import { SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts' -import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts' +import { AppFrame } from '../src/client/AppFrame.tsx' +import type { AppFrameProps } from '../src/client/AppFrame.tsx' +import type { RightbarOwnerProps, SidebarOwnerProps } from '../src/client/index.ts' +import { createLayoutStore } from '../src/client/stores.ts' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -// Session selection controls for the SessionProvider and useSessions stubs. -const selectedSession = { current: 's-test' as SessionId | undefined } -const selectedSessionBlank = { current: false } -const selectedSessionTitle = { current: undefined as string | undefined } -const workspacesReady = { current: true } +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +let selectedSession: SessionId | undefined +let selectedSessionTitle: string | undefined +let workspacesReady = true type AttentionSnapshot = Parameters[0]>[0] const noAttention: AttentionSnapshot = new Map() const useSessionPendingInteraction: AppFrameProps['useSessionPendingInteraction'] = selector => selector(noAttention) - -// Provider contract stub fed through the standard seat prop (the renderer -// injects the real one in production): session mode renders children and -// empty mode runs the empty branch. const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) => - selectedSession.current === undefined ? <>{empty?.() ?? null} : <>{children} + selectedSession === undefined ? <>{empty?.() ?? null} : <>{children} - -/** Observer stub: captures the callback so tests can fire resizes manually. */ -let fireResize: (() => void) | null = null +let observers: ResizeObserverStub[] class ResizeObserverStub { - #cb: ResizeObserverCallback - constructor(cb: ResizeObserverCallback) { this.#cb = cb } - observe(): void { fireResize = () => { this.#cb([], this) } } + disconnected = false + constructor(private callback: ResizeObserverCallback) { observers.push(this) } + observe(): void {} unobserve(): void {} - disconnect(): void { fireResize = null } + disconnect(): void { this.disconnected = true } + fire(): void { this.callback([], this) } } -let frameWidth = 1920 +let frameWidth: number +let animationFrames: Map +let nextFrame: number +let originalTitle: string +const restoreProperties: (() => void)[] = [] + +function replaceProperty(target: T, key: K, value: T[K]): void { + const descriptor = Object.getOwnPropertyDescriptor(target, key) + restoreProperties.push(() => { + if (descriptor === undefined) Reflect.deleteProperty(target, key) + else Object.defineProperty(target, key, descriptor) + }) + Object.defineProperty(target, key, { configurable: true, writable: true, value }) +} + +/** Flush one browser frame without depending on the worker's timer cadence. */ +function flushFrames(): void { + for (const [id, callback] of [...animationFrames]) { + if (!animationFrames.delete(id)) continue + callback(0) + } +} + +function resize(width: number): void { + frameWidth = width + act(() => { + for (const observer of observers) if (!observer.disconnected) observer.fire() + flushFrames() + }) +} -/** Test-local selector hook over a framework-neutral store instance. */ function hookOf(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) { return function useSelector(sel: (s: T) => S): S { return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) } } -function mountFrame() { - window.innerWidth = frameWidth // first-render viewport source before the observer fires +function mountFrame(windowWidth = frameWidth) { + vi.stubGlobal('innerWidth', windowWidth) const instance = createLayoutStore().create() - const slotCalls: { key: string; props: unknown }[] = [] + const slotCalls: { key: string; props: object }[] = [] const renderSlot = ((key: string, owner: object) => { slotCalls.push({ key, props: owner }) - if (key === 'sidebar') return
- if (key === 'conversation') return
- if (key === 'details') return
- if (key === 'conversation.empty') return
- return
+ return
}) as AppFrameProps['renderSlot'] - const useSessions = ((sel: (s: SessionListState) => unknown) => { - const current = selectedSession.current - const sessionState = { - ids: current === undefined ? [] : [current], - byId: current === undefined - ? {} - : { - [current]: { - id: current, - displayTitle: 'Test', - running: false, - blank: selectedSessionBlank.current, - updatedAt: 1, - ...(selectedSessionTitle.current === undefined ? {} : { title: selectedSessionTitle.current }), - }, - }, - current, - phase: 'ready', - } as SessionListState - return sel(sessionState) - }) as never + const useSessions = ((sel: (s: SessionListState) => unknown) => sel({ + ids: selectedSession === undefined ? [] : [selectedSession], + byId: selectedSession === undefined ? {} : { + [selectedSession]: { + id: selectedSession, displayTitle: 'Test', running: false, blank: false, updatedAt: 1, + ...(selectedSessionTitle === undefined ? {} : { title: selectedSessionTitle }), + }, + }, + current: selectedSession, + phase: 'ready', + } as SessionListState)) as AppFrameProps['useSessions'] const workspaceState: WorkspaceSnapshot = { items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - ...(workspacesReady.current ? {} : { state: 'loading' as const, phase: 'pending' as const }), + ...(workspacesReady ? {} : { state: 'loading' as const, phase: 'pending' as const }), } + const useStore = hookOf(instance) const element = () => ( unknown) => sel(workspaceState)) as never} + useResource={useResource} + useWorkspaces={((sel: (s: WorkspaceSnapshot) => unknown) => sel(workspaceState)) as AppFrameProps['useWorkspaces']} SessionProvider={SessionProviderStub} t={key => key === 'brand.localBuild' ? 'DSH Local Build' : key} /> ) const utils = render(element()) const frame = utils.container.firstElementChild as HTMLElement - return { instance, frame, slotCalls, rerenderFrame: () => { utils.rerender(element()) }, ...utils } + return { + ...utils, instance, frame, slotCalls, + rerenderFrame: () => { utils.rerender(element()) }, + rightOwner: () => slotCalls.findLast(c => c.key === 'rightbar')!.props as RightbarOwnerProps, + sidebarOwner: () => slotCalls.findLast(c => c.key === 'sidebar')!.props as SidebarOwnerProps, + } } function tracks(frame: HTMLElement): number[] { - const m = /^(\d+)px minmax\(0, 1fr\) (\d+)px$/.exec(frame.style.gridTemplateColumns) - if (m === null) throw new Error(`unexpected template: ${frame.style.gridTemplateColumns}`) - return [Number(m[1]), Number(m[2])] + const match = /^([\d.]+)px minmax\(0, 1fr\) ([\d.]+)px$/.exec(frame.style.gridTemplateColumns) + if (match === null) throw new Error(`unexpected template: ${frame.style.gridTemplateColumns}`) + return [Number(match[1]), Number(match[2])] +} + +function handleFor(frame: HTMLElement, side: 'sidebar' | 'rightbar'): HTMLElement { + const handle = frame.querySelector(`[data-side="${side}"]`) + if (handle === null) throw new Error(`missing ${side} resize handle`) + return handle +} + +function pointer(handle: Element, type: string, clientX: number, pointerId = 1, button = 0): void { + act(() => { handle.dispatchEvent(new PointerEvent(type, { pointerId, clientX, button, bubbles: true })) }) } function drag(handle: Element, fromX: number, toX: number): void { - const down = new PointerEvent('pointerdown', { pointerId: 1, clientX: fromX, bubbles: true }) - const move = new PointerEvent('pointermove', { pointerId: 1, clientX: toX, bubbles: true }) - const up = new PointerEvent('pointerup', { pointerId: 1, clientX: toX, bubbles: true }) - act(() => { handle.dispatchEvent(down) }) - act(() => { handle.dispatchEvent(move); vi.advanceTimersByTime(20) }) - act(() => { handle.dispatchEvent(up) }) + pointer(handle, 'pointerdown', fromX) + pointer(handle, 'pointermove', toX) + act(flushFrames) + pointer(handle, 'pointerup', toX) } beforeEach(() => { + originalTitle = document.title frameWidth = 1920 - selectedSession.current = 's-test' as SessionId - selectedSessionBlank.current = false - selectedSessionTitle.current = undefined - workspacesReady.current = true - vi.useFakeTimers() + selectedSession = 's-test' as SessionId + selectedSessionTitle = undefined + workspacesReady = true + observers = [] + animationFrames = new Map() + nextFrame = 1 + vi.stubEnv('DSH_CLIENT_TITLE', undefined) vi.stubGlobal('ResizeObserver', ResizeObserverStub) - vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number) - vi.stubGlobal('cancelAnimationFrame', (h: number) => { clearTimeout(h) }) - window.innerWidth = frameWidth - Element.prototype.getBoundingClientRect = function () { - return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) } - } - // jsdom lacks pointer capture: emulate per-element so hasPointerCapture gates pass. - const captured = new WeakSet() - Element.prototype.setPointerCapture = function () { captured.add(this) } - Element.prototype.releasePointerCapture = function () { captured.delete(this) } - Element.prototype.hasPointerCapture = function () { return captured.has(this) } + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = nextFrame++ + animationFrames.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { animationFrames.delete(id) }) + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({ + width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, + x: 0, y: 0, toJSON: () => ({}), + })) + // jsdom has no pointer capture. Each element retains the actual pointer id, + // and teardown restores absent methods as well as existing descriptors. + const captured = new WeakMap() + replaceProperty(Element.prototype, 'setPointerCapture', function (this: Element, id: number) { captured.set(this, id) }) + replaceProperty(Element.prototype, 'releasePointerCapture', function (this: Element) { captured.delete(this) }) + replaceProperty(Element.prototype, 'hasPointerCapture', function (this: Element, id: number) { return captured.get(this) === id }) }) afterEach(() => { - cleanup() - document.title = '' - vi.useRealTimers() - vi.unstubAllGlobals() - vi.unstubAllEnvs() + try { + cleanup() + } finally { + for (const restore of restoreProperties.splice(0).reverse()) restore() + document.title = originalTitle + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.unstubAllEnvs() + } }) describe('AppFrame', () => { - it('localizes the product title when the build does not supply one', () => { + it('localizes the product title without a configured build title', () => { mountFrame() expect(document.title).toBe('DSH Local Build') }) - it('projects the selected durable Session title', () => { + it('follows the selected durable Session title', () => { vi.stubEnv('DSH_CLIENT_TITLE', 'Product') - selectedSessionTitle.current = 'First' + selectedSessionTitle = 'First' const { rerenderFrame } = mountFrame() expect(document.title).toBe('First — Product') - - selectedSessionTitle.current = 'Revised' - act(() => { rerenderFrame() }) + selectedSessionTitle = 'Revised' + rerenderFrame() expect(document.title).toBe('Revised — Product') - - selectedSession.current = undefined - act(() => { rerenderFrame() }) + selectedSession = undefined + rerenderFrame() expect(document.title).toBe('Product') }) - it('renders three tracks from store state', () => { - const { frame } = mountFrame() + it('renders owner props for the default sidebar and prospective right panel', () => { + const { frame, rightOwner, sidebarOwner, slotCalls } = mountFrame() expect(tracks(frame)).toEqual([280, 0]) - }) - - it('renders the session pair with empty owner shares (sessionId is framework-standard)', () => { - const { slotCalls, getByTestId } = mountFrame() - expect(getByTestId('center-content')).toBeTruthy() - expect(getByTestId('details-content')).toBeTruthy() - const keys = slotCalls.map(c => c.key) - expect(keys).toContain('conversation') - expect(keys).toContain('details') - expect(keys).not.toContain('conversation.empty') + expect(sidebarOwner()).toEqual({ collapsed: false, width: 280 }) + expect(rightOwner()).toEqual({ width: 864, viewportWidth: 1920, canShow: true }) expect(slotCalls.find(c => c.key === 'conversation')!.props).toEqual({}) - expect(slotCalls.find(c => c.key === 'details')!.props).toEqual({}) }) - it('keeps the conversation slot mounted while no session is current', () => { - // No current session: the session-maybe conversation shell owns the New - // Session view itself — the center column renders it unconditionally. - selectedSession.current = undefined - const { slotCalls, getByTestId, queryByTestId } = mountFrame() - expect(getByTestId('center-content')).toBeTruthy() - expect(slotCalls.map(c => c.key)).toContain('conversation') - expect(queryByTestId('details-content')).toBeNull() - expect(slotCalls.map(c => c.key)).toContain('details') - }) - - it('renders both column occupants before baselines settle (no loading gate)', () => { - // No loading gate: a bare loading status reads worse than the shell's own - // pending rendering — both occupants mount from first paint. - workspacesReady.current = false - const { slotCalls } = mountFrame() - expect(slotCalls.map(c => c.key)).toContain('conversation') - expect(slotCalls.map(c => c.key)).toContain('details') - }) - - it('ignores unselected states and closes only when the Session id changes', () => { - const { frame, instance, rerenderFrame } = mountFrame() - expect(tracks(frame)).toEqual([280, 0]) - - act(() => { instance.actions.openDetails() }) - expect(tracks(frame)).toEqual([280, 360]) - - selectedSession.current = 's-next' as SessionId - act(() => { rerenderFrame() }) - expect(tracks(frame)).toEqual([280, 0]) - - act(() => { instance.actions.openDetails() }) - selectedSession.current = 's-blank' as SessionId - selectedSessionBlank.current = true - act(() => { rerenderFrame() }) - expect(tracks(frame)).toEqual([280, 0]) - expect(instance.getSnapshot().details).toBe(360) - - selectedSession.current = 's-next' as SessionId - selectedSessionBlank.current = false - act(() => { rerenderFrame() }) - expect(tracks(frame)).toEqual([280, 360]) - - selectedSession.current = undefined - act(() => { rerenderFrame() }) - expect(tracks(frame)).toEqual([280, 0]) - selectedSession.current = 's-test' as SessionId - act(() => { rerenderFrame() }) - expect(tracks(frame)).toEqual([280, 0]) - }) - - it('keeps details closed when the first Session materializes', () => { - selectedSession.current = undefined - const { frame, instance, rerenderFrame } = mountFrame() - expect(tracks(frame)).toEqual([280, 0]) - expect(instance.getSnapshot().details).toBe(0) - - selectedSession.current = 's-first' as SessionId - act(() => { rerenderFrame() }) - expect(tracks(frame)).toEqual([280, 0]) - }) - - it('sidebar slot receives live concession output as owner props', () => { - const { slotCalls } = mountFrame() - expect(slotCalls.find(c => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 }) - }) - - it('sidebar drag widens through rAF-batched pointer moves', () => { - const { frame } = mountFrame() - const handles = frame.querySelectorAll('[class*="handle"]') - drag(handles[0]!, 280, 350) - expect(tracks(frame)[0]).toBe(350) - }) - - it('details drag widens leftward (negative dx grows the panel)', () => { - const { frame, instance } = mountFrame() - act(() => { instance.actions.openDetails() }) - const handles = frame.querySelectorAll('[class*="handle"]') - drag(handles[1]!, 1560, 1500) - expect(tracks(frame)[1]).toBe(420) - }) - - it('drag base is the rendered (concession-clamped) width, not the preference', () => { - frameWidth = 1250 // step-2 squeeze: details renders 330 while preference is 360 - const { frame, instance } = mountFrame() - act(() => { instance.actions.openDetails() }) - expect(tracks(frame)).toEqual([280, 330]) - const handles = frame.querySelectorAll('[class*="handle"]') - drag(handles[1]!, 920, 930) // shrink by 10 from the rendered width - expect(instance.getSnapshot().details).toBe(320) - }) - - it('details column stays mounted at zero width', () => { - const { frame, getByTestId } = mountFrame() - expect(tracks(frame)).toEqual([280, 0]) - expect(getByTestId('details-content')).toBeTruthy() - expect(frame.hasAttribute('data-details-collapsed')).toBe(true) - }) - - it('closed sidebar keeps its compact rail with mounted slot content and collapsed owner props', () => { - const { frame, instance, slotCalls, getByTestId } = mountFrame() - act(() => { instance.actions.toggleSidebar() }) - expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0]) + it('retains conversation and sidebar content without a current Session', () => { + selectedSession = undefined + const { frame, getByTestId, queryByTestId } = mountFrame() + expect(getByTestId('conversation-content')).toBeTruthy() expect(getByTestId('sidebar-content')).toBeTruthy() - expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true) - const lastSidebarCall = slotCalls.filter(c => c.key === 'sidebar').at(-1)! - expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED }) + expect(queryByTestId('rightbar-content')).toBeNull() + expect(tracks(frame)).toEqual([280, 0]) }) - it('viewport shrink triggers the concession chain via ResizeObserver', () => { - const { frame, instance } = mountFrame() - act(() => { instance.actions.openDetails() }) - frameWidth = 1250 - act(() => { fireResize?.(); vi.advanceTimersByTime(20) }) - expect(tracks(frame)).toEqual([280, 330]) - frameWidth = 1920 - act(() => { fireResize?.(); vi.advanceTimersByTime(20) }) - expect(tracks(frame)).toEqual([280, 360]) + it('renders both occupants before workspace baselines settle', () => { + workspacesReady = false + const { getByTestId } = mountFrame() + expect(getByTestId('conversation-content')).toBeTruthy() + expect(getByTestId('rightbar-content')).toBeTruthy() }) - it('drag handles disappear for collapsed columns', () => { - const { frame, instance } = mountFrame() - expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1) - act(() => { instance.actions.openDetails() }) - expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(2) - act(() => { instance.actions.closeDetails() }) - expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1) + it('keeps the closed sidebar mounted at its 56px rail without a handle', () => { + const { frame, instance, sidebarOwner, getByTestId } = mountFrame() act(() => { instance.actions.toggleSidebar() }) - expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(0) + expect(tracks(frame)).toEqual([56, 0]) + expect(sidebarOwner()).toEqual({ collapsed: true, width: 56 }) + expect(getByTestId('sidebar-content')).toBeTruthy() + expect(frame.querySelector('[data-side="sidebar"]')).toBeNull() }) }) -describe('AppFrame — narrow-viewport auto-collapse', () => { - it('mounts collapsed below the breakpoint with no sidebar handle', () => { - frameWidth = 980 - const { frame, slotCalls } = mountFrame() - expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0]) - expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true) - expect(slotCalls.filter(c => c.key === 'sidebar').at(-1)!.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED }) - expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(0) +describe('AppFrame normal width concessions', () => { + it('measures the frame, not the window, before choosing the first-open preference', () => { + frameWidth = 1000 + const { instance, rightOwner } = mountFrame(1920) + expect(instance.getSnapshot().viewportWidth).toBe(1000) + expect(rightOwner()).toEqual({ width: 450, viewportWidth: 1000, canShow: true }) + act(() => { instance.actions.openRightbar(true, false) }) + expect(instance.getSnapshot().rightbar).toBe(450) + resize(1920) + expect(rightOwner().width).toBe(450) }) - it('narrow toggle re-expands over the squeezed center and back', () => { - frameWidth = 980 - const { frame, instance } = mountFrame() + it('shrinks the right panel to 300px, drops its track, and only then squeezes center', () => { + const { frame, instance, rightOwner } = mountFrame() + act(() => { instance.actions.setSidebar(420); instance.actions.openRightbar(true, false) }) + resize(1200) + expect(tracks(frame)).toEqual([420, 380]) + expect(rightOwner()).toEqual({ width: 380, viewportWidth: 1200, canShow: true }) + resize(1120) + expect(tracks(frame)).toEqual([420, 300]) + resize(1119) + expect(tracks(frame)).toEqual([420, 0]) + expect(rightOwner()).toEqual({ width: 0, viewportWidth: 1119, canShow: false }) + expect(frame.querySelector('[data-side="rightbar"]')).toBeNull() + expect(instance.getSnapshot()).toMatchObject({ rightbarShown: true, rightbar: 864 }) + act(() => { instance.actions.closeRightbar() }) + resize(455) + expect(tracks(frame)).toEqual([56, 0]) + resize(1920) + expect(tracks(frame)).toEqual([420, 0]) + }) + + it('uses the post-collapse left rail to permit a narrow first opening', () => { + frameWidth = 800 + const { frame, instance, rightOwner } = mountFrame() act(() => { instance.actions.toggleSidebar() }) expect(tracks(frame)).toEqual([280, 0]) - expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(false) - expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1) - act(() => { instance.actions.toggleSidebar() }) - expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0]) + expect(rightOwner()).toEqual({ width: 344, viewportWidth: 800, canShow: true }) + act(() => { instance.actions.openRightbar(true, false) }) + expect(tracks(frame)).toEqual([56, 344]) + expect(instance.getSnapshot()).toMatchObject({ narrowExpanded: false, rightbar: 360 }) + expect(rightOwner().canShow).toBe(true) }) - it('a wide-closed preference re-expands at the contract default while narrow', () => { - frameWidth = 1920 - const { frame, instance } = mountFrame() - act(() => { instance.actions.toggleSidebar() }) // close while wide: preference 0 - frameWidth = 980 - act(() => { fireResize?.(); vi.advanceTimersByTime(20) }) + it.each([[756, 300, true], [755, 0, false]] as const)('reports eligibility at %ipx', (width, rightbar, canShow) => { + frameWidth = width + const { instance, rightOwner } = mountFrame() act(() => { instance.actions.toggleSidebar() }) - expect(tracks(frame)).toEqual([280, 0]) - expect(instance.getSnapshot().sidebar).toBe(0) // preference untouched + expect(rightOwner()).toEqual({ width: rightbar, viewportWidth: width, canShow }) }) - it('shrinking across the breakpoint auto-collapses; re-widening restores the drag width', () => { + it('does not anticipate another left collapse after the right panel is already shown', () => { + frameWidth = 800 + const { instance, rightOwner } = mountFrame() + act(() => { instance.actions.openRightbar(true, false); instance.actions.toggleSidebar() }) + expect(rightOwner().canShow).toBe(false) + }) + + it('auto-collapses only below 1024px and preserves the wide sidebar preference', () => { const { frame, instance } = mountFrame() act(() => { instance.actions.setSidebar(400) }) - frameWidth = 980 - act(() => { fireResize?.(); vi.advanceTimersByTime(20) }) - expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0]) - frameWidth = 1920 - act(() => { fireResize?.(); vi.advanceTimersByTime(20) }) - expect(tracks(frame)).toEqual([400, 0]) + resize(1024) + expect(tracks(frame)[0]).toBe(400) + resize(1023) + expect(tracks(frame)[0]).toBe(56) + act(() => { instance.actions.toggleSidebar() }) + expect(tracks(frame)[0]).toBe(400) + resize(980) + expect(tracks(frame)[0]).toBe(400) + act(() => { instance.actions.toggleSidebar() }) + expect(tracks(frame)[0]).toBe(56) + resize(1920) + expect(tracks(frame)[0]).toBe(400) + }) + + it('re-expands a wide-closed sidebar at the default width while narrow', () => { + const { frame, instance } = mountFrame() + act(() => { instance.actions.toggleSidebar() }) + resize(980) + act(() => { instance.actions.toggleSidebar() }) + expect(tracks(frame)[0]).toBe(280) + expect(instance.getSnapshot().sidebar).toBe(0) }) }) -describe('AppFrame — guard branches', () => { - it('pointer moves without capture are ignored (no width write)', () => { +describe('AppFrame right panel presentation', () => { + it('releases the fullscreen track with the instant marker while clearing fullscreen', () => { const { frame, instance } = mountFrame() - const handle = frame.querySelectorAll('[class*="handle"]')[0]! - const before = instance.getSnapshot().sidebar - // Move + up without a preceding pointerdown: hasPointerCapture is false. - act(() => { - handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 9, clientX: 500, bubbles: true })) - vi.advanceTimersByTime(20) - handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 9, clientX: 500, bubbles: true })) - }) - expect(instance.getSnapshot().sidebar).toBe(before) + act(() => { instance.actions.openRightbar(true, true) }) + expect(tracks(frame)).toEqual([280, 864]) + act(() => { instance.actions.closeRightbar() }) + expect(tracks(frame)).toEqual([280, 0]) + expect(frame.dataset.rightbarFullscreen).toBeUndefined() + expect(frame.dataset.rightbarInstant).toBe('true') + expect(frame.querySelector('[data-side="rightbar"]')).toBeNull() + act(() => { instance.actions.closeRightbar() }) + expect(frame.dataset.rightbarInstant).toBe('true') }) - it('two moves inside one frame coalesce through the pending rAF', () => { + it('marks restoration instant without suppressing the following normal close', () => { const { frame, instance } = mountFrame() - const handle = frame.querySelectorAll('[class*="handle"]')[0]! - act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) }) - act(() => { - // Two moves before the frame flushes: the second must ride the pending - // rAF (frame.current ??= guard), and the flush sees the latest x. - handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 320, bubbles: true })) - handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 340, bubbles: true })) - vi.advanceTimersByTime(20) - }) - act(() => { handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 340, bubbles: true })) }) - expect(instance.getSnapshot().sidebar).toBe(340) + act(() => { instance.actions.openRightbar(true, true) }) + act(() => { instance.actions.openRightbar(true, false) }) + expect(tracks(frame)).toEqual([280, 864]) + expect(frame.dataset.rightbarFullscreen).toBeUndefined() + expect(frame.dataset.rightbarInstant).toBe('true') + act(() => { instance.actions.closeRightbar() }) + expect(tracks(frame)).toEqual([280, 0]) + expect(frame.dataset.rightbarFullscreen).toBeUndefined() + expect(frame.dataset.rightbarInstant).toBeUndefined() }) - it('pointerup with a pending rAF cancels it and commits the final position', () => { + it.each(['setSidebar', 'toggleSidebar', 'setRightbar', 'viewport', 'open'] as const)('reenables normal transitions after %s', (action) => { const { frame, instance } = mountFrame() - const handle = frame.querySelectorAll('[class*="handle"]')[0]! - act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) }) + act(() => { instance.actions.openRightbar(true, true); instance.actions.closeRightbar() }) + expect(frame.dataset.rightbarInstant).toBe('true') act(() => { - handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 360, bubbles: true })) - // No timer advance: the rAF is still pending when pointerup arrives. - handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 360, bubbles: true })) + if (action === 'viewport') resize(1800) + else if (action === 'open') instance.actions.openRightbar(true, false) + else if (action === 'toggleSidebar') instance.actions.toggleSidebar() + else instance.actions[action](350) }) + expect(frame.dataset.rightbarInstant).toBeUndefined() + expect(frame.dataset.rightbarFullscreen).toBeUndefined() + }) + + it('keeps fullscreen suppression independent from resetting the instant marker', () => { + const { frame, instance } = mountFrame() + act(() => { instance.actions.openRightbar(true, true) }) + act(() => { instance.actions.setSidebar(350) }) + expect(frame.dataset.rightbarInstant).toBeUndefined() + expect(frame.dataset.rightbarFullscreen).toBe('true') + }) + + it('preserves normal tracks through fullscreen and hides the outer resize handle', () => { + const { frame, instance, rightOwner } = mountFrame() + act(() => { instance.actions.openRightbar(true, false) }) + expect(tracks(frame)).toEqual([280, 864]) + expect(handleFor(frame, 'rightbar').style.left).toBe('1056px') + act(() => { instance.actions.openRightbar(true, true) }) + expect(tracks(frame)).toEqual([280, 864]) + expect(rightOwner().width).toBe(864) + expect(frame.dataset.rightbarFullscreen).toBe('true') + expect(frame.querySelector('[data-side="rightbar"]')).toBeNull() + act(() => { instance.actions.openRightbar(true, false) }) + expect(tracks(frame)).toEqual([280, 864]) + expect(handleFor(frame, 'rightbar').style.left).toBe('1056px') + expect(frame.dataset.rightbarFullscreen).toBeUndefined() + act(() => { instance.actions.closeRightbar() }) + expect(tracks(frame)).toEqual([280, 0]) + expect(frame.querySelector('[data-side="rightbar"]')).toBeNull() + expect(frame.dataset.rightbarFullscreen).toBeUndefined() + }) + + it('inserts a fullscreen track and its transition-suppression marker in the same render', () => { + const { frame, instance } = mountFrame() + expect(tracks(frame)).toEqual([280, 0]) + expect(frame.dataset.rightbarFullscreen).toBeUndefined() + act(() => { instance.actions.openRightbar(true, true) }) + expect(tracks(frame)).toEqual([280, 864]) + expect(frame.dataset.rightbarFullscreen).toBe('true') + expect(frame.querySelector('[data-side="rightbar"]')).toBeNull() + act(() => { instance.actions.openRightbar(true, false) }) + expect(tracks(frame)).toEqual([280, 864]) + expect(frame.dataset.rightbarFullscreen).toBeUndefined() + }) + + it('retains fullscreen without a track when normal columns cannot fit', () => { + frameWidth = 700 + const { frame, instance, rightOwner } = mountFrame() + act(() => { instance.actions.openRightbar(false, true) }) + expect(tracks(frame)).toEqual([56, 0]) + expect(rightOwner()).toEqual({ width: 0, viewportWidth: 700, canShow: false }) + expect(instance.getSnapshot().rightbarShown).toBe(true) + expect(frame.querySelector('[data-side="rightbar"]')).toBeNull() + }) + + it('keeps resolved panel width independent of the requested track', () => { + const { frame, instance, rightOwner } = mountFrame() + act(() => { instance.actions.openRightbar(false, false) }) + resize(1100) + expect(tracks(frame)).toEqual([280, 0]) + expect(rightOwner().width).toBe(420) + drag(handleFor(frame, 'rightbar'), 680, 690) + expect(instance.getSnapshot().rightbar).toBe(410) + expect(rightOwner().width).toBe(410) + expect(tracks(frame)[1]).toBe(0) + }) +}) + +describe('AppFrame pointer resizing', () => { + it('updates columns during the gesture and freezes the drag-start width', () => { + const { frame, instance } = mountFrame() + const handle = handleFor(frame, 'sidebar') + pointer(handle, 'pointerdown', 280) + expect(frame.dataset.dragging).toBe('true') + pointer(handle, 'pointermove', 320) + pointer(handle, 'pointermove', 340) + expect(animationFrames.size).toBe(1) + act(flushFrames) + expect(tracks(frame)[0]).toBe(340) + pointer(handle, 'pointermove', 360) + act(flushFrames) + expect(tracks(frame)[0]).toBe(360) + pointer(handle, 'pointerup', 360) + expect(instance.getSnapshot().sidebar).toBe(360) + expect(frame.dataset.dragging).toBeUndefined() + expect(handle.hasPointerCapture(1)).toBe(false) + }) + + it('starts a conceded right drag at its actual width, shared by panel and track', () => { + const { frame, instance, rightOwner } = mountFrame() + act(() => { instance.actions.openRightbar(true, false) }) + resize(1100) + const handle = handleFor(frame, 'rightbar') + expect(rightOwner().width).toBe(420) + expect(tracks(frame)[1]).toBe(420) + expect(handle.style.left).toBe('680px') + drag(handle, 680, 690) + expect(instance.getSnapshot().rightbar).toBe(410) + expect(rightOwner().width).toBe(410) + expect(tracks(frame)[1]).toBe(410) + expect(handle.style.left).toBe('690px') + }) + + it('widens to the 70% limit and shrinks to 300px through pointer input', () => { + frameWidth = 3000 + const { frame, instance, rightOwner } = mountFrame() + act(() => { instance.actions.toggleSidebar(); instance.actions.openRightbar(true, false) }) + drag(handleFor(frame, 'rightbar'), 1650, 0) + expect(rightOwner().width).toBe(2100) + expect(tracks(frame)[1]).toBe(2100) + drag(handleFor(frame, 'rightbar'), 900, 3000) + expect(rightOwner().width).toBe(300) + expect(tracks(frame)[1]).toBe(300) + }) + + it('commits the pointerup coordinate and cancels its pending animation frame', () => { + const { frame, instance } = mountFrame() + const handle = handleFor(frame, 'sidebar') + pointer(handle, 'pointerdown', 280) + pointer(handle, 'pointermove', 320) + pointer(handle, 'pointerup', 360) + expect(instance.getSnapshot().sidebar).toBe(360) + expect(animationFrames.size).toBe(0) + act(flushFrames) expect(instance.getSnapshot().sidebar).toBe(360) }) - it('zero-width resize reports are ignored (display:none window)', () => { - const { frame } = mountFrame() - frameWidth = 0 - act(() => { fireResize?.(); vi.advanceTimersByTime(20) }) - // Track template still reflects the last non-zero viewport. - expect(tracks(frame)).toEqual([280, 0]) - }) -}) - -describe('AppFrame — unmount with an in-flight resize frame', () => { - it('cancels the pending rAF on unmount (no post-unmount setState)', () => { - const { unmount } = mountFrame() - frameWidth = 800 - act(() => { fireResize?.() }) // rAF scheduled, NOT flushed - unmount() - // Flushing after unmount must be a no-op (the frame was cancelled). - expect(() => { vi.advanceTimersByTime(20) }).not.toThrow() - }) - - it('double resize inside one frame rides the pending rAF (??= guard)', () => { + it('ignores uncaptured motion, secondary buttons, and a second pointer', () => { const { frame, instance } = mountFrame() - act(() => { instance.actions.openDetails() }) - frameWidth = 1250 - act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) }) - expect(tracks(frame)).toEqual([280, 330]) + const handle = handleFor(frame, 'sidebar') + pointer(handle, 'pointermove', 500, 9) + pointer(handle, 'pointerup', 500, 9) + pointer(handle, 'pointercancel', 500, 9) + pointer(handle, 'pointerdown', 500, 9, 2) + expect(frame.dataset.dragging).toBeUndefined() + pointer(handle, 'pointerdown', 280) + pointer(handle, 'pointerdown', 500, 9) + pointer(handle, 'pointermove', 500, 9) + pointer(handle, 'pointerup', 500, 9) + expect(animationFrames.size).toBe(0) + expect(instance.getSnapshot().sidebar).toBe(280) + pointer(handle, 'pointerup', 300) + expect(instance.getSnapshot().sidebar).toBe(300) + }) + + it.each(['pointercancel', 'lostpointercapture'])('ends %s without committing queued motion', (event) => { + const { frame, instance } = mountFrame() + const handle = handleFor(frame, 'sidebar') + pointer(handle, 'pointerdown', 280) + pointer(handle, 'pointermove', 340) + if (event === 'lostpointercapture') handle.releasePointerCapture(1) + pointer(handle, event, 340) + act(flushFrames) + expect(instance.getSnapshot().sidebar).toBe(280) + expect(animationFrames.size).toBe(0) + expect(frame.dataset.dragging).toBeUndefined() + expect(handle.hasPointerCapture(1)).toBe(false) + }) + + it.each(['fullscreen', 'close', 'unmount'])('cancels a pending drag on %s', (change) => { + const { frame, instance, unmount } = mountFrame() + act(() => { instance.actions.openRightbar(true, false) }) + const handle = handleFor(frame, 'rightbar') + pointer(handle, 'pointerdown', 1056) + pointer(handle, 'pointermove', 1000) + act(() => { + if (change === 'fullscreen') instance.actions.openRightbar(true, true) + else if (change === 'close') instance.actions.closeRightbar() + else unmount() + }) + const settled = instance.getSnapshot() + act(flushFrames) + expect(instance.getSnapshot()).toBe(settled) + expect(instance.getSnapshot().rightbar).toBe(864) + expect(animationFrames.size).toBe(0) + expect(handle.hasPointerCapture(1)).toBe(false) + if (change !== 'unmount') expect(frame.dataset.dragging).toBeUndefined() + }) +}) + +describe('AppFrame frame measurement lifecycle', () => { + it('coalesces observer reports and publishes the latest frame measurement', () => { + const { instance, rightOwner } = mountFrame() + const observer = observers.at(-1)! + act(() => { + frameWidth = 900 + observer.fire() + frameWidth = 1200 + observer.fire() + }) + expect(animationFrames.size).toBe(1) + expect(instance.getSnapshot().viewportWidth).toBe(1920) + act(flushFrames) + expect(instance.getSnapshot().viewportWidth).toBe(1200) + expect(rightOwner().viewportWidth).toBe(1200) + expect(instance.getSnapshot().rightbar).toBeNull() + }) + + it('retains the last positive measurement while the frame is hidden', () => { + const { instance, rightOwner } = mountFrame() + resize(0) + expect(instance.getSnapshot().viewportWidth).toBe(1920) + expect(rightOwner().viewportWidth).toBe(1920) + }) + + it('disconnects the observer and prevents queued or late reports after unmount', () => { + const { instance, unmount } = mountFrame() + const observer = observers.at(-1)! + frameWidth = 800 + act(() => { observer.fire() }) + expect(animationFrames.size).toBe(1) + unmount() + expect(observer.disconnected).toBe(true) + expect(animationFrames.size).toBe(0) + act(() => { observer.fire(); flushFrames() }) + expect(instance.getSnapshot().viewportWidth).toBe(1920) + expect(animationFrames.size).toBe(0) }) }) diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts index da3056b5db..38732eda2c 100644 --- a/packages/client/ui-layout/tests/apply.client.spec.ts +++ b/packages/client/ui-layout/tests/apply.client.spec.ts @@ -43,7 +43,6 @@ describe('ui-layout client apply', () => { // …and declared the three children in the ledger. expect(slots.spec('sidebar')).toEqual({ kind: 'single', scope: 'root' }) expect(slots.spec('conversation')).toEqual({ kind: 'single', scope: 'session-maybe' }) - expect(slots.spec('details')).toEqual({ kind: 'single', scope: 'session' }) }) it('injects no business face and attaches the layout actions', async () => { diff --git a/packages/client/ui-layout/tests/columns.client.spec.ts b/packages/client/ui-layout/tests/columns.client.spec.ts index ae8c39a117..4278516f0e 100644 --- a/packages/client/ui-layout/tests/columns.client.spec.ts +++ b/packages/client/ui-layout/tests/columns.client.spec.ts @@ -1,12 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - CENTER_MIN, clampWidth, computeColumns, - DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT, SIDEBAR_MIN, -} from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts' - -// Numeric preference form (0 = closed); helpers keep the scenario names readable. -const open = (width: number) => width -const closed = (_width: number) => 0 +import { clampWidth, computeColumns } from '../src/client/columns.ts' describe('clampWidth', () => { it('clamps into the range and rounds', () => { @@ -17,79 +10,44 @@ describe('clampWidth', () => { }) describe('computeColumns', () => { - it('step 1: everything fits at preferred widths', () => { - const cols = computeColumns(1920, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT)) - expect(cols).toEqual({ sidebar: 280, center: 1920 - 280 - 360, details: 360 }) + it('gives each edge column its preference when the center has enough room', () => { + expect(computeColumns(1920, 280, 864)).toEqual({ sidebar: 280, center: 776, rightbar: 864 }) }) - it('closed sidebar keeps its compact rail while closed details contribute zero width', () => { - expect(computeColumns(1920, closed(300), closed(360))) - .toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 1920 - SIDEBAR_COLLAPSED, details: 0 }) + it('keeps only the left rail when both panels are closed', () => { + expect(computeColumns(1920, 0, 0)).toEqual({ sidebar: 56, center: 1864, rightbar: 0 }) }) - it('preferences beyond the clamp range are clamped before solving', () => { - const cols = computeColumns(1920, open(9999), open(1)) - expect(cols.sidebar).toBe(420) - expect(cols.details).toBe(300) - expect(computeColumns(1920, open(1), open(DETAILS_DEFAULT)).sidebar).toBe(SIDEBAR_MIN) + it('clamps sidebar preferences and limits the right panel to 70% of the frame', () => { + expect(computeColumns(3000, 9999, 9999)).toEqual({ sidebar: 420, center: 480, rightbar: 2100 }) + expect(computeColumns(1920, 1, 1)).toEqual({ sidebar: 264, center: 1356, rightbar: 300 }) }) - it('step 2: details shrinks first, center pinned at min', () => { - // 280 + 360 + 640 = 1280 > 1250; details concedes to 1250-280-640 = 330. - const cols = computeColumns(1250, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT)) - expect(cols).toEqual({ sidebar: 280, center: CENTER_MIN, details: 330 }) + it.each([ + [1300, 280, 620, 400], + [1100, 280, 420, 400], + [1120, 420, 300, 400], + [1119, 420, 0, 699], + [1024, 420, 0, 604], + [756, 0, 300, 400], + [755, 0, 0, 699], + [455, 0, 0, 399], + [20, 0, 0, 0], + ])('solves frame %i and sidebar %i to right %i and center %i', (viewport, sidebar, rightbar, center) => { + expect(computeColumns(viewport, sidebar, 864)).toEqual({ sidebar: sidebar || 56, center, rightbar }) }) - it('boundary: exactly at the step-1/step-2 seam', () => { - const cols = computeColumns(300 + 360 + CENTER_MIN, open(300), open(360)) - expect(cols).toEqual({ sidebar: 300, center: CENTER_MIN, details: 360 }) - const one = computeColumns(300 + 360 + CENTER_MIN - 1, open(300), open(360)) - expect(one).toEqual({ sidebar: 300, center: CENTER_MIN, details: 359 }) + it('does not reduce the wide sidebar to keep a normal right panel open', () => { + expect(computeColumns(1024, 420, 500)).toEqual({ sidebar: 420, center: 604, rightbar: 0 }) }) - it('step 3: details auto-closes when its min still starves center — sidebar holds its preference', () => { - // 280 + 300 + 640 = 1220 > 1210 → details 0; sidebar untouched: center = 1210-280 = 930. - const cols = computeColumns(1210, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT)) - expect(cols).toEqual({ sidebar: 280, center: 930, details: 0 }) + it('restores a still-open preference when the frame widens', () => { + expect(computeColumns(1100, 280, 864).rightbar).toBe(420) + expect(computeColumns(1920, 280, 864).rightbar).toBe(864) }) - it('the sidebar never concedes: center absorbs the deficit below CENTER_MIN', () => { - // 700 < 280+640: sidebar keeps 280, center takes 420 < CENTER_MIN. - const cols = computeColumns(700, open(SIDEBAR_DEFAULT), closed(DETAILS_DEFAULT)) - expect(cols).toEqual({ sidebar: SIDEBAR_DEFAULT, center: 420, details: 0 }) - }) - - it('sidebar-closed narrow window: details concedes then auto-closes', () => { - const fits = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN, closed(300), open(DETAILS_DEFAULT)) - expect(fits).toEqual({ sidebar: SIDEBAR_COLLAPSED, center: CENTER_MIN, details: DETAILS_MIN }) - const starved = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN - 1, closed(300), open(DETAILS_DEFAULT)) - expect(starved).toEqual({ - sidebar: SIDEBAR_COLLAPSED, - center: DETAILS_MIN + CENTER_MIN - 1, - details: 0, - }) - }) - - it('tiny viewport: details closes, sidebar holds, center takes the remainder', () => { - const cols = computeColumns(400, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT)) - expect(cols.details).toBe(0) - expect(cols.sidebar).toBe(SIDEBAR_DEFAULT) - expect(cols.center).toBe(Math.max(0, 400 - SIDEBAR_DEFAULT)) - }) - - it('recovery is pure: re-widening restores preferred widths untouched', () => { - const squeezed = computeColumns(1100, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT)) - expect(squeezed.details).toBe(0) - const restored = computeColumns(1920, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT)) - expect(restored.details).toBe(DETAILS_DEFAULT) - expect(restored.sidebar).toBe(SIDEBAR_DEFAULT) - }) -}) - -describe('computeColumns — degenerate viewports', () => { - it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes the rest', () => { - // Reaches step 3's auto-close with the compact rail sidebar. - expect(computeColumns(500, closed(300), open(DETAILS_DEFAULT))) - .toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 500 - SIDEBAR_COLLAPSED, details: 0 }) + it('leaves a closed right track closed when the frame widens', () => { + expect(computeColumns(755, 0, 0).rightbar).toBe(0) + expect(computeColumns(1920, 0, 0).rightbar).toBe(0) }) }) diff --git a/packages/client/ui-layout/tests/layout-store.client.spec.ts b/packages/client/ui-layout/tests/layout-store.client.spec.ts index c6f0069197..60dacf2f4b 100644 --- a/packages/client/ui-layout/tests/layout-store.client.spec.ts +++ b/packages/client/ui-layout/tests/layout-store.client.spec.ts @@ -1,103 +1,226 @@ // @vitest-environment jsdom -/** - * createLayoutStore unit account: init shape, the action write set (clamp - * inside actions), and the absence of browser persistence. Uses the - * test-sanctioned path: factory self-call + .create() gives the - * real engine instance (same create path as production). - */ -import { beforeEach, describe, expect, it } from 'vitest' -import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts' -import { - DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN, - SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN, -} from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts' -const PERSIST_KEY = 'dsh.layout.panels' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createLayoutStore } from '../src/client/stores.ts' -beforeEach(() => { localStorage.clear() }) +beforeEach(() => { vi.stubGlobal('innerWidth', 1920) }) +afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() }) describe('createLayoutStore', () => { - it('initializes the sidebar at its default width, details closed, wide viewport assumed', () => { + it('starts with the default sidebar and no right panel preference', () => { const { store } = createLayoutStore().create() - expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0, narrow: false, narrowExpanded: false }) + expect(store.getSnapshot()).toEqual({ + sidebar: 280, + viewportWidth: 1920, + narrowExpanded: false, + rightbar: null, + rightbarShown: false, + rightbarTrack: false, + rightbarFullscreen: false, + rightbarInstant: false, + }) }) - it('each create() is an independent instance (factory is not a singleton)', () => { + it('creates independent instances without browser persistence', () => { + const write = vi.spyOn(Storage.prototype, 'setItem') const a = createLayoutStore().create() const b = createLayoutStore().create() a.actions.setSidebar(400) - expect(b.store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT) + a.actions.openRightbar(true, false) + expect(b.store.getSnapshot().sidebar).toBe(280) + expect(b.store.getSnapshot().rightbar).toBeNull() + expect(write).not.toHaveBeenCalled() }) - it('setSidebar/setDetails clamp into the contract ranges', () => { + it('clamps the sidebar to 264–420px', () => { const { store, actions } = createLayoutStore().create() actions.setSidebar(1) - expect(store.getSnapshot().sidebar).toBe(SIDEBAR_MIN) + expect(store.getSnapshot().sidebar).toBe(264) actions.setSidebar(9999) - expect(store.getSnapshot().sidebar).toBe(SIDEBAR_MAX) - actions.setDetails(1) - expect(store.getSnapshot().details).toBe(DETAILS_MIN) - actions.setDetails(9999) - expect(store.getSnapshot().details).toBe(DETAILS_MAX) + expect(store.getSnapshot().sidebar).toBe(420) }) - it('toggleSidebar flips closed <-> contract default (drag width forgotten)', () => { + it('toggles the wide sidebar between closed and default width', () => { const { store, actions } = createLayoutStore().create() actions.setSidebar(400) actions.toggleSidebar() expect(store.getSnapshot().sidebar).toBe(0) actions.toggleSidebar() - expect(store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT) + expect(store.getSnapshot().sidebar).toBe(280) }) - it('narrow toggleSidebar flips only the re-expand override; the width preference survives', () => { + it('keeps the sidebar preference while toggling its narrow override', () => { const { store, actions } = createLayoutStore().create() actions.setSidebar(400) - actions.setNarrow(true) + actions.setViewportWidth(980) actions.toggleSidebar() - expect(store.getSnapshot()).toEqual({ sidebar: 400, details: 0, narrow: true, narrowExpanded: true }) + expect(store.getSnapshot()).toMatchObject({ sidebar: 400, viewportWidth: 980, narrowExpanded: true }) actions.toggleSidebar() - expect(store.getSnapshot().narrowExpanded).toBe(false) - expect(store.getSnapshot().sidebar).toBe(400) + expect(store.getSnapshot()).toMatchObject({ sidebar: 400, narrowExpanded: false }) }) - it('crossing the breakpoint drops the override; a same-value setNarrow keeps it', () => { + it('clears the manual override only when crossing 1024px', () => { const { store, actions } = createLayoutStore().create() - actions.setNarrow(true) + actions.setViewportWidth(980) actions.toggleSidebar() + actions.setViewportWidth(980) + actions.setViewportWidth(1023) expect(store.getSnapshot().narrowExpanded).toBe(true) - actions.setNarrow(true) - expect(store.getSnapshot().narrowExpanded).toBe(true) - actions.setNarrow(false) - expect(store.getSnapshot()).toMatchObject({ narrow: false, narrowExpanded: false }) - actions.setNarrow(true) + actions.setViewportWidth(1024) + expect(store.getSnapshot().narrowExpanded).toBe(false) + actions.setViewportWidth(980) expect(store.getSnapshot().narrowExpanded).toBe(false) }) +}) - it('openDetails uses the contract default, preserves an open width, and closeDetails zeroes', () => { - const { store, actions } = createLayoutStore().create() - actions.openDetails() - expect(store.getSnapshot().details).toBe(DETAILS_DEFAULT) - actions.setDetails(500) - actions.openDetails() - expect(store.getSnapshot().details).toBe(500) - actions.closeDetails() - expect(store.getSnapshot().details).toBe(0) - }) - - it('does not persist panel geometry', () => { - const first = createLayoutStore().create() - first.actions.setSidebar(400) - first.actions.openDetails() - first.actions.setDetails(500) - expect(localStorage.getItem(PERSIST_KEY)).toBeNull() - - const second = createLayoutStore().create() - expect(second.store.getSnapshot()).toEqual({ - sidebar: SIDEBAR_DEFAULT, - details: 0, - narrow: false, - narrowExpanded: false, +describe('right panel', () => { + it('initializes at 45% of the latest frame only on first opening', () => { + const { store, actions } = createLayoutStore().create() + actions.setViewportWidth(1000) + expect(store.getSnapshot().rightbar).toBeNull() + actions.openRightbar(true, false) + expect(store.getSnapshot().rightbar).toBe(450) + actions.setViewportWidth(2000) + actions.openRightbar(true, true) + expect(store.getSnapshot().rightbar).toBe(450) + actions.closeRightbar() + actions.openRightbar(true, false) + expect(store.getSnapshot().rightbar).toBe(450) + }) + + it('keeps track and fullscreen reports independent and clears both on close', () => { + const { store, actions } = createLayoutStore().create() + actions.openRightbar(true, false) + expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false }) + actions.openRightbar(true, true) + expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: true }) + actions.openRightbar(false, true) + expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: false, rightbarFullscreen: true }) + actions.closeRightbar() + expect(store.getSnapshot()).toMatchObject({ rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false }) + }) + + it('keeps dragged px preferences across resize, close, and reopen', () => { + const { store, actions } = createLayoutStore().create() + actions.openRightbar(true, false) + actions.setRightbar(1100) + actions.setViewportWidth(800) + expect(store.getSnapshot().rightbar).toBe(1100) + actions.closeRightbar() + actions.openRightbar(false, true) + expect(store.getSnapshot().rightbar).toBe(1100) + }) + + it('clamps drag preferences to 300px and 70% of the current frame', () => { + const { store, actions } = createLayoutStore().create() + actions.setViewportWidth(1600) + actions.setRightbar(9999) + expect(store.getSnapshot().rightbar).toBe(1120) + actions.setViewportWidth(1000) + actions.setRightbar(9999) + expect(store.getSnapshot().rightbar).toBe(700) + actions.setRightbar(1) + expect(store.getSnapshot().rightbar).toBe(300) + }) + + it('retains a minimum normal preference when first opened fullscreen on a phone', () => { + const { store, actions } = createLayoutStore().create() + actions.setViewportWidth(320) + actions.openRightbar(false, true) + expect(store.getSnapshot().rightbar).toBe(300) + }) + + it('collapses a manually expanded narrow sidebar on opening, not presentation reports', () => { + const { store, actions } = createLayoutStore().create() + actions.setSidebar(400) + actions.setViewportWidth(800) + actions.toggleSidebar() + actions.openRightbar(true, false) + expect(store.getSnapshot()).toMatchObject({ sidebar: 400, narrowExpanded: false }) + actions.toggleSidebar() + actions.openRightbar(true, true) + expect(store.getSnapshot().narrowExpanded).toBe(true) + actions.closeRightbar() + actions.openRightbar(true, false) + expect(store.getSnapshot().narrowExpanded).toBe(false) + }) + + it('keeps the wide sidebar preference and never opens a closed right panel on resize', () => { + const { store, actions } = createLayoutStore().create() + actions.setSidebar(420) + actions.openRightbar(true, false) + expect(store.getSnapshot().sidebar).toBe(420) + actions.closeRightbar() + actions.setViewportWidth(3000) + expect(store.getSnapshot()).toMatchObject({ sidebar: 420, rightbarShown: false, rightbarTrack: false }) + }) +}) + +describe('right panel instant geometry', () => { + it.each([true, false])('closes fullscreen with track=%s in one instant update and retains repeated close reports', (track) => { + const { store, actions } = createLayoutStore().create() + actions.openRightbar(track, true) + actions.closeRightbar() + expect(store.getSnapshot()).toMatchObject({ + rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false, rightbarInstant: true, + }) + const closed = store.getSnapshot() + actions.closeRightbar() + expect(store.getSnapshot()).toBe(closed) + }) + + it('restores the normal track instantly, retaining the marker on an identical report', () => { + const { store, actions } = createLayoutStore().create() + actions.openRightbar(true, true) + actions.openRightbar(true, false) + expect(store.getSnapshot()).toMatchObject({ + rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false, rightbarInstant: true, }) + const restored = store.getSnapshot() + actions.openRightbar(true, false) + expect(store.getSnapshot()).toBe(restored) + actions.openRightbar(false, false) + expect(store.getSnapshot().rightbarInstant).toBe(false) + }) + + it('allows a normal close to animate, including after restoring from fullscreen', () => { + const { store, actions } = createLayoutStore().create() + actions.openRightbar(true, false) + actions.closeRightbar() + expect(store.getSnapshot().rightbarInstant).toBe(false) + actions.openRightbar(true, true) + actions.openRightbar(true, false) + expect(store.getSnapshot().rightbarInstant).toBe(true) + actions.closeRightbar() + expect(store.getSnapshot()).toMatchObject({ rightbarTrack: false, rightbarInstant: false }) + }) + + it.each(['setSidebar', 'toggleSidebar', 'setRightbar', 'setViewportWidth'] as const)('clears instant geometry on %s', (action) => { + const { store, actions } = createLayoutStore().create() + actions.openRightbar(true, true) + actions.closeRightbar() + expect(store.getSnapshot().rightbarInstant).toBe(true) + if (action === 'toggleSidebar') actions.toggleSidebar() + else actions[action](action === 'setViewportWidth' ? 1800 : 350) + expect(store.getSnapshot().rightbarInstant).toBe(false) + actions.closeRightbar() + expect(store.getSnapshot().rightbarInstant).toBe(false) + }) + + it('does not let an unchanged frame measurement reset the fullscreen-exit marker', () => { + const { store, actions } = createLayoutStore().create() + actions.openRightbar(true, true) + actions.closeRightbar() + const closed = store.getSnapshot() + actions.setViewportWidth(closed.viewportWidth) + expect(store.getSnapshot()).toBe(closed) + }) + + it.each([true, false])('clears the exit marker on a fresh opening with fullscreen=%s', (fullscreen) => { + const { store, actions } = createLayoutStore().create() + actions.openRightbar(true, true) + actions.closeRightbar() + actions.openRightbar(true, fullscreen) + expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarFullscreen: fullscreen, rightbarInstant: false }) }) }) diff --git a/packages/client/ui-layout/tests/service.client.spec.ts b/packages/client/ui-layout/tests/service.client.spec.ts index ea4118927c..a95c6a5b44 100644 --- a/packages/client/ui-layout/tests/service.client.spec.ts +++ b/packages/client/ui-layout/tests/service.client.spec.ts @@ -1,40 +1,51 @@ import { describe, expect, it, vi } from 'vitest' -import { LayoutController } from '@deepseek-ai/dsh-client-ui-layout/src/client/service.ts' -import type { PanelActions } from '@deepseek-ai/dsh-client-ui-layout/src/client/service.ts' +import { LayoutController } from '../src/client/service.ts' +import type { PanelActions } from '../src/client/service.ts' function fakePanels(): PanelActions { return { setSidebar: vi.fn(), - setDetails: vi.fn(), toggleSidebar: vi.fn(), - setNarrow: vi.fn(), - openDetails: vi.fn(), - closeDetails: vi.fn(), + setViewportWidth: vi.fn(), + setRightbar: vi.fn(), + openRightbar: vi.fn(), + closeRightbar: vi.fn(), } } describe('LayoutController', () => { + it('forwards the right column transitions to the attached set', () => { + const service = new LayoutController() + const panels = fakePanels() + service.attachPanels(panels) + + service.openRightbar(true, false) + service.openRightbar(true, true) + service.openRightbar(false, true) + service.closeRightbar() + + expect(panels.openRightbar).toHaveBeenNthCalledWith(1, true, false) + expect(panels.openRightbar).toHaveBeenNthCalledWith(2, true, true) + expect(panels.openRightbar).toHaveBeenNthCalledWith(3, false, true) + expect(panels.closeRightbar).toHaveBeenCalledTimes(1) + // The drag width stays the frame's own business, never the caller's. + expect(panels.setRightbar).not.toHaveBeenCalled() + }) + it('forwards the three panel actions to the attached set', () => { const service = new LayoutController() const panels = fakePanels() service.attachPanels(panels) service.toggleSidebar() - service.openDetails() - service.closeDetails() expect(panels.toggleSidebar).toHaveBeenCalledTimes(1) - expect(panels.openDetails).toHaveBeenCalledTimes(1) - expect(panels.closeDetails).toHaveBeenCalledTimes(1) expect(panels.setSidebar).not.toHaveBeenCalled() - expect(panels.setDetails).not.toHaveBeenCalled() }) it('fails loud before the root entry wired its actions', () => { const service = new LayoutController() expect(() => { service.toggleSidebar() }).toThrow(/panel actions not wired/) - expect(() => { service.openDetails() }).toThrow(/panel actions not wired/) - expect(() => { service.closeDetails() }).toThrow(/panel actions not wired/) }) it('re-attach overwrites the stale action set (entry re-register)', () => { diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 042eff33d2..da92ab50d8 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 4bd090aa3a..cbee5f3884 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-model-selection", "description": "Model selection over the shared model catalog, Session projection, and session.selectModel", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json index c7751f3fc4..7565ef82a6 100644 --- a/packages/client/ui-open-in-app/package.json +++ b/packages/client/ui-open-in-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-open-in-app", "description": "Web Session-header \"Open In...\" split button opening the session workspace directory in a locally installed application", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 20ebdac995..75fc145050 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-permission-presets", "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index b083a4074a..5ef380f656 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' @@ -10,6 +11,9 @@ import { zh } from '../src/client/locales.ts' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { PermissionPresetSettingsController } from '../src/client/settings-store.ts' +// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + const schema = new SettingsSchemaService(new Context()) /** Controller over a real mirror derived from the same scripted context. */ @@ -56,6 +60,7 @@ const useSessionPendingInteraction: PermissionRowProps['useSessionPendingInterac const runtime = { useSessions: (() => { throw new Error('unused') }) as never, useSessionPendingInteraction, + useResource, useWorkspaces: (() => { throw new Error('unused') }) as never, } diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index f7676ccb7a..ec429207af 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", "description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 8690e9ad84..68614c11fd 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 1fba6938f9..d7b50a43ff 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-reference", "description": "Unified Web @file and @session reference source", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 63d0c71784..342af5b1fa 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-renderer", "description": "Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index 843bd9f681..c30441e55c 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-schedule", "description": "Read-only active Schedule catalog in the Web Session header", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index 8f3f47c77f..865ec5c225 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-session", "description": "Session Controller adapter for React and session-scoped slots", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 1eea375c47..af604ae5ea 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", "description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index d106f0211e..c3daeaaeb6 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' @@ -10,6 +11,9 @@ import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' +// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + /** Store over a real mirror derived from the same scripted context. */ function derivedDocumentStore(remote: object) { const ctx = { remote } as never @@ -28,7 +32,7 @@ const unusedHook = (() => { throw new Error('unused by settings-general componen type AttentionSnapshot = Parameters[0]>[0] const noAttention: AttentionSnapshot = new Map() const useSessionPendingInteraction: TriggerContentProps['useSessionPendingInteraction'] = selector => selector(noAttention) -const kit = { useSessions: unusedHook, useSessionPendingInteraction, useWorkspaces: unusedHook } +const kit = { useSessions: unusedHook, useSessionPendingInteraction, useResource, useWorkspaces: unusedHook } describe('chrome content', () => { it('TriggerContent renders the icon with the label in the wide column', () => { diff --git a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx index a496344319..3ebce030b4 100644 --- a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { useEffect, useState } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' @@ -7,6 +8,9 @@ import type { SettingsRootComponentProps } from '../src/client/shell-contract.ts import { SettingsRoot } from '../src/client/SettingsRoot.tsx' import { en } from '../src/client/locales.ts' +// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + afterEach(() => { cleanup() vi.useRealTimers() @@ -72,6 +76,7 @@ function mount({ const props: SettingsRootComponentProps = { useSessions, useSessionPendingInteraction, + useResource, useWorkspaces: unusedHook, wide, reconnect, diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index fdf48278b4..8783b91bf6 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-models", "description": "Models settings and shared product-onboarding dialogs over existing settings and credential joins", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index 996b0ff2f4..9f10184eaa 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom /** First-run DeepSeek prompt behavior over the shared Models join. */ +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' @@ -14,6 +15,9 @@ import { createModelsOperations } from '../src/client/operations.ts' import { en } from '../src/client/locales.ts' import { settingsSchema } from './settings-schema.client.ts' +// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + afterEach(() => { cleanup() document.getElementById('root')?.remove() @@ -143,6 +147,7 @@ function harness(options: { openSection, useSessions: unusedHook, useSessionPendingInteraction, + useResource, useWorkspaces: unusedHook, controller, useModels: bindSnapshotSelector(controller.store), diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index efa8c01665..59b2b7acd8 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' @@ -7,6 +8,9 @@ import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/c import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { SettingsScopeController } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts' +// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + /** Stateless schema service for scope construction in this jsdom fixture. */ const schemaService = new SettingsSchemaService(new Context()) import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' @@ -89,6 +93,7 @@ function mount( openSection: vi.fn(), useSessions: unusedHook, useSessionPendingInteraction, + useResource, useWorkspaces: unusedHook, controller, useWelcome: bindSnapshotSelector(controller.store), diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index a56eba039f..a322b33c31 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory", "description": "Read-only Cordis Loader inventory tab in Web Plugins settings", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 66c4db2ad5..d0eacaa783 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugins", "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 71688e8d67..92084e335a 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings", "description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-files/README.i18n.yaml b/packages/client/ui-sidebar-files/README.i18n.yaml new file mode 100644 index 0000000000..52e19f2db3 --- /dev/null +++ b/packages/client/ui-sidebar-files/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-files/README.md +README.md: 6e9e305b15058797156f832a56a29c095b0641f3 +README.zh.md: 95a15444182a988fdf512735be4e0fbfcaad6bdd diff --git a/packages/client/ui-sidebar-files/README.md b/packages/client/ui-sidebar-files/README.md new file mode 100644 index 0000000000..6e9e305b15 --- /dev/null +++ b/packages/client/ui-sidebar-files/README.md @@ -0,0 +1,72 @@ +--- +description: "The right Sidebar's file-tree tab type for the dsh web client: the session workspace root listed one level at a time over the wire, opening files into the Sidebar by resource address." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-sidebar-files + +English | [中文](README.zh.md) + +## Summary + +The right Sidebar's navigator tab type: the session's workspace root as a tree, listed one level at a time over the wire, opening files into the Sidebar. It is a page type reached from the guide and claims no address; it opens files by address for the `dsh-resource://file` viewers to claim — nothing in `ui-sidebar-right` knows this package. + +## Table of Contents + +- [What it registers](#what-it-registers) +- [The tree](#the-tree) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## What it registers + +- **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, titled from the `sidebarFiles` namespace) that opens the type. +- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: the tree, with its one control, reload, at the right of its header row. + +Six source files under `src/client/`: `definition.ts` (the type), `store.ts` (what it keeps), `face.ts` (how it lists, Remote binding included), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `locales.ts` (what it says), and `index.ts` (the wiring). + + +## The tree + +The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`, and labelled by `workspaceTitleOf` from `@deepseek-ai/dsh-util-workspace-path`. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry. + +| Entry type | Row | +|---|---| +| `directory` | Toggles; the level is fetched the first time it opens and kept while collapsed. | +| `file` | Opens `dsh-resource://file/session//`, built by `fileAddressFor` from `@deepseek-ai/dsh-util-workspace-path` from the entry's absolute path and the tree's root, through `useTabInfo().tab.actions.openResource`, landing in the tab's own pane. | +| `other` | Shown greyed and not clickable, so the directory is reported whole. | + +A level cut by the endpoint's entry cap ends with a marker; an empty level says so; a level that failed shows one line per code — `workspace-file/not-found`, `outside-workspace`, `not-directory` — and the transport's own message otherwise. Reload drops every listed level and asks again for the expanded ones; collapsed levels are fetched again when they next open. A session without a working directory shows a single line instead of a tree. + +State lives in the type's own store, bucketed by tab id: `root`, `levels` (loading / ready / failed per absolute path), and `expanded`. The owner's `signal` ends a bucket: on abort the tab is forgotten and a listing that settles afterwards writes nothing. + + +## Model Experience + +None, as this package draws a workspace file tree in the browser and registers nothing model-facing. + +#### KV Cache effect + +None; directory listings travel over the Remote and assemble no model request. + +## Known Limitations and Deferred Work + + +- **Listing only.** No search, artifact filter, drag-and-drop, rename, context menu, current-file highlight, or filesystem watching; a level changes only through reload. +- **One root.** The tree is rooted at the session's working directory; there is no way to browse above it, and the Host refuses paths outside the workspace root anyway. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
+ +**Runtime invariant:** No companion is published. The tree's only runtime state is one Slot store per tab, written by the body that owns it and forgotten on the tab's abort signal; there is no second observation of it to compare against. diff --git a/packages/client/ui-sidebar-files/README.zh.md b/packages/client/ui-sidebar-files/README.zh.md new file mode 100644 index 0000000000..95a1544418 --- /dev/null +++ b/packages/client/ui-sidebar-files/README.zh.md @@ -0,0 +1,72 @@ +--- +description: "dsh Web 客户端右侧 Sidebar 的文件树 tab 类型:逐层经线上列出会话工作区根目录,按资源地址把文件打开到 Sidebar。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-sidebar-files + +[English](README.md) | 中文 + +## 概述 + +右侧 Sidebar 的导航器 tab 类型:把会话的工作区根目录画成一棵树,逐层经线上列出,并把文件打开到 Sidebar 里。它是从引导页进入的页类型,不认领任何地址;它按地址打开文件,交给 `dsh-resource://file` 的查看器认领:`ui-sidebar-right` 里没有任何东西认识本包。 + +## 目录 + +- [注册了什么](#what-it-registers) +- [树](#the-tree) +- [模型体验](#model-experience) +- [已知限制与暂缓事项](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 注册了什么 + +- **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题取自 `sidebarFiles` 命名空间)。 +- **正文**:以该 id 为键的 `sidebar.right.pane.tab` 坑位:树本身,以及它唯一的控件、位于标题行右端的重新读取。 + +`src/client/` 下六个源文件:`definition.ts`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(它如何列目录,含 Remote 绑定)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`locales.ts`(它说什么)、`index.ts`(接线)。 + + +## 树 + +根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`,标签由 `@deepseek-ai/dsh-util-workspace-path` 的 `workspaceTitleOf` 给出。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配层保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。 + +| 条目类型 | 行 | +|---|---| +| `directory` | 切换展开与折叠;该层在首次打开时拉取,折叠期间保留。 | +| `file` | 经 `useTabInfo().tab.actions.openResource` 打开 `dsh-resource://file/session//`,地址由 `@deepseek-ai/dsh-util-workspace-path` 的 `fileAddressFor` 从条目的绝对路径与树的根生成,落在该 tab 自己的 pane 里。 | +| `other` | 灰显且不可点击,使目录被完整报告。 | + +被端点条目上限截断的层以一条标记收尾;空层如实说明;失败的层按错误码各显示一行(`workspace-file/not-found`、`outside-workspace`、`not-directory`),其他情况显示传输层自己的消息。重新读取丢弃所有已列出的层并只对展开中的层重新请求;折叠的层在下次打开时重新拉取。没有工作目录的会话只显示一行说明,而不是树。 + +状态住在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)与 `expanded`。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表什么也不写。 + + +## 模型体验 + +无,因为本包在浏览器里绘制工作区文件树,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无;目录列表经 Remote 传输,不会组装模型请求。 + +## 已知限制与暂缓事项 + + +- **只有列目录。**没有搜索、产物过滤、拖拽、重命名、右键菜单、当前文件高亮或文件系统监听;一层只会因重新读取而变化。 +- **只有一个根。**树以会话工作目录为根;没有办法浏览到它之上,而 Host 本来也拒绝工作区根之外的路径。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
+ +**运行时不变量:** 不发布 companion。树唯一的运行时状态是每 tab 一份的 Slot store,由持有它的正文写入、随 tab 的中止信号忘掉;没有第二个观测源可与之比对。 diff --git a/packages/client/ui-sidebar-files/package.json b/packages/client/ui-sidebar-files/package.json new file mode 100644 index 0000000000..0ba064054e --- /dev/null +++ b/packages/client/ui-sidebar-files/package.json @@ -0,0 +1,76 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-sidebar-files", + "description": "Workspace file tree tab type for the right Sidebar: lazy directory listing over the workspaceFiles Remote namespace, opening files into the Sidebar", + "version": "0.1.3-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-sidebar-files" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-workspace-files", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-api-remotes" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-workspace-files": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.module.css b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css new file mode 100644 index 0000000000..4ae79aa58a --- /dev/null +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css @@ -0,0 +1,128 @@ +.root { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow: auto; + padding: 4px 0 8px; + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: 1.5; +} + +.header { + display: flex; + flex: 0 0 auto; + gap: 6px; + align-items: center; + padding: 4px 10px; + color: var(--dsw-alias-label-secondary); + font-weight: 500; +} + +.level { + margin: 0; + padding: 0; + list-style: none; +} + +/* Every nested level indents by one step; the root level sits under the header. */ +.level .level { + padding-left: 14px; +} + +.item { + margin: 0; + padding: 0; +} + +.row { + display: flex; + gap: 6px; + align-items: center; + width: 100%; + min-width: 0; + padding: 3px 10px; + color: inherit; + font: inherit; + text-align: left; + background: transparent; + border: 0; + border-radius: 6px; + cursor: pointer; +} + +.row:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.icon { + flex: 0 0 auto; + color: var(--dsw-alias-label-secondary); +} + +/* The document glyph is drawn 24×28; it rides the row at icon height. */ +.fileIcon { + flex: 0 0 auto; + width: 14px; + height: 16px; +} + +.name { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* Neither a file nor a directory: shown so the directory is reported whole, + greyed so nobody tries to open it. */ +.other { + color: var(--dsw-alias-label-tertiary); + cursor: default; +} + +.other:hover { + background: transparent; +} + +.note { + margin: 0; + padding: 3px 10px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; +} + +.status { + display: flex; + flex-direction: column; + padding: 12px 10px; +} + +.statusLine { + margin: 0; + color: var(--dsw-alias-label-secondary); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: 1.6; +} + +/* The header's reload control, pushed to the row's right edge. */ +.tool { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin-left: auto; + padding: 0; + color: var(--dsw-alias-label-secondary); + background: transparent; + border: 0; + border-radius: 6px; + cursor: pointer; +} + +.tool:hover { + background: var(--dsw-alias-interactive-bg-hover); +} diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx new file mode 100644 index 0000000000..46727638b7 --- /dev/null +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx @@ -0,0 +1,192 @@ +/** + * The file tree's body: the session's workspace root, listed one level at a time. + * + * Everything the tree keeps lives in its store, keyed by tab; everything it asks + * for goes through its injected face. The component itself only decides what to + * draw for each absolute path and what a click means: a directory toggles, a + * file opens through the owner's `tabActions` for a `file:` viewer to claim, and + * anything else is shown but refuses to open. The header row carries the one + * control: reload, which drops every listed level and asks again for the + * expanded ones. + */ +import { useEffect } from 'react' +import type { ReactNode } from 'react' +import clsx from 'clsx' +import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' +import type { PropsLocale, PropsRuntime, PropsStore, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import { + DocumentFileIcon, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import { fileAddressFor, workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' +import type { WorkspaceDirectoryEntry } from '@deepseek-ai/dsh-api-workspace-files/types' +import { childPath } from './face.ts' +import type { FilesInjected } from './face.ts' +import type {} from './locales.ts' +import type { FilesTabState, createFilesStore } from './store.ts' +import css from './FilesBody.module.css' + +/** The body's composed props: the tab it draws, its store, its face, and its copy. */ +export type FilesBodyProps = + & PropsRuntime<'sidebar.right.pane.tab'> + & PropsStore> + & FilesInjected + & PropsLocale<'sidebarFiles'> + +/** Natural, case-insensitive name order, so `file2` precedes `file10`. */ +const byName = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }) + +/** + * Order one level's entries for display: directories first, then everything + * else, each group by name. The endpoint's order is a listing fact; this is the + * reader's. + * @param entries - the listing as the endpoint returned it. + * @returns a new array, directories first, then by name within each group. + */ +export function orderEntries(entries: readonly WorkspaceDirectoryEntry[]): WorkspaceDirectoryEntry[] { + return [...entries].sort((left, right) => { + const group = Number(right.type === 'directory') - Number(left.type === 'directory') + return group !== 0 ? group : byName.compare(left.name, right.name) + }) +} + +/** + * Say why a directory could not be listed, in terms of the directory. + * @param t - namespace-bound translate. + * @param failure - the settled Remote failure. + * @returns the line to show under the directory. + */ +export function failureLine(t: TranslateNS<'sidebarFiles'>, failure: RemoteFailure): string { + switch (failure.code) { + case 'workspace-file/not-found': return t('error.notFound') + case 'workspace-file/outside-workspace': return t('error.outsideWorkspace') + case 'workspace-file/not-directory': return t('error.notDirectory') + // Carrier and unclassified host failures reach the reader as themselves: + // this tree knows nothing useful to add to a transport-level message. + default: return t('error.unavailable', { message: failure.message }) + } +} + +/** What every level shares: the tab's tree and the two gestures. */ +interface TreeContext { + readonly state: FilesTabState + readonly onToggle: (path: string) => void + readonly onOpen: (path: string) => void + readonly t: TranslateNS<'sidebarFiles'> +} + +/** One entry's row, and its children when it is an expanded directory. */ +function Entry({ parent, entry, tree }: { parent: string; entry: WorkspaceDirectoryEntry; tree: TreeContext }): ReactNode { + const path = childPath(parent, entry.name) + if (entry.type === 'directory') { + const expanded = tree.state.expanded.includes(path) + return ( +
  • + + {expanded &&
    } +
  • + ) + } + if (entry.type === 'file') { + return ( +
  • + +
  • + ) + } + return ( +
  • + + {entry.name} + +
  • + ) +} + +/** One directory's rows: its state while listing, its entries once listed. */ +function Level({ path, tree }: { path: string; tree: TreeContext }): ReactNode { + const { state, t } = tree + const level = state.levels[path] + if (level === undefined || level.kind === 'loading') { + return
  • {t('loading')}
  • + } + if (level.kind === 'failed') { + return ( +
  • + {failureLine(t, level.failure)} +
  • + ) + } + const entries = orderEntries(level.level.entries) + return ( + <> + {entries.length === 0 &&
  • {t('empty')}
  • } + {entries.map(entry => )} + {level.level.truncated &&
  • {t('truncated')}
  • } + + ) +} + +/** The file tree's body: the workspace root and whatever the reader has opened under it. */ +export function FilesBody({ + useTabInfo, sessionId, useSessions, useStore, actions, start, load, toggle, t, +}: FilesBodyProps): ReactNode { + const { tab } = useTabInfo() + const { signal, actions: tabActions } = tab + const cwd = useSessions(sessions => sessions.byId[sessionId]?.cwd) + const state = useStore(store => store.byTab[tab.id]) + useEffect(() => { + // A bucket gone because the record aborted must not be re-seeded by a + // component that has not unmounted yet. + if (state !== undefined || cwd === undefined || signal.aborted) return + start(tab.id, cwd, signal) + }, [state, cwd, tab.id, signal, start]) + + if (cwd === undefined) { + return ( +
    +

    {t('noWorkspace')}

    +
    + ) + } + if (state === undefined) return null + const tree: TreeContext = { + state, + onToggle: (path) => { toggle(tab.id, path, state.levels[path] !== undefined, signal) }, + // Every row is under the tree's root, so its address is session-relative. + onOpen: (path) => { tabActions.openResource(fileAddressFor(sessionId, state.root, path)) }, + t, + } + // Reload drops every level and asks again for the expanded ones; a collapsed + // level is fetched again the next time it opens. + const reload = (): void => { + actions.reset(tab.id) + for (const path of state.expanded) load(tab.id, path, signal) + } + // A separator-only root has no final segment; the root itself is the label then. + const title = workspaceTitleOf(state.root) || state.root + return ( +
    +
    + + {title} + +
    +
    +
    + ) +} diff --git a/packages/client/ui-sidebar-files/src/client/definition.ts b/packages/client/ui-sidebar-files/src/client/definition.ts new file mode 100644 index 0000000000..f967a472a0 --- /dev/null +++ b/packages/client/ui-sidebar-files/src/client/definition.ts @@ -0,0 +1,37 @@ +/** + * Stage one of this package's registration: what the `files` tab type IS. + * + * The type is a page, not a viewer: it claims no address. The guide page offers + * it as an entry box, and the tree opens files through `tabActions.openResource` + * for the `dsh-resource://file` viewers to claim. + */ +import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' +import type {} from './locales.ts' +import { IconFolderClose16 } from '@deepseek-ai/dsh-client-ui-primitives' + +/** The tab kind this package owns. */ +export const FILES_KIND = 'files' + +/** This implementation's identity in the tab system, and the key its body registers under. */ +export const FILES_ID = '@deepseek-ai/dsh-client-ui-sidebar-files' + +/** + * The files type's registry definition. + * @param t - namespace-bound translate, read fresh on every label call. + * @returns the definition to register. + */ +export function filesDefinition(t: TranslateNS<'sidebarFiles'>): SidebarRightTabDefinition { + return { + id: FILES_ID, + kind: FILES_KIND, + priority: 'builtin', + title: () => t('type.label'), + guide: [{ + order: 10, + title: () => t('guide.title'), + description: () => t('guide.description'), + icon: IconFolderClose16, + }], + } +} diff --git a/packages/client/ui-sidebar-files/src/client/face.ts b/packages/client/ui-sidebar-files/src/client/face.ts new file mode 100644 index 0000000000..a170e897ce --- /dev/null +++ b/packages/client/ui-sidebar-files/src/client/face.ts @@ -0,0 +1,149 @@ +/** + * The tree's asynchronous half: listing directories into the store. + * + * The component never awaits anything. It calls `start` / `load` / `toggle`, and + * this face performs the listing and writes the outcome through the store's own + * actions — the Slot-standard `inject` shape, so the session id is resolved by + * the framework and the write set stays the store's. + * + * The listing itself is bound here to the Client Remote face: the tree keys + * every level by absolute path and hands the endpoint that same absolute path; + * the endpoint answers with the directory's workspace-relative path as well, + * which the tree has no use for and drops. + * + * One level has one listing in force: asking for a level again — the reload + * gesture, a directory reopened after a reset — retires the listing still in + * flight for it, whose settlement then writes nothing. Cleanup rides the owner's + * `signal`: a request is not made for a record that already ended, and when the + * record goes away the bucket and the tab's listing bookkeeping are forgotten, + * so no later settlement writes to it. + */ +import type { ClientRemote, RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' +import type { BoundActions } from '@deepseek-ai/dsh-client-store' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { DirLevel, createFilesStore } from './store.ts' + +/** + * One directory listing, bound to a Remote face. + * + * The session travels with the call because the endpoint resolves the workspace + * root from it: the same path means different directories in different sessions. + * A Remote call does not reject — the result carries the failure. + */ +export type ListWorkspaceDirectory = ( + sessionId: SessionId, + path: string, + signal: AbortSignal, +) => Promise> + +/** + * The slice of the Client Remote face this package calls: the `workspaceFiles` + * namespace's `list`, exactly as the Host's generated client declares it. + */ +export type WorkspaceFilesListRemote = { + readonly workspaceFiles: Pick +} + +/** + * Bind the listing to one Remote face, keeping only what the tree stores. + * @param remote - the Client Remote face carrying the `workspaceFiles` namespace. + * @returns the listing the tree's face performs. + */ +export function createList(remote: WorkspaceFilesListRemote): ListWorkspaceDirectory { + return async (sessionId, path, signal) => { + const result = await remote.workspaceFiles.list(sessionId, path, signal) + if (!result.ok) return result + return { ok: true, value: { entries: result.value.entries, truncated: result.value.truncated } } + } +} + +/** + * The absolute path of one child entry. + * + * Joined with `/` whatever the parent's separators: the Host resolves mixed + * separators, and the tree only needs a stable key. + * @param parent - absolute path of the listed directory. + * @param name - the entry's basename. + * @returns the child's absolute path. + */ +export function childPath(parent: string, name: string): string { + return `${parent.replace(/[/\\]+$/, '')}/${name}` +} + +/** The tree's injected business face, as the body receives it. */ +export interface FilesInjected { + /** + * Seed this tab's tree and list its root. + * @param tabId - the tab being drawn. + * @param root - absolute path of the workspace root. + * @param signal - the tab record's lifetime. + */ + readonly start: (tabId: TabId, root: string, signal: AbortSignal) => void + /** + * List one directory into the store. + * @param tabId - the tab being drawn. + * @param path - absolute directory path. + * @param signal - the tab record's lifetime. + */ + readonly load: (tabId: TabId, path: string, signal: AbortSignal) => void + /** + * Open or collapse one directory, listing it the first time it opens. + * @param tabId - the tab being drawn. + * @param path - absolute directory path. + * @param loaded - whether this level already has state. + * @param signal - the tab record's lifetime. + */ + readonly toggle: (tabId: TabId, path: string, loaded: boolean, signal: AbortSignal) => void +} + +/** + * Bind the tree's face to one directory listing. + * @param list - the bound `workspaceFiles.list` call. + * @returns the Slot `inject` factory: session and bound actions in, face out. + */ +export function filesFace( + list: ListWorkspaceDirectory, +): (sessionId: SessionId, actions: BoundActions>) => FilesInjected { + return ( + sessionId: SessionId, + actions: BoundActions>, + ): FilesInjected => { + /** Per tab, per absolute path: the listing generation a settlement must match; the latest request wins. */ + const generations = new Map>() + const nextGeneration = (tabId: TabId, path: string): number => { + const byPath = generations.get(tabId) ?? new Map() + generations.set(tabId, byPath) + const generation = (byPath.get(path) ?? 0) + 1 + byPath.set(path, generation) + return generation + } + const load = (tabId: TabId, path: string, signal: AbortSignal): void => { + if (signal.aborted) return + const generation = nextGeneration(tabId, path) + actions.loading(tabId, path) + void list(sessionId, path, signal).then((result) => { + // A newer listing of this level was asked for since, or the record is + // gone and its bookkeeping with it: nothing left for this one to write. + if (generations.get(tabId)?.get(path) !== generation) return + if (result.ok) actions.loaded(tabId, path, result.value) + else actions.failed(tabId, path, result.error) + }) + } + return { + start(tabId, root, signal) { + actions.start(tabId, root) + signal.addEventListener('abort', () => { + generations.delete(tabId) + actions.forget(tabId) + }, { once: true }) + load(tabId, root, signal) + }, + load, + toggle(tabId, path, loaded, signal) { + actions.toggled(tabId, path) + if (!loaded) load(tabId, path, signal) + }, + } + } +} diff --git a/packages/client/ui-sidebar-files/src/client/index.ts b/packages/client/ui-sidebar-files/src/client/index.ts new file mode 100644 index 0000000000..0f04544729 --- /dev/null +++ b/packages/client/ui-sidebar-files/src/client/index.ts @@ -0,0 +1,52 @@ +/** + * Browser half: register `files` as a right-Sidebar tab type. + * + * The public two-stage path, unmodified: the type into `ctx.sidebarRightTabs`, + * the body into the keyed `sidebar.right.pane.tab` seat under the type's `id`. + * + * The file split is this package's layering: what the type IS + * (`definition.ts`), what it keeps (`store.ts`), how it lists (`face.ts`), what + * it draws (`FilesBody.tsx`), what it says (`locales.ts`), and this module, + * which only wires them together. + */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import { FILES_ID, filesDefinition } from './definition.ts' +import { createList, filesFace } from './face.ts' +import { FilesBody } from './FilesBody.tsx' +import { en, zh } from './locales.ts' +import { createFilesStore } from './store.ts' + +export type { SidebarFilesKey } from './locales.ts' +export type { DirLevel, FilesState, FilesTabState, LevelState } from './store.ts' +export type { FilesInjected, ListWorkspaceDirectory, WorkspaceFilesListRemote } from './face.ts' +export type { FilesBodyProps } from './FilesBody.tsx' + +/** This package's copy namespace. */ +const NS = 'sidebarFiles' + +/** + * Required browser services: the tab registry, the keyed seat, the Remote + * carrier and its namespace, and copy. + */ +export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles'] + +/** + * Client plugin body: register the type, its dictionaries, then its body. + * @param ctx - client root context carrying the registry, the slots, and the Remote face. + */ +export function apply(ctx: ClientContext): void { + const t = ctx.locale.bind(NS) + ctx.effect(() => ctx.sidebarRightTabs.register(filesDefinition(t)), 'ui-sidebar-files: files type') + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar-files: dictionaries') + + const store = createFilesStore() + const inject = filesFace(createList(ctx.remote)) + ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register( + { name: 'sidebar.right.pane.tab', key: FILES_ID, locale: NS, store, inject }, + FilesBody, + )), 'ui-sidebar-files: files tab body') +} diff --git a/packages/client/ui-sidebar-files/src/client/locales.ts b/packages/client/ui-sidebar-files/src/client/locales.ts new file mode 100644 index 0000000000..1976add093 --- /dev/null +++ b/packages/client/ui-sidebar-files/src/client/locales.ts @@ -0,0 +1,56 @@ +/** + * `sidebarFiles` namespace dictionaries, and the namespace's declaration. + * + * The failure lines name what the tree could not list, one code each, because a + * directory that is gone, one outside the workspace, and a path that is not a + * directory each suggest a different next step. + * + * The namespace merge lives with its key set so that any module naming + * `TranslateNS<'sidebarFiles'>` or `PropsLocale<'sidebarFiles'>` needs only this + * file, whichever entry a program loads first. + */ +import type {} from '@deepseek-ai/dsh-client-ui-slots' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** File-tree type name, guide entry, row states, and failure lines. */ + sidebarFiles: SidebarFilesKey + } +} + +/** Simplified Chinese dictionary and key-set source of truth. */ +export const zh = { + 'type.label': '文件', + 'guide.title': '文件', + 'guide.description': '浏览这个会话工作区里的文件,点开就能查看。', + loading: '正在读取…', + empty: '空目录', + truncated: '条目太多,只显示了一部分。', + noWorkspace: '这个会话没有工作区目录。', + reload: '重新读取', + 'entry.other': '这不是文件或目录,没法打开。', + 'error.notFound': '这个目录不在了。可能已被移动或删除。', + 'error.outsideWorkspace': '这个目录在工作区之外,侧栏不会读取它。', + 'error.notDirectory': '这不是一个目录。', + 'error.unavailable': '读取失败:{message}', +} satisfies Record + +/** Files dictionary key union. */ +export type SidebarFilesKey = keyof typeof zh + +/** English dictionary, checked against the Chinese key set. */ +export const en = { + 'type.label': 'Files', + 'guide.title': 'Files', + 'guide.description': 'Browse the files in this session\'s workspace and open any of them.', + loading: 'Reading…', + empty: 'Empty directory', + truncated: 'Too many entries; showing only some of them.', + noWorkspace: 'This session has no workspace directory.', + reload: 'Reload', + 'entry.other': 'Not a file or a directory, so it cannot be opened.', + 'error.notFound': 'That directory is gone. It may have been moved or deleted.', + 'error.outsideWorkspace': 'That directory is outside the workspace, so the sidebar will not read it.', + 'error.notDirectory': 'That is not a directory.', + 'error.unavailable': 'Read failed: {message}', +} satisfies Record diff --git a/packages/client/ui-sidebar-files/src/client/store.ts b/packages/client/ui-sidebar-files/src/client/store.ts new file mode 100644 index 0000000000..619ed0ba30 --- /dev/null +++ b/packages/client/ui-sidebar-files/src/client/store.ts @@ -0,0 +1,165 @@ +/** + * The file tree's view state: which directories are expanded, and what each + * loaded level contains. + * + * The tree is not one resource. A directory listing per level, expanded lazily, + * is state the type owns — so it lives in a Slot-standard exclusive store + * (one instance per session), bucketed by tab id because two tabs of this kind + * in one session expand independently. + * + * Writers run between `start` and `forget`: the owner's `signal` is what ends a + * bucket's life, and the face stops dispatching once it aborts. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { WorkspaceDirectoryEntry } from '@deepseek-ai/dsh-api-workspace-files/types' + +/** + * One directory's contents, as one expanded level of the tree. + * + * The endpoint's listing also names the directory as a workspace-relative path; + * the tree keys every level by absolute path instead, so the adapter drops it. + */ +export interface DirLevel { + /** The directory's entries, in the endpoint's order. */ + readonly entries: readonly WorkspaceDirectoryEntry[] + /** The listing hit the endpoint's entry cap, so entries are missing. */ + readonly truncated: boolean +} + +/** What one directory level is doing right now. */ +export type LevelState = + | { readonly kind: 'loading' } + | { readonly kind: 'ready'; readonly level: DirLevel } + | { readonly kind: 'failed'; readonly failure: RemoteFailure } + +/** + * One tab's tree: its root, the levels it has asked for, and what is open. + * + * Every path here is absolute: the root is the session's working directory as + * the Host reports it, and a child is the parent joined with the entry name. + */ +export interface FilesTabState { + /** Absolute path of the workspace root this tree is rooted at. */ + root: string + /** Level state by absolute directory path; a path absent here was never asked for. */ + levels: Record + /** Expanded absolute directory paths, root included. */ + expanded: string[] +} + +/** Every tab's tree, keyed by tab id. */ +export interface FilesState { + byTab: Record +} + +/** + * One tab's bucket, which every writer after `start` relies on: the face only + * dispatches while the record's signal is live, and `forget` runs on its abort. + * @param state - the draft. + * @param tabId - the tab being written. + * @returns the tab's tree. + */ +function bucket(state: FilesState, tabId: TabId): FilesTabState { + const tree = state.byTab[tabId] + if (tree === undefined) throw new Error(`ui-sidebar-files: no tree for tab "${tabId}"`) + return tree +} + +/** The tree store's write set; every action names the tab it writes. */ +type FilesActions = { + start: (draft: FilesState, tabId: TabId, root: string) => void + loading: (draft: FilesState, tabId: TabId, path: string) => void + loaded: (draft: FilesState, tabId: TabId, path: string, level: DirLevel) => void + failed: (draft: FilesState, tabId: TabId, path: string, failure: RemoteFailure) => void + toggled: (draft: FilesState, tabId: TabId, path: string) => void + reset: (draft: FilesState, tabId: TabId) => void + forget: (draft: FilesState, tabId: TabId) => void +} + +/** + * Declare the file tree's store. + * + * A factory rather than a shared handle: the registration declares it as an + * exclusive store, so the framework mints one instance per session. + * @returns the store handle to declare on the registration. + */ +export function createFilesStore(): EngineStoreHandle { + return defineStore({ + init: (): FilesState => ({ byTab: {} }), + actions: { + /** + * Seed one tab's tree at its workspace root, with the root expanded. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param root - absolute path of the workspace root. + */ + start: (d, tabId: TabId, root: string) => { + d.byTab[tabId] = { root, levels: {}, expanded: [root] } + }, + /** + * Mark one directory as being listed. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param path - absolute directory path. + */ + loading: (d, tabId: TabId, path: string) => { + bucket(d, tabId).levels[path] = { kind: 'loading' } + }, + /** + * Record one directory's contents. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param path - absolute directory path. + * @param level - the listing to show under it. + */ + loaded: (d, tabId: TabId, path: string, level: DirLevel) => { + bucket(d, tabId).levels[path] = { kind: 'ready', level } + }, + /** + * Record why one directory could not be listed. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param path - absolute directory path. + * @param failure - the settled Remote failure. + */ + failed: (d, tabId: TabId, path: string, failure: RemoteFailure) => { + bucket(d, tabId).levels[path] = { kind: 'failed', failure } + }, + /** + * Open a collapsed directory, or collapse an open one. + * + * A collapsed level keeps what it loaded, so reopening it draws at once. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param path - absolute directory path. + */ + toggled: (d, tabId: TabId, path: string) => { + const state = bucket(d, tabId) + const at = state.expanded.indexOf(path) + if (at >= 0) state.expanded.splice(at, 1) + else state.expanded.push(path) + }, + /** + * Drop every loaded level, keeping what is expanded. + * + * This is the reload gesture's first half: the expanded set says which + * levels to fetch again. + * @param d - draft state. + * @param tabId - the tab being drawn. + */ + reset: (d, tabId: TabId) => { + bucket(d, tabId).levels = {} + }, + /** + * Forget one tab's tree, for a tab record that is gone. + * @param d - draft state. + * @param tabId - the tab that went away. + */ + forget: (d, tabId: TabId) => { + d.byTab = Object.fromEntries(Object.entries(d.byTab).filter(([id]) => id !== tabId)) + }, + }, + }) +} diff --git a/packages/client/ui-sidebar-files/src/css-modules.d.ts b/packages/client/ui-sidebar-files/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-sidebar-files/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-sidebar-files/src/index.ts b/packages/client/ui-sidebar-files/src/index.ts new file mode 100644 index 0000000000..154525a7cb --- /dev/null +++ b/packages/client/ui-sidebar-files/src/index.ts @@ -0,0 +1,4 @@ +/** Pure host half; the whole tab type lives in the browser export. */ + +/** Host plugin body: the file tree contributes nothing to the host tree. */ +export function apply(): void {} diff --git a/packages/client/ui-sidebar-files/tests/apply.client.spec.ts b/packages/client/ui-sidebar-files/tests/apply.client.spec.ts new file mode 100644 index 0000000000..8ab773ae13 --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/apply.client.spec.ts @@ -0,0 +1,89 @@ +/** + * The plugin's registrations, and their removal when the plugin goes. + * + * The registry is real, because "registered" means what it says a type is; the + * slot, locale, and Remote faces are recorders, because what matters here is + * what was handed to them — one body seat under the type's id with its store + * and face — and that every registration is gone after dispose, which is what + * makes a reload safe. + */ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts' +import { FILES_ID, FILES_KIND } from '../src/client/definition.ts' +import { apply, inject } from '../src/client/index.ts' +import { apply as hostApply } from '../src/index.ts' +import { FilesBody } from '../src/client/FilesBody.tsx' +import { en, zh } from '../src/client/locales.ts' + +interface Recorded { + name: string + key: string + locale: string + store: unknown + inject: unknown + component: unknown +} + +async function boot() { + const ctx = new Context() + const tabs = new SidebarRightTabRegistry(ctx) + const registered: Recorded[] = [] + const slots = { + inject: vi.fn((_name: string, register: () => () => void) => register()), + register: vi.fn((options: Omit, component: unknown) => { + const entry: Recorded = { ...options, component } + registered.push(entry) + return () => { registered.splice(registered.indexOf(entry), 1) } + }), + } + const dictionaries = new Map() + const locale = { + // Copy is the dictionary's contract; the key stands in for the translation. + bind: vi.fn(() => (key: string) => key), + register: vi.fn((ns: string, dicts: unknown) => { + dictionaries.set(ns, dicts) + return () => { dictionaries.delete(ns) } + }), + } + const workspaceFiles = { list: vi.fn() } + ctx.provide('sidebarRightTabs', tabs as never) + ctx.provide('slots', slots as never) + ctx.provide('locale', locale as never) + ctx.provide('remote', { workspaceFiles } as never) + ctx.provide('remote.workspaceFiles', workspaceFiles as never) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + return { tabs, registered, dictionaries, fiber } +} + +describe('ui-sidebar-files apply', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + + it('registers the type, its dictionaries, and the body seat under the type\'s id with a store and a face', async () => { + const { tabs, registered, dictionaries } = await boot() + const definition = tabs.get(FILES_KIND) + expect(definition?.id).toBe(FILES_ID) + expect(definition?.priority).toBe('builtin') + expect(definition?.title('sidebar://files')).toBe('type.label') + expect(definition?.guide?.map(entry => [entry.order, entry.title(), entry.description()])).toEqual([[10, 'guide.title', 'guide.description']]) + expect(dictionaries.get('sidebarFiles')).toEqual({ zh, en }) + // The seat key is the implementation's id, not the kind: an extension may + // take the kind over, and the seat must still find this body. + expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([ + ['sidebar.right.pane.tab', FILES_ID, 'sidebarFiles', FilesBody], + ]) + expect(registered[0]?.store).toBeDefined() + expect(typeof registered[0]?.inject).toBe('function') + }) + + it('takes every registration back when the plugin is disposed', async () => { + const { tabs, registered, dictionaries, fiber } = await boot() + await fiber.dispose() + expect(tabs.get(FILES_KIND)).toBeUndefined() + expect(registered).toEqual([]) + expect(dictionaries.size).toBe(0) + }) +}) diff --git a/packages/client/ui-sidebar-files/tests/definition.client.spec.ts b/packages/client/ui-sidebar-files/tests/definition.client.spec.ts new file mode 100644 index 0000000000..dc5dfeda1b --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/definition.client.spec.ts @@ -0,0 +1,44 @@ +/** + * Stage one, as the registry sees it: the type is a page that claims no + * address, sits in the builtin band, and offers the guide page one entry that + * opens its kind. + */ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts' +import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import { + FILES_ID, FILES_KIND, filesDefinition, +} from '../src/client/definition.ts' +import { zh } from '../src/client/locales.ts' + +const t = makeTranslate(zh) + +describe('filesDefinition', () => { + it('registers under its kind and id and claims no address', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(filesDefinition(t)) + expect(registry.get(FILES_KIND)?.id).toBe(FILES_ID) + expect(registry.candidates(sessionFileAddress('s-1', '/work/app/a.ts'))).toEqual([]) + }) + + it('offers the guide page one entry at order 10 that opens the files kind', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(filesDefinition(t)) + const [entry, ...rest] = registry.guide() + expect(rest).toEqual([]) + expect(entry?.order).toBe(10) + expect(entry?.kind).toBe(FILES_KIND) + expect(entry?.title()).toBe(zh['guide.title']) + expect(entry?.description()).toBe(zh['guide.description']) + expect(entry?.icon).toBeDefined() + }) + + it('sits in the builtin band and titles itself from the dictionary', () => { + const definition = filesDefinition(t) + expect(definition.priority).toBe('builtin') + expect(definition.patterns).toBeUndefined() + expect(definition.title('')).toBe(zh['type.label']) + }) +}) diff --git a/packages/client/ui-sidebar-files/tests/face.client.spec.ts b/packages/client/ui-sidebar-files/tests/face.client.spec.ts new file mode 100644 index 0000000000..dfa1710b23 --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/face.client.spec.ts @@ -0,0 +1,143 @@ +/** + * The tree's asynchronous half against a scripted listing, and the Remote + * adapter under it. + * + * The face's contract is what reaches the store and when: a level is `loading` + * before the listing settles, `ready` or `failed` after, never written once the + * owner's signal aborted or a newer listing of the level was asked for, and a + * tab whose record is gone leaves no bucket behind. The adapter's is what it + * keeps and what it drops: entries and the + * truncation flag reach the store, the endpoint's workspace-relative path does + * not, and a failure passes through untouched. + */ +import { describe, expect, it, vi } from 'vitest' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceDirectoryListing } from '@deepseek-ai/dsh-api-workspace-files/types' +import { childPath, createList, filesFace } from '../src/client/face.ts' +import type { WorkspaceFilesListRemote } from '../src/client/face.ts' +import { createFilesStore } from '../src/client/store.ts' +import type { DirLevel } from '../src/client/store.ts' +import { scriptedList } from './scripted-list.client.ts' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' + +const SESSION = 's-1' as SessionId +const ROOT = '/work/app' +const TAB = 'tab-1' as TabId + +const LEVEL: DirLevel = { entries: [{ name: 'src', type: 'directory' }], truncated: false } + +function mount() { + const instance = createFilesStore().create() + const script = scriptedList() + const face = filesFace(script.list)(SESSION, instance.actions) + return { ...script, face, snapshot: () => instance.getSnapshot().byTab[TAB] } +} + +describe('filesFace', () => { + it('start seeds the tab and lists the root with the session and the absolute root path', async () => { + const { face, list, settle, snapshot } = mount() + const controller = new AbortController() + face.start(TAB, ROOT, controller.signal) + expect(list).toHaveBeenCalledWith(SESSION, ROOT, controller.signal) + expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'loading' }) + await settle({ ok: true, value: LEVEL }) + expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) + }) + + it('records a failed listing under its level', async () => { + const { face, settle, snapshot } = mount() + face.start(TAB, ROOT, new AbortController().signal) + const error = new RemoteError('workspace-file/not-directory', 'not a directory', { path: ROOT, kind: 'file' }) + await settle({ ok: false, error }) + expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'failed', failure: error }) + }) + + it('toggle expands and lists a directory the first time, and only toggles afterwards', async () => { + const { face, list, settle, snapshot } = mount() + const signal = new AbortController().signal + const child = `${ROOT}/src` + face.start(TAB, ROOT, signal) + await settle({ ok: true, value: LEVEL }) + face.toggle(TAB, child, false, signal) + expect(list).toHaveBeenLastCalledWith(SESSION, child, signal) + expect(snapshot()!.expanded).toEqual([ROOT, child]) + await settle({ ok: true, value: LEVEL }) + face.toggle(TAB, child, true, signal) + expect(snapshot()!.expanded).toEqual([ROOT]) + expect(list).toHaveBeenCalledTimes(2) + }) + + it('abort forgets the bucket and a late settlement writes nothing', async () => { + const { face, settle, snapshot } = mount() + const controller = new AbortController() + face.start(TAB, ROOT, controller.signal) + controller.abort() + expect(snapshot()).toBeUndefined() + await settle({ ok: true, value: LEVEL }) + expect(snapshot()).toBeUndefined() + }) + + it('makes no request for a record that already ended', () => { + const { face, list } = mount() + const controller = new AbortController() + controller.abort() + face.load(TAB, ROOT, controller.signal) + expect(list).not.toHaveBeenCalled() + }) + + it('lets the latest listing of a level win, whichever settles first', async () => { + const { face, list, settle, settleLatest, snapshot, outstanding } = mount() + const signal = new AbortController().signal + const older: DirLevel = { entries: [{ name: 'old.txt', type: 'file' }], truncated: false } + face.start(TAB, ROOT, signal) + // The reload gesture asks for the root again while the first listing is still out. + face.load(TAB, ROOT, signal) + expect(list).toHaveBeenCalledTimes(2) + expect(outstanding()).toEqual([ROOT, ROOT]) + await settleLatest({ ok: true, value: LEVEL }) + expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) + // The retired listing lands afterwards and changes nothing. + await settle({ ok: true, value: older }) + expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) + // A retired failure is dropped the same way. + face.load(TAB, ROOT, signal) + face.load(TAB, ROOT, signal) + await settleLatest({ ok: true, value: LEVEL }) + await settle({ ok: false, error: new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }) }) + expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) + }) +}) + +describe('createList', () => { + it('passes the session, the absolute path, and the signal through, and keeps entries and truncation', async () => { + const listing: WorkspaceDirectoryListing = { + path: 'src', + entries: [{ name: 'a.ts', type: 'file', size: 3 }], + truncated: true, + } + const list = vi.fn() + .mockResolvedValue({ ok: true, value: listing }) + const signal = new AbortController().signal + const result = await createList({ workspaceFiles: { list } })(SESSION, `${ROOT}/src`, signal) + expect(list).toHaveBeenCalledWith(SESSION, `${ROOT}/src`, signal) + expect(result).toEqual({ ok: true, value: { entries: listing.entries, truncated: true } }) + }) + + it('returns a failure as the endpoint reported it', async () => { + const error = new RemoteError('workspace-file/not-directory', 'file', { path: 'x', kind: 'file' }) + const list = vi.fn() + .mockResolvedValue({ ok: false, error }) + const result = await createList({ workspaceFiles: { list } })(SESSION, `${ROOT}/x`, new AbortController().signal) + expect(result).toEqual({ ok: false, error }) + }) +}) + +describe('childPath', () => { + it('joins with one slash whatever the parent ends in', () => { + expect(childPath('/work/app', 'src')).toBe('/work/app/src') + expect(childPath('/work/app/', 'src')).toBe('/work/app/src') + expect(childPath('/', 'etc')).toBe('/etc') + expect(childPath('C:\\work\\', 'src')).toBe('C:\\work/src') + }) +}) diff --git a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx new file mode 100644 index 0000000000..cf889e3859 --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx @@ -0,0 +1,177 @@ +// @vitest-environment jsdom +/** + * The body against a scripted listing. + * + * What is asserted is the reader's contract: the root lists itself on mount, + * rows come out directories-first, a directory click asks for exactly that + * level, a file click opens exactly that session-scoped `file:` address through + * the owner, an `other` entry is shown but not clickable, the tree says when it + * was cut or could not be read, and reload asks again for the expanded levels + * only. The two pure helpers the rows are built from are checked on their own. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { act, cleanup, fireEvent } from '@testing-library/react' +import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' +import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' +import { failureLine, orderEntries } from '../src/client/FilesBody.tsx' +import type { DirLevel } from '../src/client/store.ts' +import { zh } from '../src/client/locales.ts' +import { mountBody, ROOT, SESSION, TAB } from './mount.client.tsx' + +const ROOT_LEVEL: DirLevel = { + entries: [ + { name: 'README.md', type: 'file', size: 12 }, + { name: 'src', type: 'directory' }, + { name: '.env', type: 'file', size: 2 }, + { name: 'pipe', type: 'other' }, + ], + truncated: false, +} + +afterEach(() => { cleanup() }) + +/** Row labels in document order. */ +function names(root: HTMLElement): string[] { + return [...root.querySelectorAll('[data-files-entry]')].map(li => li.getAttribute('data-files-path')!) +} + +describe('FilesBody', () => { + it('says so when the session has no workspace directory, and asks for nothing', () => { + const { view, script } = mountBody(null) + expect(view.container.querySelector('[data-files-state="no-workspace"]')?.textContent).toBe(zh.noWorkspace) + expect(script.list).not.toHaveBeenCalled() + }) + + it('lists the root on mount, names it by its basename, and draws directories first with dotfiles kept', async () => { + const { view, script } = mountBody() + expect(script.list).toHaveBeenCalledWith(SESSION, ROOT, expect.any(AbortSignal)) + expect(view.container.querySelector('[data-files-row="loading"]')).not.toBeNull() + await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) + expect(view.container.querySelector('[data-files-state="tree"]')?.getAttribute('data-files-root')).toBe(ROOT) + expect(view.container.querySelector('[data-files-state="tree"] > div')?.textContent).toBe('app') + expect(names(view.container)).toEqual([`${ROOT}/src`, `${ROOT}/.env`, `${ROOT}/pipe`, `${ROOT}/README.md`]) + }) + + it('labels a separator-only root by the root itself, since it has no final segment', async () => { + const { view, script } = mountBody('/') + await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) + expect(view.container.querySelector('[data-files-state="tree"] > div')?.textContent).toBe('/') + expect(names(view.container)).toEqual(['/src', '/.env', '/pipe', '/README.md']) + }) + + it('a directory click lists that level once and marks it expanded; a second click collapses without asking again', async () => { + const { view, script } = mountBody() + await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) + const dir = view.container.querySelector(`[data-files-path="${ROOT}/src"] > button`)! + act(() => { fireEvent.click(dir) }) + expect(script.list).toHaveBeenLastCalledWith(SESSION, `${ROOT}/src`, expect.any(AbortSignal)) + expect(dir.getAttribute('aria-expanded')).toBe('true') + await act(() => script.settle({ ok: true, value: { entries: [{ name: 'a.ts', type: 'file' }], truncated: false } })) + expect(names(view.container)).toContain(`${ROOT}/src/a.ts`) + act(() => { fireEvent.click(dir) }) + expect(dir.getAttribute('aria-expanded')).toBe('false') + expect(names(view.container)).not.toContain(`${ROOT}/src/a.ts`) + act(() => { fireEvent.click(dir) }) + expect(names(view.container)).toContain(`${ROOT}/src/a.ts`) + expect(script.list).toHaveBeenCalledTimes(2) + }) + + it('a file click opens its session-scoped file: address through the owner; an other entry offers no button', async () => { + const { view, script, tabActions } = mountBody() + await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) + fireEvent.click(view.container.querySelector(`[data-files-path="${ROOT}/README.md"] > button`)!) + // Every row sits under the tree's root, so the address is the path relative to it. + expect(tabActions.openResource).toHaveBeenCalledWith(fileAddressFor(SESSION, ROOT, `${ROOT}/README.md`)) + expect(tabActions.openResource).toHaveBeenCalledWith('dsh-resource://file/session/s-test/README.md') + const other = view.container.querySelector(`[data-files-path="${ROOT}/pipe"]`)! + expect(other.querySelector('button')).toBeNull() + expect(other.querySelector('[aria-disabled="true"]')?.getAttribute('title')).toBe(zh['entry.other']) + }) + + it('marks a cut listing and an empty one', async () => { + const { view, script } = mountBody() + await act(() => script.settle({ ok: true, value: { entries: [{ name: 'd', type: 'directory' }], truncated: true } })) + expect(view.container.querySelector('[data-files-row="truncated"]')?.textContent).toBe(zh.truncated) + act(() => { fireEvent.click(view.container.querySelector(`[data-files-path="${ROOT}/d"] > button`)!) }) + await act(() => script.settle({ ok: true, value: { entries: [], truncated: false } })) + expect(view.container.querySelector('[data-files-row="empty"]')?.textContent).toBe(zh.empty) + }) + + it('shows a failed level under its directory with the failure code', async () => { + const { view, script } = mountBody() + await act(() => script.settle({ + ok: false, + error: new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }), + })) + const failed = view.container.querySelector('[data-files-row="failed"]') + expect(failed?.getAttribute('data-files-code')).toBe('workspace-file/not-found') + expect(failed?.textContent).toBe(zh['error.notFound']) + }) + + it('reload resets every level and lists the expanded ones again', async () => { + const { view, script, controller, instance } = mountBody() + const child = `${ROOT}/src` + const collapsed = `${ROOT}/docs` + await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) + act(() => { fireEvent.click(view.container.querySelector(`[data-files-path="${child}"] > button`)!) }) + await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) + // A level listed earlier and since collapsed is dropped, not re-fetched. + act(() => { instance.actions.loaded(TAB, collapsed, ROOT_LEVEL) }) + script.list.mockClear() + + act(() => { fireEvent.click(view.container.querySelector('[data-files-reload]')!) }) + expect(script.list.mock.calls.map(call => call[1])).toEqual([ROOT, child]) + expect(script.list).toHaveBeenCalledWith(SESSION, ROOT, controller.signal) + const state = instance.getSnapshot().byTab[TAB]! + expect(state.expanded).toEqual([ROOT, child]) + expect(state.levels).toEqual({ [ROOT]: { kind: 'loading' }, [child]: { kind: 'loading' } }) + expect(view.container.querySelector('[data-files-reload]')?.getAttribute('aria-label')).toBe(zh.reload) + }) + + it('an aborted record is forgotten and not seeded again while the body is still mounted', async () => { + const { view, script, controller, instance } = mountBody() + await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) + act(() => { controller.abort() }) + expect(instance.getSnapshot().byTab[TAB]).toBeUndefined() + expect(view.container.querySelector('[data-files-state="tree"]')).toBeNull() + expect(script.list).toHaveBeenCalledTimes(1) + }) +}) + +describe('orderEntries', () => { + it('puts directories first and orders each group by name, numbers included', () => { + const ordered = orderEntries([ + { name: 'file10.txt', type: 'file' }, + { name: 'zeta', type: 'directory' }, + { name: 'file2.txt', type: 'file' }, + { name: '.env', type: 'file' }, + { name: 'Alpha', type: 'directory' }, + { name: 'sock', type: 'other' }, + ]) + expect(ordered.map(entry => entry.name)).toEqual(['Alpha', 'zeta', '.env', 'file2.txt', 'file10.txt', 'sock']) + }) + + it('leaves the endpoint\'s array untouched', () => { + const entries = [{ name: 'b', type: 'file' as const }, { name: 'a', type: 'file' as const }] + orderEntries(entries) + expect(entries.map(entry => entry.name)).toEqual(['b', 'a']) + }) +}) + +describe('failureLine', () => { + const t = makeTranslate(zh) + + it('names each directory failure', () => { + expect(failureLine(t, new RemoteError('workspace-file/not-found', 'x', { path: 'p' }))).toBe(zh['error.notFound']) + expect(failureLine(t, new RemoteError('workspace-file/outside-workspace', 'x', { path: 'p' }))) + .toBe(zh['error.outsideWorkspace']) + expect(failureLine(t, new RemoteError('workspace-file/not-directory', 'x', { path: 'p', kind: 'file' }))) + .toBe(zh['error.notDirectory']) + }) + + it('carries an unclassified failure\'s own message', () => { + const failure = { code: 'remote/transport', message: 'socket closed' } as unknown as RemoteFailure + expect(failureLine(t, failure)).toBe('读取失败:socket closed') + }) +}) diff --git a/packages/client/ui-sidebar-files/tests/mount.client.tsx b/packages/client/ui-sidebar-files/tests/mount.client.tsx new file mode 100644 index 0000000000..389c383a9e --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/mount.client.tsx @@ -0,0 +1,100 @@ +/** + * Mount the body over a real store instance and a scripted listing. + * + * The component reads a handful of its props; the rest of the standard kit is + * framework-injected and never touched here, so one documented cast keeps the + * harness to what is actually exercised. + */ +import { useSyncExternalStore } from 'react' +import { render } from '@testing-library/react' +import type { RenderResult } from '@testing-library/react' +import { vi } from 'vitest' +import type { Mock } from 'vitest' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SidebarRightTabActions } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import { filesFace } from '../src/client/face.ts' +import type { FilesInjected } from '../src/client/face.ts' +import { FilesBody } from '../src/client/FilesBody.tsx' +import type { FilesBodyProps } from '../src/client/FilesBody.tsx' +import { zh } from '../src/client/locales.ts' +import { createFilesStore } from '../src/client/store.ts' +import { scriptedList } from './scripted-list.client.ts' +import type { ScriptedList } from './scripted-list.client.ts' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' + +export const SESSION = 's-test' as SessionId +export const ROOT = '/work/app' +export const TAB = 'tab-1' as TabId + +/** Test-local selector hook over a framework-neutral store instance. */ +function hookOf(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) { + return function useSelector(sel: (s: T) => S): S { + return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) + } +} + +/** A live instance of the tree's store, as the framework would mint one per session. */ +type FilesStoreInstance = ReturnType['create']> + +/** The owner's tab actions as recording mocks. */ +interface MockedTabActions { + readonly openResource: Mock + readonly openTab: Mock + readonly close: Mock +} + +/** What a spec holds after mounting: the rendered view and every hand on the tree. */ +export interface Mounted { + readonly view: RenderResult + readonly instance: FilesStoreInstance + readonly script: ScriptedList + readonly face: FilesInjected + readonly controller: AbortController + readonly tabActions: MockedTabActions +} + +/** One store instance, one face, one owner share. */ +function harness(cwd: string | null) { + const instance = createFilesStore().create() + const script = scriptedList() + const face = filesFace(script.list)(SESSION, instance.actions) + const controller = new AbortController() + const tabActions: MockedTabActions = { + openResource: vi.fn(), + openTab: vi.fn(), + close: vi.fn(), + } + const sessions = { byId: cwd === null ? {} : { [SESSION]: { cwd } } } as unknown as SessionListState + const shared = { + // A page tab's address is the shell's to mint; the body never reads it. + useTabInfo: () => ({ + sidebar: { expanded: true, fullscreen: false }, + panel: { id: 'pane-1' }, + tab: { + id: TAB, kind: 'files', contentId: 'files', title: zh['type.label'], visible: true, + navigation: { address: 'files', params: undefined, revision: 1 }, + signal: controller.signal, + actions: tabActions, + }, + }), + sessionId: SESSION, + useSessions: (sel: (s: SessionListState) => S) => sel(sessions), + useStore: hookOf(instance), + actions: instance.actions, + ...face, + t: makeTranslate(zh), + } + return { instance, script, face, controller, tabActions, shared } +} + +/** + * Mount the body. + * @param cwd - the session's working directory as `useSessions` reports it; `null` for a session without one. + */ +export function mountBody(cwd: string | null = ROOT): Mounted { + const { shared, ...hands } = harness(cwd) + const view = render() + return { ...hands, view } +} diff --git a/packages/client/ui-sidebar-files/tests/scripted-list.client.ts b/packages/client/ui-sidebar-files/tests/scripted-list.client.ts new file mode 100644 index 0000000000..7825afc719 --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/scripted-list.client.ts @@ -0,0 +1,51 @@ +/** A directory listing the spec settles by hand, one deferred result per call. */ +import { vi } from 'vitest' +import type { Mock } from 'vitest' +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' +import type { ListWorkspaceDirectory } from '../src/client/face.ts' +import type { DirLevel } from '../src/client/store.ts' + +/** The scripted listing: the mock the face receives, and the hand that settles it. */ +export interface ScriptedList { + readonly list: Mock + /** + * Settle the oldest outstanding call and let its store write land. + * @param result - what the endpoint answers. + */ + readonly settle: (result: RemoteResult) => Promise + /** + * Settle the newest outstanding call first, so an older one can arrive after it. + * @param result - what the endpoint answers. + */ + readonly settleLatest: (result: RemoteResult) => Promise + /** Paths of calls not yet settled, oldest first. */ + readonly outstanding: () => readonly string[] +} + +/** + * Build a listing whose every call stays pending until the spec settles it. + * @returns the scripted listing. + */ +/** One listing awaiting the spec's answer. */ +interface PendingList { + readonly path: string + resolve(result: RemoteResult): void +} + +export function scriptedList(): ScriptedList { + const pending: PendingList[] = [] + const list = vi.fn((_sessionId, path) => + new Promise((resolve) => { pending.push({ path, resolve }) })) + const land = async (call: PendingList | undefined, result: RemoteResult): Promise => { + if (call === undefined) throw new Error('no outstanding listing to settle') + call.resolve(result) + await Promise.resolve() + await Promise.resolve() + } + return { + list, + settle: result => land(pending.shift(), result), + settleLatest: result => land(pending.pop(), result), + outstanding: () => pending.map(call => call.path), + } +} diff --git a/packages/client/ui-sidebar-files/tests/store.client.spec.ts b/packages/client/ui-sidebar-files/tests/store.client.spec.ts new file mode 100644 index 0000000000..ea364c564e --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/store.client.spec.ts @@ -0,0 +1,95 @@ +/** + * The tree's write set, one tab at a time. + * + * Two facts here are load-bearing for the body: a collapsed level keeps what it + * loaded (reopening draws at once), and `reset` clears levels while keeping the + * expanded set, which is what lets the reload gesture know which levels to ask + * for again. + */ +import { describe, expect, it } from 'vitest' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { createFilesStore } from '../src/client/store.ts' +import type { DirLevel } from '../src/client/store.ts' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' + +const ROOT = '/work/app' +const TAB = 'tab-1' as TabId + +const LEVEL: DirLevel = { + entries: [{ name: 'src', type: 'directory' }, { name: 'README.md', type: 'file', size: 12 }], + truncated: false, +} + +describe('createFilesStore', () => { + it('mints an independent instance per call', () => { + const first = createFilesStore().create() + const second = createFilesStore().create() + first.actions.start(TAB, ROOT) + expect(second.getSnapshot().byTab[TAB]).toBeUndefined() + }) + + it('seeds a tab at its root with the root expanded and nothing loaded', () => { + const store = createFilesStore().create() + const { actions } = store + const getSnapshot = (): ReturnType => store.getSnapshot() + actions.start(TAB, ROOT) + expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT] }) + }) + + it('walks one level through loading, ready, and failed', () => { + const store = createFilesStore().create() + const { actions } = store + const getSnapshot = (): ReturnType => store.getSnapshot() + actions.start(TAB, ROOT) + actions.loading(TAB, ROOT) + expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'loading' }) + actions.loaded(TAB, ROOT, LEVEL) + expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) + const failure = new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }) + actions.failed(TAB, ROOT, failure) + expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'failed', failure }) + }) + + it('toggles a directory in and out of the expanded set without touching its level', () => { + const store = createFilesStore().create() + const { actions } = store + const getSnapshot = (): ReturnType => store.getSnapshot() + const child = `${ROOT}/src` + actions.start(TAB, ROOT) + actions.loaded(TAB, child, LEVEL) + actions.toggled(TAB, child) + expect(getSnapshot().byTab[TAB]!.expanded).toEqual([ROOT, child]) + actions.toggled(TAB, child) + expect(getSnapshot().byTab[TAB]!.expanded).toEqual([ROOT]) + // Collapsing keeps the listing, so reopening draws without another fetch. + expect(getSnapshot().byTab[TAB]!.levels[child]).toEqual({ kind: 'ready', level: LEVEL }) + }) + + it('reset drops every level and keeps the expanded set', () => { + const store = createFilesStore().create() + const { actions } = store + const getSnapshot = (): ReturnType => store.getSnapshot() + const child = `${ROOT}/src` + actions.start(TAB, ROOT) + actions.loaded(TAB, ROOT, LEVEL) + actions.toggled(TAB, child) + actions.loaded(TAB, child, LEVEL) + actions.reset(TAB) + expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT, child] }) + }) + + it('refuses to write a level for a tab that was never started', () => { + const { actions } = createFilesStore().create() + expect(() => { actions.loading('tab-nowhere' as TabId, ROOT) }).toThrow('no tree for tab "tab-nowhere"') + }) + + it('forget removes exactly the tab that went away', () => { + const store = createFilesStore().create() + const { actions } = store + const getSnapshot = (): ReturnType => store.getSnapshot() + actions.start(TAB, ROOT) + actions.start('tab-2' as TabId, ROOT) + actions.forget(TAB) + expect(Object.keys(getSnapshot().byTab)).toEqual(['tab-2']) + }) +}) diff --git a/packages/client/ui-sidebar-files/tsconfig.json b/packages/client/ui-sidebar-files/tsconfig.json new file mode 100644 index 0000000000..97ba9441ec --- /dev/null +++ b/packages/client/ui-sidebar-files/tsconfig.json @@ -0,0 +1,48 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../core/session" + }, + { + "path": "../locale" + }, + { + "path": "../store" + }, + { + "path": "../ui-dockkit" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-session" + }, + { + "path": "../ui-sidebar-right" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../util/workspace-path" + }, + { + "path": "../../api/workspace-files/tsconfig.client.json" + } + ] +} diff --git a/packages/client/ui-sidebar-files/tsdown.config.ts b/packages/client/ui-sidebar-files/tsdown.config.ts new file mode 100644 index 0000000000..15e759c557 --- /dev/null +++ b/packages/client/ui-sidebar-files/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-files', ['lib/types/index.js']) diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml new file mode 100644 index 0000000000..8d11c28b28 --- /dev/null +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md +README.md: feb75c6184098f54961ca161b4dc5ca5d0c7fc78 +README.zh.md: 03c841dfdaac970e938cf934e7f09d151268be8b diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md new file mode 100644 index 0000000000..feb75c6184 --- /dev/null +++ b/packages/client/ui-sidebar-right/README.md @@ -0,0 +1,133 @@ +--- +description: "The right Sidebar of the dsh web client: one docking surface per session, two presentations, the navigation controller ctx.sidebarRight, the tab-type registry ctx.sidebarRightTabs, and the Tab domain." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-sidebar-right + +English | [中文](README.zh.md) + +## Summary + +The right Sidebar: where the docking kit meets this product. It holds one docking surface per session, draws it as one edge-anchored panel in the frame's right column in either of two presentations, puts the expand button in the conversation header, and owns the navigation controller (`ctx.sidebarRight`), the tab-type registry (`ctx.sidebarRightTabs`), and the Tab domain that tells each open tab how it was navigated to and how long it lives. + +## Table of Contents + +- [What lives here, and what does not](#what-lives-here-and-what-does-not) +- [Presentations](#presentations) +- [The expand button](#the-expand-button) +- [State](#state) +- [Extension seats](#extension-seats) +- [`ctx.sidebarRight`](#ctxsidebarright) +- [The Tab domain](#the-tab-domain) +- [The guide](#the-guide) +- [Copy](#copy) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## What lives here, and what does not + +The layout itself — the split tree, its operations, the drag gestures, the floating panels — belongs to `@deepseek-ai/dsh-client-ui-dockkit` and stays host-agnostic. This package supplies everything that kit refuses to know: the product's copy, what a tab's `kind` means, which tab a fresh pane is seeded with, where the surface is mounted, and how other plugins reach it. + + +## Presentations + +Normal and fullscreen presentations share the same content tree, so switching does not remount tabs. The normal panel anchors to the right column; fullscreen covers the viewport while retaining the wide-screen columns underneath. Opening below 768px uses fullscreen automatically; leaving fullscreen on a narrow viewport closes the panel, and widening does not reopen a closed panel. A fullscreen opening keeps the underlying columns unchanged until its slide finishes, then prepares the normal track without a column transition. Before a fullscreen panel retreats, closing prepares a full-width conversation and restoring prepares the normal right track; the background does not animate during the retreat. + +| Mode | The track | The panel | +|---|---|---| +| `push` (default) | Panel width: the conversation makes room | In the track; its left edge and the conversation's right edge travel together, on the frame's own curve | +| `fullscreen` | Retains the wide-screen normal track; automatic narrow-screen fullscreen takes no track | Covers the entire viewport | + +The seat reports presentation through `ctx.layout.openRightbar(track, fullscreen)` / `closeRightbar()`; the frame does not inject this package. Switching fullscreen on a wide viewport leaves the center width unchanged, and the width handle appears only in expanded normal mode. Independent floating panels and `float`/`dock` operations remain available. + +The panel has no header row. Its two controls — the presentation switch and the collapse button — ride the kit's chrome seat at the far end of the top-right pane's tab strip, so the strip is the panel's whole top edge. Each strip reads, left to right: the tabs as capsules with their own close, the add control (drawn only while that pane holds no guide tab; it opens the guide there through `ctx.sidebarRight.openTab`), the pane's split control, and in the top-right pane the two panel controls. Only the chips give way in a narrow pane; the controls after them never shrink or clip. + + +## The expand button + +While the panel is hidden, one button in the conversation header's corner seat (`conversation.session.header.corner`, past the utilities' right edge and level with the Session log control) is the way back in. Its glyph is the left sidebar's collapse icon mirrored. It shares the panel's store (the slot runtime allows one handle across two same-scope seats); while the panel is shown it renders a same-size placeholder, so the corner keeps its width and nothing in the header row moves. A collapsed Sidebar therefore costs the conversation nothing: no rail, no width, and the transcript's scrollbar stays at the column's edge. Without a session there is no button and no panel. + +The panel takes the conversation's ground colour and content font sizes rather than a raised layer of its own: it is a column of the page, not a card over it. + + +## State + +One `SurfaceState` per session id — the layout, its recorded sequence, and how many ids it has minted — held in a store declared at the registration. Every action follows the same shape: mint the ids the intent needs, ask a kit planner which operations carry it out, record them, then assign the session's whole surface back. No action edits a layout in place, which is what keeps the kit's pure functions the only thing that computes one. + +Carrying the mint counter in the surface is what makes a recorded sequence replayable: operations embed the ids they create, so replaying from the same initial state reproduces the same tree. Every action records one history entry, however many operations it needed. Expanding, collapsing, and switching presentation are recorded too. + +After every action the kit's settle planner keeps the surface populated: a docked pane whose last tab was closed, moved out, or floated is merged away, and when only the root pane is left and it is empty, the guide tab is reseeded. There is always at least one tab, and never an empty pane — so there is no separate "close pane" gesture. + +State is memory-only. A reload returns every session to the collapsed default; switching sessions keeps each surface where it was. + + +## Extension seats + +A tab type registers in two stages, and the shipped guide type goes through exactly the same public path a type from another package does (`ui-sidebar-textpreview` is the live proof). Both stages sit inside the type's own `ctx.effect`, so the registration lives exactly as long as the plugin that made it. + +1. **The type** — `ctx.sidebarRightTabs.register({ id, kind, patterns?, priority?, canOpen?, title, guide? })`, a static declaration with no runtime hook, returning a disposer. `id` is this implementation's identity in the tab system, unique across every registration (a package name is the natural value; the shipped guide is `@deepseek-ai/dsh-client-ui-sidebar-right/guide`): a kind is not unique once an extension may take a builtin's over, so the implementation names itself, and a second registration of an `id` throws. A resource type names `patterns`, globs over `dsh-resource://` addresses: one containing `:` matches the whole address (`dsh-resource://file/**`); one without matches the URI's path at any depth, ignoring case (`*.md`), and an address that is not a URI matches no such pattern. A page type — the guide, a file tree — names none and is opened by kind. `canOpen(address)` vetoes a match. `title(address)` is the tab chip's text, captured when the tab opens. `guide` lists entry boxes for the guide page; picking one opens the contributing type as a page. A `kind` carries at most one `builtin` and one `extension` registration (the extension is in force; the builtin resumes when it leaves); any other collision on a kind throws. The `id` is also the key the type's body and title register under, so an extension and the builtin it takes over hold distinct cells and the seat renders the one in force. +2. **The body** — `ctx.slots.register({ name: 'sidebar.right.pane.tab', key: definition.id }, Body)` reads `{ sidebar, panel, tab }` through the framework-injected `useTabInfo()`. `sidebar` supplies expansion and fullscreen information; `panel.id` identifies its pane; `tab` contains the record fields, `visible`, `navigation`, `signal`, and `actions`. These are not parallel owner props; the type's own store still uses `useStore`/`actions`. Optional title registrations and guide replacements share this hook; an absent title registration uses the text captured at open time. + +Which type opens a resource follows the editor-resolver convention: the types whose `patterns` match are ranked by `priority` band — `extension` (a type from outside the product, the highest, and the default when none is named), `builtin`, `fallback` (plain viewers anything more specific should beat) — then by the length of the matched pattern, then by registration order; `canOpen` removes a candidate. The bands are string literals so a type in another package needs no runtime import from here. `candidates(address)` returns the ranking, `claim(address, kind?)` the decision; naming a `kind` skips its globs but keeps its `canOpen`. + +Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) replaces the guide tab's body without replacing the tab, and `sidebar.right.tab.menu.item` (list) appends content-level actions to a tab's menu after the kit's own layout actions. No seat exists for pane-level actions or for collapsed-state controls yet, because nothing needs one. + + +## `ctx.sidebarRight` + +`openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource:///…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a tab already showing the same (kind, contentId) is focused unless `revealIfOpened: false`; otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error. + +`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; the presentation switch is the panel's own control and not part of this face. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws. + + +## The Tab domain + +The Tab domain retains navigation, an abort signal, and bound actions per (Session, tab id). A private assembly callback adopts each Session's store and reconciles records on its commits. Only record removal or plugin unload aborts the signal; closing the sidebar and switching Sessions retain records, while undo restores a new occurrence. `useTabInfo()` composes framework-bound store and navigation hooks without manual component subscriptions or render-time record creation. `tab.actions` always target their own Session; `tab.visible` distinguishes bodies from titles, and floating tabs remain visible when the sidebar closes. `adopt` is absent from the public controller. + + +## The guide + +The guide tab is a centred title, one line under it, and one entry box per `guide` entry the registered types contributed, in `order`. Picking a box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split or an emptied root pane seeds a guide through the kit's factory, one per new pane. A plain `openTab('guide')` keeps the tree-wide reveal every open has. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them. + + +## Copy + +Every string in the column comes from the `sidebarRight` locale namespace, including the kit's accessible names. A tab's title is fixed when the tab is minted; a type's display name follows the current language. + + +## Model Experience + +None, as the package is a browser-side UI plugin layer that registers nothing model-facing. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +- **Memory-only.** Nothing is persisted; a reload starts every session collapsed. +- **No surface without a session.** State is keyed by session id, so the hero screen shows nothing on the right. +- **Hard-coded stacking.** The panel and the float host use fixed z-index values because the client has no z-index token layer yet. +- **Undo is not exposed.** The recorded sequence is stepped only through the `@internal` service methods; product controls are deliberately absent. +- **Guide copy is a draft** awaiting product review; the words live in `locales.ts`. +- **Titles are fixed at open time.** A type's `title(address)` is captured into the record; a live title comes only from the optional title seat. +- **No content navigation stack.** Stepping back replays layout operations; an editor-style back/forward over visited content is not built. + + +### Dev Note + +
    +Working context for maintainers — click to expand + +None. + +
    + +**Runtime invariant:** No companion is published. The two services (`sidebarRight`, `sidebarRightTabs`) are provided through `ctx.reflect.provide` inside one effect and torn down with it; the seat's binding and the Tab domain's occurrence lifetimes are asserted directly by this package's specs, and no independent observation exists to diverge from them. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md new file mode 100644 index 0000000000..03c841dfda --- /dev/null +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -0,0 +1,133 @@ +--- +description: "dsh Web 客户端的右侧 Sidebar:每会话一个停靠面、两种呈现形态、导航控制器 ctx.sidebarRight、tab 类型注册表 ctx.sidebarRightTabs 与 Tab 域。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-sidebar-right + +[English](README.md) | 中文 + +## 概述 + +右侧 Sidebar:停靠套件与本产品相遇的地方。它为每个会话持有一个停靠面,以两种呈现形态之一把它画成贴靠框架右列边缘的一块面板,把展开按钮放进会话 header,并拥有导航控制器(`ctx.sidebarRight`)、tab 类型注册表(`ctx.sidebarRightTabs`),以及告诉每个已开 tab 它是如何被导航到、能活多久的 Tab 域。 + +## 目录 + +- [什么住在这里,什么不住](#what-lives-here-and-what-does-not) +- [呈现形态](#presentations) +- [展开按钮](#the-expand-button) +- [状态](#state) +- [扩展席位](#extension-seats) +- [`ctx.sidebarRight`](#ctxsidebarright) +- [Tab 域](#the-tab-domain) +- [引导页](#the-guide) +- [文案](#copy) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 什么住在这里,什么不住 + +布局本身——分裂树、它的操作、拖拽手势、浮窗——属于 `@deepseek-ai/dsh-client-ui-dockkit`,并保持与宿主无关。本包提供套件拒绝知道的一切:产品文案、tab 的 `kind` 是什么意思、新格用哪个 tab 播种、停靠面挂在哪里、其它插件如何触达它。 + + +## 呈现形态 + +普通与全屏共用同一棵面板内容树,切换不会重挂载Tab。普通面板贴靠右栏;全屏面板覆盖窗口并保留宽屏底层列宽。窗口低于768px时打开右栏自动全屏;窄屏退出全屏会收起右栏,变宽不重新打开已关闭的右栏。 全屏打开时,底层列宽保持不变,直到滑入结束后才无过渡地准备普通轨道。 全屏面板退场前,关闭先准备全宽会话区,恢复先准备普通右轨道;退场期间底层不播放宽度动画。 + +| 形态 | 轨道 | 面板 | +|---|---|---| +| `push`(默认) | 面板宽度:会话区让出空间 | 在轨道内;它的左缘与会话区的右缘沿框架自己的曲线一起移动 | +| `fullscreen` | 保留宽屏普通轨道;窄屏自动全屏不占轨道 | 覆盖整个窗口 | + +席位通过 `ctx.layout.openRightbar(track, fullscreen)` / `closeRightbar()` 报告呈现,框架不注入本包。宽屏切换全屏不改变中栏宽度;宽度拖拽区只在普通展开态显示。独立浮窗及 `float`/`dock` 操作保持可用。 + +面板没有标题行。它的两个控件——形态切换与折叠按钮——搭在套件 chrome 席位上,位于右上格 tab 条的最末端,因此 tab 条就是面板的整条上边。每条 tab 条从左到右读作:作为胶囊、各带关闭按钮的 tab,添加控件(只在该格没有引导 tab 时绘制;它通过 `ctx.sidebarRight.openTab` 在该格打开引导页),该格的分栏控件,以及右上格里的两个面板控件。窄格里只有 chip 让位;其后的控件从不收缩或被裁切。 + + +## 展开按钮 + +面板隐藏时,会话 header 角落席位里的一个按钮(`conversation.session.header.corner`,在工具组右缘之外,与 Session 日志控件齐平)是回去的路。它的图形是左侧 sidebar 折叠图标的镜像。它与面板共用一个存储(slot 运行时允许两个同作用域席位共用一个 handle);面板显示时它渲染一个同尺寸的占位,因此角落保持宽度,header 行里没有东西会移动。于是折叠的 Sidebar 不花会话区任何代价:没有轨条、没有宽度,转录的滚动条留在列的边缘。没有会话就没有按钮也没有面板。 + +面板取会话区的底色与正文字号,而不是自成一层浮起的表面:它是页面的一列,不是压在页面上的卡片。 + + +## 状态 + +每个会话 id 一个 `SurfaceState`——布局、它记录的序列、以及它已铸造的 id 数——保存在注册时声明的存储里。每个动作都遵循同一形态:铸造意图需要的 id,向套件 planner 询问由哪些操作承载,记录它们,然后把该会话的整个停靠面赋回去。没有任何动作就地编辑布局,这正是让套件的纯函数成为唯一计算布局之处的原因。 + +把铸造计数器带在停靠面里,是记录的序列可回放的原因:操作内嵌它们创建的 id,因此从同一初始状态回放能复现同一棵树。每个动作记录一条历史,无论它需要多少操作。展开、折叠与切换形态也都被记录。 + +每个动作之后,套件的 settle planner 保证停靠面有内容:最后一个 tab 被关闭、搬走或浮出的停靠格会被并掉;只剩根格且它为空时,重新播种引导 tab。永远至少有一个 tab,永远没有空格——因此没有单独的「关闭格」手势。 + +状态只在内存中。刷新会让每个会话回到折叠的默认态;切换会话则让每个停靠面留在原处。 + + +## 扩展席位 + +tab 类型分两阶段注册,随包发布的引导类型走的正是别的包的类型走的同一条公开路径(`ui-sidebar-textpreview` 是活的证明)。两个阶段都在类型自己的 `ctx.effect` 里,因此注册与创建它的插件同生共死。 + +1. **类型**——`ctx.sidebarRightTabs.register({ id, kind, patterns?, priority?, canOpen?, title, guide? })`,一份没有运行时钩子的静态声明,返回 disposer。`id` 是这个实现在 tab 系统里的身份,在全部注册中唯一(包名是天然取值;随包引导页是 `@deepseek-ai/dsh-client-ui-sidebar-right/guide`):一旦 extension 可以接管 builtin 的 kind,kind 就不再唯一,所以实现要自己命名,同一 `id` 的第二次注册会 throw。资源类型给出 `patterns`,即作用于 `dsh-resource://` 地址的 glob:含 `:` 的匹配整个地址(`dsh-resource://file/**`);不含的匹配 URI 路径的任意深度且忽略大小写(`*.md`),不是 URI 的地址不匹配任何这类模式。页类型——引导页、文件树——不给出模式,按 kind 打开。`canOpen(address)` 否决一次命中。`title(address)` 是 tab chip 的文字,在 tab 打开时捕获。`guide` 列出引导页的入口框;选中一个即把贡献它的类型作为页打开。一个 `kind` 最多承载一份 `builtin` 与一份 `extension` 注册(extension 生效;它离开后 builtin 恢复);kind 上的其它任何撞名都 throw。`id` 同时也是该类型正文与标题注册时用的 key,因此 extension 与它接管的 builtin 各占一个格位,席位渲染生效的那个。 +2. **正文**——`ctx.slots.register({ name: 'sidebar.right.pane.tab', key: definition.id }, Body)` 通过框架注入的 `useTabInfo()` 读取 `{ sidebar, panel, tab }`。`sidebar` 提供开合与全屏信息,`panel.id` 命名所在格,`tab` 包含原记录字段、`visible`、`navigation`、`signal` 和 `actions`。这些字段不再作为平铺owner props传入;类型自己的store仍使用 `useStore`/`actions`。可选标题注册及引导替换共享该hook;未注册标题时使用打开时保存的文本。 + +由哪个类型打开资源遵循编辑器解析器的惯例:`patterns` 命中的类型先按 `priority` 档排序——`extension`(产品外的类型,最高档,也是未命名时的默认)、`builtin`、`fallback`(任何更具体的类型都应胜过的通用查看器)——再按命中模式的长度,再按注册顺序;`canOpen` 会剔除候选。各档是字符串字面量,因此别的包里的类型不需要从这里做运行时导入。`candidates(address)` 返回排序,`claim(address, kind?)` 返回决定;指定 `kind` 时跳过它的 glob 但保留它的 `canOpen`。 + +另有两个席位扩展已有之物:`sidebar.right.tab.guide`(chain)替换引导 tab 的正文而不替换 tab,`sidebar.right.tab.menu.item`(list)在套件自己的布局动作之后向 tab 菜单追加内容级动作。目前没有面向格级动作或折叠态控件的席位,因为还没有东西需要它。 + + +## `ctx.sidebarRight` + +`openResource(address, options?)` 与 `openTab(kind, options?)` 是导航控制器,进入该列的每条路都调用其中之一:会话区的文件链接与工具行的行号引用(`openResource(fileAddress, { params: { line } })`),tab 条的添加控件与引导入口框(`openTab`),文件树的行(`tab.actions.openResource`)。资源地址是 `dsh-resource:///…` URI;不带 `options.kind` 时由注册表认领(glob 与 `canOpen`,最高档胜出),带它时由该 kind 生效的类型打开。页按 kind 命名;tab 记录在本包拼出、别处无人书写的地址下(`contract/seed.ts`)。两者以同一组步骤作为一条历史运行:已展示同一 (kind, contentId) 的 tab 被聚焦,除非 `revealIfOpened: false`;否则新 tab 落到 `options.replaceTab` 所在的格与位置(并关掉那个 tab),再退而落到 `options.paneId`,再退而落到活跃停靠格;面板展开,因为用户看不到的内容不算打开。随后 Tab 域记录这次导航——`params` 以 `navigation.params` 抵达正文,`revision` 递增——不进布局历史。`params` 按所开之物定型:某资源类型的查看器把自己那项并入 `SidebarRightResourceParamsMap`(文本预览声明 `{ line?: number }`);接受参数的页类型按其 kind 并入 `SidebarRightTabParamsMap`;值约定为 JSON 形状,运行时不校验。`dsh-resource://` 之外的地址、无人认领的地址、或未注册的 kind 都会 throw:那是接线错误,不是用户错误。 + +`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;形态切换是面板自己的控件,不属于这个接口。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。 + + +## Tab 域 + +Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私有装配回调收养各会话的store,并在每次提交时对齐记录。记录消失或插件卸载才中止signal,收起和切会话不销毁记录;undo恢复的是新occurrence。`useTabInfo()` 组合框架绑定的store与导航hook,不在组件中手写订阅或在渲染时创建记录。`tab.actions` 始终作用于自己的会话;`tab.visible` 区分正文与标题,浮窗不受整栏收起影响。`adopt` 不在公开控制器上。 + + +## 引导页 + +引导 tab 是一个居中标题、其下一行说明,以及各已注册类型贡献的每个 `guide` 条目一个入口框,按 `order` 排列。选中一个框会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏或被清空的根格通过套件的工厂播种一个引导页,每个新格一个。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。 达到两格上限时隐藏分栏控件;关闭回单格后恢复。 + + +## 文案 + +该列里的每个字符串都来自 `sidebarRight` 语言命名空间,包括套件的无障碍名称。tab 的标题在 tab 铸造时固定;类型的显示名跟随当前语言。 + + +## 模型体验 + +None, as the package is a browser-side UI plugin layer that registers nothing model-facing. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## 已知限制与延期工作 + + + +- **只在内存中。** 不持久化任何东西;刷新让每个会话从折叠态开始。 +- **没有会话就没有停靠面。** 状态按会话 id 键控,因此 hero 画面右侧什么都不显示。 +- **硬编码的层叠。** 面板与浮窗宿主使用固定的 z-index 值,因为客户端还没有 z-index token 层。 +- **未暴露撤销。** 记录的序列只能通过 `@internal` 服务方法步进;产品控件是有意缺席的。 +- **引导页文案是草稿**,等待产品评审;文字住在 `locales.ts`。 +- **标题在打开时固定。** 类型的 `title(address)` 被捕获进记录;会变的标题只来自可选的标题席位。 +- **没有内容导航栈。** 后退回放的是布局操作;编辑器式的「已访问内容」前进/后退尚未构建。 + + +### 开发备注 + +
    +维护者工作上下文——点击展开 + +无。 + +
    + +**运行时不变量:** 不发布 companion。两个服务(`sidebarRight`、`sidebarRightTabs`)在同一个 effect 内经 `ctx.reflect.provide` 提供并随之拆除;席位绑定与 Tab 域 occurrence 的生命周期由本包的 spec 直接断言,不存在会与之分歧的独立观察。 diff --git a/packages/client/ui-sidebar-right/package.json b/packages/client/ui-sidebar-right/package.json new file mode 100644 index 0000000000..0268a05a33 --- /dev/null +++ b/packages/client/ui-sidebar-right/package.json @@ -0,0 +1,79 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-sidebar-right", + "description": "Right Sidebar: the docking surface's session-bound state, its panel and header expand control, and the navigation service over it", + "version": "0.1.3-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-sidebar-right" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-resources", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-session" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "picomatch": "^4.0.4", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-resources": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/picomatch": "^4.0.2", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-sidebar-right/src/client/contract/params.ts b/packages/client/ui-sidebar-right/src/client/contract/params.ts new file mode 100644 index 0000000000..6b29656839 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/contract/params.ts @@ -0,0 +1,52 @@ +/** + * Navigation parameters, typed by what is being opened. + * + * Two declaration-merged maps. `SidebarRightResourceParamsMap` is keyed by + * resource type — the segment after `dsh-resource://` — and filled by the + * package that owns that type (the `file` provider adds `file: { line?: number }`); + * `SidebarRightTabParamsMap` is keyed by tab kind and filled by a page type that + * takes parameters (neither shipped page does). Values are JSON-shaped by + * convention; nothing validates them at run time, because caller and body meet + * at a typed same-process boundary. A body narrows `navigation.params` by the + * scheme and type of `navigation.address`. + * + * The unions below are spelled as indexed accesses over a record rather than as + * `A | B`: in a program where no package has augmented a map, both sides of such + * a union resolve to `undefined`, which the type-aware lint reads as a duplicated + * constituent. The indexed access names the same union without the pair. + */ + +/** The values of a record, as one union. */ +type ValuesOf = T[keyof T] + +/** Resource type → the parameters a resource of that type accepts. Merge-extensible. */ +export interface SidebarRightResourceParamsMap {} + +/** What `openResource` accepts as `params`: a declared resource type's parameters, or `undefined` for none. */ +export type SidebarRightResourceParams = ValuesOf<{ + declared: SidebarRightResourceParamsMap[keyof SidebarRightResourceParamsMap] + none: undefined +}> + +/** Tab kind → the parameters a page of that kind accepts. Merge-extensible. */ +export interface SidebarRightTabParamsMap {} + +/** + * What `openTab(kind)` accepts as `params` for one kind: its declared parameters, + * or `undefined` for none; a kind that declares none accepts only `undefined`. + */ +export type SidebarRightTabParamsFor = + | (K extends keyof SidebarRightTabParamsMap ? SidebarRightTabParamsMap[K] : never) + | undefined + +/** Every declared page kind's parameters, or `undefined` for none. */ +export type SidebarRightTabParams = ValuesOf<{ + declared: SidebarRightTabParamsMap[keyof SidebarRightTabParamsMap] + none: undefined +}> + +/** What a body may find in `navigation.params`: either map's values, or `undefined` when the opener gave none. */ +export type SidebarRightNavigationParams = ValuesOf<{ + resource: SidebarRightResourceParams + tab: SidebarRightTabParams +}> diff --git a/packages/client/ui-sidebar-right/src/client/contract/seed.ts b/packages/client/ui-sidebar-right/src/client/contract/seed.ts new file mode 100644 index 0000000000..638801a87e --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/contract/seed.ts @@ -0,0 +1,41 @@ +/** + * The guide tab's identity, the page-address scheme, and the seed factory. + * + * These live in the contract because two sides need them and neither may read + * the other: the store seeds every new pane with a guide tab, and the guide + * domain registers the type under the same kind. + * + * The docking kit treats `kind` as opaque, so these strings mean something only + * here and in the registry. Both types go through the same two stages any other + * type would use — the guide is not special in the machinery, only in being + * always available. + */ +import type { TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' + +/** The guide tab's kind. */ +export const GUIDE_KIND = 'guide' + +/** + * The address a page tab is recorded under: `sidebar://`. The scheme is + * this package's bookkeeping for `openTab`, spelled here and nowhere else; a + * caller names the kind and never sees or composes the address. + * @param kind - the page type's kind. + * @returns the page's address. + */ +export function pageAddress(kind: string): string { + return `sidebar://${kind}` +} + +/** + * Build the guide tab a new pane is seeded with. + * + * The title is captured at mint time because it goes into the surface's + * operation sequence, which records what happened and must not change meaning + * later. A language change relabels the type, not tabs already open. + * @param id - tab id minted by the caller. + * @param title - the guide type's display name at mint time. + * @returns the guide tab record. + */ +export function makeGuideTab(id: TabId, title: string): TabRecord { + return { id, kind: GUIDE_KIND, contentId: pageAddress(GUIDE_KIND), title } +} diff --git a/packages/client/ui-sidebar-right/src/client/contract/slots.ts b/packages/client/ui-sidebar-right/src/client/contract/slots.ts new file mode 100644 index 0000000000..49709181c5 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/contract/slots.ts @@ -0,0 +1,170 @@ +/** + * The right Sidebar's extension seats and its copy namespace. + * + * Four seats, each with a different reason to exist: + * - `sidebar.right.pane.tab` is how a tab type contributes a body. It is keyed by + * the type definition's `id`, so adding a type is a registration, never an + * edit here. The key domain stays the open string space because a tab type may + * ship from outside this repository. + * - `sidebar.right.pane.tab.title` is the same dispatch for what the chip shows + * as the tab's title. Registering is optional: without an entry the chip shows + * the title the registry captured when the tab opened. + * - `sidebar.right.tab.guide` lets a product replace the guide tab's contents + * without replacing the tab. It is a chain because the replacement decides for + * itself whether it applies, and the shipped guide is the owner's fallback. + * - `sidebar.right.tab.menu.item` extends a tab's actions menu. The kit owns the + * actions that are gestures on the layout itself; this seat is for actions that + * mean something about the tab's content. + * + * TYPE HOME RATIONALE: this package declares all four at runtime, and anything + * registering into one already depends on it for the declaration. The types + * therefore live with their declarer. + */ +import type {} from '@deepseek-ai/dsh-client-ui-slots' +// The locale plugin's own merge carries the shared `common` vocabulary that the +// lookup chain consults after this namespace misses. +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { PaneId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { SlotHookFactory } from '@deepseek-ai/dsh-client-ui-slots' +import type { TabHookContext } from '../tab-info.ts' +import type { SidebarRightKey } from '../locales.ts' +import type { SidebarRightNavigationParams, SidebarRightResourceParams, SidebarRightTabParamsFor } from './params.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Right-Sidebar chrome, docking-kit vocabulary, and guide copy. */ + sidebarRight: SidebarRightKey + } + + interface SlotMap { + /** + * One tab's body, dispatched with the `id` of the type in force for + * `tab.kind`. A tab type registers here under its definition's `id` and + * receives every tab of that kind, in every pane, docked or floating. A kind + * with no type in force renders the owner's "nothing can view this" notice + * rather than an empty pane. + */ + 'sidebar.right.pane.tab': { + kind: 'keyed' + scope: 'session' + hookContext: TabHookContext + inject: SidebarRightTabInjected + } + /** + * A tab's title as its chip (and a floating panel's header) shows it, + * dispatched with the same key and information hook as the body. A type with a + * live title — a terminal named after its shell, a chat after its first + * line — registers here and reads its own store; one without registers + * nothing and the chip shows the registry's `title(address)` text captured + * at open time. + */ + 'sidebar.right.pane.tab.title': { + kind: 'keyed' + scope: 'session' + hookContext: TabHookContext + inject: SidebarRightTabInjected + } + /** + * The guide tab's body. Selectors run in chain order and the first + * non-declining entry replaces the shipped guide entirely; with no entry, or + * with every entry declining, the shipped guide renders. + */ + 'sidebar.right.tab.guide': { + kind: 'chain' + scope: 'session' + hookContext: UseSidebarRightTabInfo + inject: { hooks: { tabInfo: SlotHookFactory<'sidebar.right.tab.guide', UseSidebarRightTabInfo> } } + } + /** + * Extra items at the end of one tab's actions menu, in registration order. + * Entries decide their own visibility from the tab they are given. Without a + * registrant the menu shows only the kit's own layout actions. + */ + 'sidebar.right.tab.menu.item': { kind: 'list'; scope: 'session'; owner: SidebarRightTabMenuOwnerProps } + } +} + +/** Where a tab was last navigated to: what the `open` that created or revealed it carried. */ +export interface SidebarRightTabNavigation { + /** The address opened; for a tab record this is its `contentId`. */ + readonly address: string + /** The opener's `params` (see `contract/params.ts`); `undefined` when it gave none. */ + readonly params: SidebarRightNavigationParams + /** + * Incremented on every navigation to this tab, whether or not `params` + * changed, so a body can act on "navigated again" alone. `0` for a record + * nobody opened by address: a seeded guide, or a tab restored by undo. + */ + readonly revision: number +} + +/** Where an open from a tab lands. Without any of these it lands in the pane holding the tab at call time. */ +export interface SidebarRightTabPlacement { + /** Land a new tab in this pane instead. */ + readonly paneId?: PaneId + /** Defaults to `true`: a tab already showing the same content is focused instead of a second one opening. */ + readonly revealIfOpened?: boolean + /** `true` opens in this tab's place — its pane and strip slot — and closes this tab in the same step. */ + readonly replaceTab?: boolean +} + +/** The actions one tab may take on itself; each acts on the session the tab is in. */ +export interface SidebarRightTabActions { + /** + * Open a resource from this tab; see `ISidebarRight.openResource`. + * @param address - a `dsh-resource://` address. + * @param options - placement and the resource's navigation parameters. + */ + openResource(address: string, options?: SidebarRightTabPlacement & { readonly params?: SidebarRightResourceParams }): void + /** + * Open a page type from this tab; see `ISidebarRight.openTab`. + * @param kind - the page type's kind. + * @param options - placement and that kind's navigation parameters. + */ + openTab(kind: K, options?: SidebarRightTabPlacement & { readonly params?: SidebarRightTabParamsFor }): void + /** Close this tab. */ + close(): void +} + +/** Live information shared by a tab's body, title, and guide replacement. */ +export interface SidebarRightTabInfo { + readonly sidebar: { + readonly expanded: boolean + /** Presentation selected by manual mode or viewport width; preserved while collapsed. */ + readonly fullscreen: boolean + } + readonly panel: { readonly id: PaneId } + readonly tab: TabRecord & { + /** Docked bodies need an expanded sidebar and an active tab; expanded titles include inactive tabs. Floats stay visible. */ + readonly visible: boolean + readonly navigation: SidebarRightTabNavigation + /** Aborted only when the record disappears or this plugin unloads, not on hide or session switch. */ + readonly signal: AbortSignal + readonly actions: SidebarRightTabActions + } +} + +/** + * Read current tab information through the slot framework's subscriptions. + * @returns the sidebar presentation, containing pane, and live tab record. + */ +export type UseSidebarRightTabInfo = () => SidebarRightTabInfo + +/** The slot-owned hook shared by every tab body and title registration. */ +export interface SidebarRightTabInjected { + hooks: { tabInfo: SlotHookFactory<'sidebar.right.pane.tab', UseSidebarRightTabInfo> } +} + +/** Owner share of one tab-menu item occurrence. */ +export interface SidebarRightTabMenuOwnerProps { + /** The tab whose menu is open. */ + tab: TabRecord + /** + * Dismiss the menu. + * + * An item that acts MUST call this: the menu is the kit's, and it closes on + * its own actions only. An item that leaves it open leaves a menu floating + * over content the action may have just replaced. + */ + dismiss: () => void +} diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts new file mode 100644 index 0000000000..0971b9d21c --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/index.ts @@ -0,0 +1,194 @@ +/** + * Browser half: fill the frame's right column with the panel, put the expand + * button in the conversation header, and own the seats a tab type registers + * into. + * + * Two seats share one session-scoped store, which the slot runtime allows + * because both are session-scoped (a handle may not span scopes). The panel seat + * in the frame draws the surface normally or fullscreen, retaining the track + * on wide viewports; the header's corner seat draws the way back in + * while the panel is hidden. The store is the layout's only source of truth; the docking + * kit's pure planners compute every change and the store records them, one + * history entry per intent. + * + * The frame is a base package and never injects this one. What it needs — + * whether the panel is shown and whether it wants a track — arrives through its + * own `ctx.layout` action face, reported by the seat that knows both facts. + * + * Tab types register in two stages: the type itself into `ctx.sidebarRightTabs`, + * its body into the keyed `sidebar.right.pane.tab` seat under the same kind. The + * guide registers through those stages unmodified, exactly as a type shipped + * from another package does — `ui-sidebar-textpreview` is the live proof. + */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-resources/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type { ILayout } from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type {} from './contract/slots.ts' +import { GuideBody, type GuideInjected } from './tabs/guide/GuideBody.tsx' +import { ExpandButton } from './shell/ExpandButton.tsx' +import { RightbarSeat, type SidebarRightInjected } from './shell/SidebarRight.tsx' +import { createSidebarRightController, type SidebarRightController } from './service.ts' +import { SidebarRightTabRegistry } from './tab-registry.ts' +import { createSidebarRightStore } from './stores.ts' +import { en, zh } from './locales.ts' +import { GUIDE_ID, guideDefinition } from './tabs/guide/definition.ts' +import { guideTabInfoFactory, tabInfoFactory } from './tab-info.ts' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' + +export type { RightbarSeatProps, SidebarRightInjected, SidebarRightPresentation } from './shell/SidebarRight.tsx' +export type { GuideBodyProps, GuideInjected } from './tabs/guide/GuideBody.tsx' +export type { ExpandButtonProps } from './shell/ExpandButton.tsx' +export type { SidebarRightState, SurfaceState } from './stores.ts' +export type { + ISidebarRight, SidebarRightBinding, SidebarRightOpenResourceOptions, SidebarRightOpenTabOptions, + SidebarRightPlacement, SurfaceActions, +} from './service.ts' +export type { + SidebarRightGuideBox, SidebarRightGuideEntry, SidebarRightTabClaim, SidebarRightTabDefinition, + SidebarRightTabPriority, +} from './tab-registry.ts' +export type { + SidebarRightTabInfo, SidebarRightTabInjected, UseSidebarRightTabInfo, SidebarRightTabActions, + SidebarRightTabMenuOwnerProps, SidebarRightTabNavigation, SidebarRightTabPlacement, +} from './contract/slots.ts' +export type { + SidebarRightNavigationParams, SidebarRightResourceParams, SidebarRightResourceParamsMap, + SidebarRightTabParams, SidebarRightTabParamsFor, SidebarRightTabParamsMap, +} from './contract/params.ts' +// The layout ids and rectangle the navigation face takes, so a caller needs no import from the kit. +export type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' +export type { PinResource, SidebarRightNavigator, TabOccurrence } from './tab-domain.ts' +export type { SidebarRightKey } from './locales.ts' +export type { OpenContentIntent } from './stores.ts' + +/** This package's copy namespace. */ +const NS = 'sidebarRight' + +/** Required browser services: the slot registry, the frame's panel actions, copy, and the resource model. */ +export const inject = ['slots', 'layout', 'locale', 'resources'] + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Right-Sidebar navigation and presentation face. */ + sidebarRight: SidebarRightController + /** Right-Sidebar tab-type registry (stage one of a tab type's registration). */ + sidebarRightTabs: SidebarRightTabRegistry + } +} + +/** + * Client plugin body: provide the registry and the navigation face, register the + * panel seat and the rail seat over one store with their extension children, and + * register the guide type through the same public two-stage path any other type + * uses. + * @param ctx - client root context carrying the slot registry, the frame's face, and copy. + */ +export function apply(ctx: ClientContext): void { + // The registry and the face it backs are built here, at apply's top level, + // and never inside an effect. A registry other packages register into cannot + // have an effect-internal scope as its host: `register()` adds an effect to + // this fiber, and doing that from another plugin's apply while the effect is + // still the active scope stalls browser boot with no error at all. The + // template this follows (ui-conversation's definition registry) is built at + // its own apply top level for the same reason. + const t = ctx.locale.bind(NS) + const tabs = new SidebarRightTabRegistry(ctx) + const { controller, adopt } = createSidebarRightController( + tabs, + (address, signal) => { ctx.resources.pin(address, signal) }, + ) + const disposeRegistry = ctx.reflect.provide('sidebarRightTabs', tabs) + const disposeService = ctx.reflect.provide('sidebarRight', controller) + // Registered first, so it tears down last: the faces outlive every seat and + // type that reaches for them. provide()'s disposer settles asynchronously; + // teardown is synchronous fire-and-forget, matching ui-layout's root entry. + // Unloading aborts every tab occurrence, which releases every pin. + ctx.effect(() => () => { + controller.tabDomain.dispose() + void disposeService() + void disposeRegistry() + }, 'ui-sidebar-right: service faces') + + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar-right: dictionaries') + + ctx.effect(() => { + const handle = createSidebarRightStore(() => t('tab.guide.title')) + // The runtime mints one instance of this handle per session (the scope key + // is the session id) and caches it per key. Each is adopted as it is minted, + // so a tab's own action reaches its session's store while another session + // is on screen, and that store's commits sync the Tab domain themselves. + const adoptions: Array<() => void> = [] + const store: typeof handle = { + ...handle, + create: (scopeKey) => { + const instance = handle.create(scopeKey) + if (scopeKey !== undefined) adoptions.push(adopt(scopeKey as SessionId, instance)) + return instance + }, + } + const layout: ILayout = ctx.layout + const injected: Omit = { + syncPresentation({ shown, track, fullscreen }) { + if (shown) layout.openRightbar(track, fullscreen) + else layout.closeRightbar() + }, + bindService: binding => controller.bind(binding), + openTab: (kind, options) => { controller.openTab(kind, options) }, + hooks: { tabTypes: { subscribe: listener => tabs.subscribe(listener), getSnapshot: () => tabs.entries() } }, + } + + const disposeTypes = [tabs.register(guideDefinition(t))] + const disposeSeat = ctx.slots.inject('rightbar', () => ctx.slots.register({ + name: 'rightbar', + locale: NS, + children: { + 'sidebar.right.pane.tab': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: tabInfoFactory } } }, + 'sidebar.right.pane.tab.title': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: tabInfoFactory } } }, + 'sidebar.right.tab.menu.item': { kind: 'list', scope: 'session' }, + }, + store, + inject: (sessionId): SidebarRightInjected => ({ + ...injected, + keyedHooks: { tabNavigation: key => controller.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation }, + occurrence: tab => controller.tabDomain.occurrence(sessionId, tab), + }), + }, RightbarSeat)) + // The expand button shares the panel's store: it only needs to know whether + // the panel is expanded, and to ask for it to be. The header's corner seat + // is its own place, past the utilities, so showing and hiding it moves + // nothing else in the row. + const disposeExpand = ctx.slots.inject('conversation.session.header.corner', () => ctx.slots.register({ + name: 'conversation.session.header.corner', + locale: NS, + store, + }, ExpandButton)) + // Stage two for the guide: it declares the chain child it hosts and reads + // the registry's entry boxes, which an ordinary type has no reason to do. + const guideInjected: GuideInjected = { + hooks: { guideEntries: { subscribe: listener => tabs.subscribe(listener), getSnapshot: () => tabs.guide() } }, + } + const disposeGuide = ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab', + key: GUIDE_ID, + locale: NS, + children: { + 'sidebar.right.tab.guide': { + kind: 'chain', scope: 'session', inject: { hooks: { tabInfo: guideTabInfoFactory } }, + }, + }, + inject: () => guideInjected, + }, GuideBody)) + return () => { + disposeGuide() + disposeExpand() + disposeSeat() + for (const dispose of disposeTypes.reverse()) dispose() + for (const release of adoptions) release() + } + }, 'ui-sidebar-right: seats and shipped tab type') +} diff --git a/packages/client/ui-sidebar-right/src/client/labels.ts b/packages/client/ui-sidebar-right/src/client/labels.ts new file mode 100644 index 0000000000..d259eecca7 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/labels.ts @@ -0,0 +1,30 @@ +/** + * The docking kit's vocabulary, in the product's language. + * + * The kit renders no string of its own, so every word a user reads inside it is + * handed over from here. This is a projection of the dictionary, not a second + * home for copy: the strings live in `locales.ts`. + */ +import type { DockLabels } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' + +/** + * Project the dictionary into the kit's label contract. + * + * Called during render, so a language change reaches the kit with the next one — + * the kit caches no copy to invalidate. + * @param t - namespace-bound translate. + * @returns every string the kit renders. + */ +export function dockLabels(t: TranslateNS<'sidebarRight'>): DockLabels { + return { + emptyPane: t('dock.emptyPane'), + splitPane: t('dock.splitPane'), + splitPaneDisabled: t('dock.splitPaneDisabled'), + splitPaneNarrow: t('dock.splitPaneNarrow'), + closeTab: t('dock.closeTab'), + addTab: t('dock.addTab'), + dockFloat: t('dock.dockFloat'), + closeFloat: t('dock.closeFloat'), + } +} diff --git a/packages/client/ui-sidebar-right/src/client/locales.ts b/packages/client/ui-sidebar-right/src/client/locales.ts new file mode 100644 index 0000000000..3e5b697967 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/locales.ts @@ -0,0 +1,50 @@ +/** + * `sidebarRight` namespace dictionaries. + * + * Everything a user reads in this column is here, including the strings handed + * to the docking kit — the kit renders no copy of its own, so its whole + * vocabulary is this package's to own and translate. + */ + +/** Simplified Chinese dictionary and key-set source of truth. */ +export const zh = { + 'chrome.expand': '展开侧栏', + 'chrome.collapse': '收起侧栏', + 'chrome.toFullscreen': '全屏显示侧栏', + 'chrome.exitFullscreen': '退出侧栏全屏', + 'dock.emptyPane': '空面板', + 'dock.splitPane': '向右分栏', + 'dock.splitPaneDisabled': '已达两格上限', + 'dock.splitPaneNarrow': '栏宽不足,拖宽侧栏后再分栏', + 'dock.closeTab': '关闭', + 'dock.addTab': '新标签页', + 'dock.dockFloat': '收回到侧栏', + 'dock.closeFloat': '关闭', + 'tab.guide.title': '开始', + 'tab.unavailable': '这类内容还没有可用的查看方式。', + 'guide.lead': '侧栏用来放你想一直看着的东西。', + 'guide.body': '会话里的文件和产物会开在这一栏,也可以从下面的入口打开。', +} satisfies Record + +/** Right-Sidebar dictionary key union. */ +export type SidebarRightKey = keyof typeof zh + +/** English dictionary, checked against the Chinese key set. */ +export const en = { + 'chrome.expand': 'Open the sidebar', + 'chrome.collapse': 'Close the sidebar', + 'chrome.toFullscreen': 'Show the sidebar fullscreen', + 'chrome.exitFullscreen': 'Exit sidebar fullscreen', + 'dock.emptyPane': 'Empty pane', + 'dock.splitPane': 'Split to the right', + 'dock.splitPaneDisabled': 'Two panes is the limit', + 'dock.splitPaneNarrow': 'Not enough width to split; widen the sidebar', + 'dock.closeTab': 'Close', + 'dock.addTab': 'New tab', + 'dock.dockFloat': 'Send back to the sidebar', + 'dock.closeFloat': 'Close', + 'tab.guide.title': 'Start', + 'tab.unavailable': 'Nothing here can view this kind of content yet.', + 'guide.lead': 'The sidebar holds what you want to keep looking at.', + 'guide.body': 'Files and artifacts from the conversation open in this column; the entries below open more.', +} satisfies Record diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts new file mode 100644 index 0000000000..25e702b612 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -0,0 +1,476 @@ +/** + * `ctx.sidebarRight`: what other plugins may ask of this column. + * + * The surface is per session and its state lives in that session's store + * instance, which the slot runtime mints per session and a root service cannot + * reach on its own. Two paths lead in. The mounted seat publishes its binding — + * session id, bound actions, its surface — for exactly as long as it is mounted, + * and every command on the public face goes through that binding; a command + * arriving with no seat mounted has no session to act on and fails loudly rather + * than writing into a surface nobody is drawing. And the plugin adopts each + * session's store instance as the runtime mints it, so the controller reaches + * any session's store by id and syncs the Tab domain from that store's commits. + * + * A tab's own actions (`tabActions`) aim at the session the tab is in, not at + * the mounted one: they run through that session's adopted store, so a callback + * fired after the user switched sessions still lands where its tab is, and they + * do nothing for a session whose store was never minted. + * + * `openResource` and `openTab` are the navigation controller, and every way + * into the column is a call to one of them: the conversation's file links, a + * tool row's line reference, the strip's add control, a guide entry box, a file + * tree's rows. A resource is claimed through the registry by address; a page is + * named by kind and recorded at the address this package composes for it. Both + * hand the store one settled intent and record the navigation in the Tab + * domain. Placement is the caller's option, never a type's property. + * + * Wiring follows `LayoutController.attachPanels`: the registration hands the + * service its store actions, and the service is the face other plugins hold. + */ +import type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' +import { activeDockPaneId, canSplit, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SidebarRightNavigationParams, SidebarRightResourceParams, SidebarRightTabParamsFor } from './contract/params.ts' +import { pageAddress } from './contract/seed.ts' +import type { SidebarRightTabClaim, SidebarRightTabRegistry } from './tab-registry.ts' +import type { SidebarRightState, SurfaceState } from './stores.ts' +import type { createSidebarRightStore } from './stores.ts' +import { TabDomain, type PinResource } from './tab-domain.ts' + +/** The seat's bound action set. */ +export type SurfaceActions = BoundActions> + +/** One session's store instance as the slot runtime minted it: its actions and its observable snapshot. */ +export interface SidebarRightSurfaceStore { + readonly actions: SurfaceActions + getSnapshot(): SidebarRightState + subscribe(listener: () => void): () => void +} + +/** One adoption of a session's store; the token a release compares against. */ +interface Adoption { + readonly store: SidebarRightSurfaceStore + readonly unsubscribe: () => void +} + +/** + * Create the public controller and the plugin-private store adoption callback. + * Adoption subscribes without reconciling; the first store commit creates occurrences. + * @param tabs - registered tab types. + * @param pin - resource retention for an occurrence's lifetime. + * @returns the controller and a callback releasing exactly its own adoption. + */ +export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin: PinResource): { + controller: SidebarRightController + adopt: (sessionId: SessionId, store: SidebarRightSurfaceStore) => () => void +} { + const adopted = new Map() + const controller = new SidebarRightController(tabs, pin, adopted) + return { + controller, + adopt(sessionId, store) { + adopted.get(sessionId)?.unsubscribe() + const sync = (): void => { + const surface = store.getSnapshot().bySession[sessionId] + if (surface !== undefined) controller.tabDomain.sync(sessionId, surface.layout) + } + const adoption: Adoption = { store, unsubscribe: store.subscribe(sync) } + adopted.set(sessionId, adoption) + return () => { + adoption.unsubscribe() + if (adopted.get(sessionId) === adoption) adopted.delete(sessionId) + } + }, + } +} + +/** Everything a command needs, as the mounted seat sees it. */ +export interface SidebarRightBinding { + /** The session the mounted seat is drawing. */ + readonly sessionId: SessionId + /** The seat's store's bound actions; every action names the session it acts on. */ + readonly actions: SurfaceActions + /** + * The seat's store surfaces as last committed, keyed by session id; the + * mounted session's is `surfaces[sessionId]`, absent before the seat's first + * open. The runtime mints one store per session, so this holds that session. + */ + readonly surfaces: Readonly> + /** + * The room rule's verdict for a docked pane, as the kit last measured it: + * whether two working halves would fit. Unmeasured panes fit. + */ + readonly canSplitPane: (paneId: PaneId) => boolean +} + +/** Where an open lands; every field is optional and the defaults are the common case. */ +export interface SidebarRightPlacement { + /** Land a new tab in this pane instead of the active docked one. */ + readonly paneId?: PaneId + /** Take this tab's place — its pane and its strip slot — and close it in the same step. */ + readonly replaceTab?: TabId + /** + * Defaults to `true`: a tab already showing the same (kind, contentId) is + * focused and handed `params`. `false` opens another tab regardless. + */ + readonly revealIfOpened?: boolean +} + +/** How a caller wants a resource opened. */ +export interface SidebarRightOpenResourceOptions extends SidebarRightPlacement { + /** Name the opening type instead of letting the registry rank claims; its `canOpen` still applies. */ + readonly kind?: string + /** The resource's navigation parameters, typed by resource type; delivered as `navigation.params`. */ + readonly params?: SidebarRightResourceParams +} + +/** How a caller wants a page type opened. */ +export interface SidebarRightOpenTabOptions extends SidebarRightPlacement { + /** That kind's navigation parameters, typed by kind; delivered as `navigation.params`. */ + readonly params?: SidebarRightTabParamsFor +} + +/** The scheme every resource address carries; anything else is not a resource this face opens. */ +const RESOURCE_SCHEME = 'dsh-resource://' + +/** The outward right-Sidebar face (`ctx.sidebarRight`). */ +export interface ISidebarRight { + /** + * Open a resource: claim it, place it, reveal the column, record the navigation. + * + * Without `options.kind` the registry ranks the types whose globs and + * `canOpen` accept the address and the best band wins; with it, that kind's + * type in force opens the address (its `canOpen` still applies). An address + * outside `dsh-resource://`, or one no type will open, is a wiring mistake, + * not a user error, so it throws. The column expands in the same step, + * because content the user cannot see is not opened. + * @param address - a `dsh-resource:///…` address. + * @param options - placement, the opening type, and navigation parameters. + */ + openResource(address: string, options?: SidebarRightOpenResourceOptions): void + /** + * Open a page type by kind: the type in force for it, at the address this + * package records pages under. A kind nothing registered throws. + * @param kind - the page type's kind. + * @param options - placement and that kind's navigation parameters. + */ + openTab(kind: K, options?: SidebarRightOpenTabOptions): void + /** + * Close one tab of the mounted session. + * @param tabId - the tab to close. + */ + close(tabId: TabId): void + /** + * The active tab of the active pane. + * @returns the record, or `undefined` when no seat is mounted. + */ + active(): TabRecord | undefined + /** + * Whether the column is currently showing its panel. + * @returns `true` while expanded; `false` while collapsed to its rail. + */ + isExpanded(): boolean + /** Collapse an expanded column, or expand a collapsed one. Recorded in the sequence. */ + toggleExpanded(): void + /** + * Focus a tab and the pane holding it, raising a floating one. Recorded. + * @param tabId - the tab; one that does not exist is left alone. + */ + focus(tabId: TabId): void + /** + * Split a docked pane to its right and seed the new pane, under the same + * pane budget and room rule as the strip's split control. Recorded when it + * splits. + * @param paneId - the pane to split; defaults to the active docked pane. + * @returns the new pane's id, or `undefined` when nothing was split: the pane + * is missing or floating, the budget is spent, or two halves would not fit. + */ + split(paneId?: PaneId): PaneId | undefined + /** + * Take a docked tab out into a floating panel. Recorded. + * @param tabId - the tab; one that is missing or already floating is left alone. + * @param rect - the panel's rectangle; defaults to the cascade from the last panel. + */ + float(tabId: TabId, rect?: FloatRect): void + /** + * Return a floating panel's tab to the active docked pane. Recorded. + * @param paneId - the floating pane; one that is missing or docked is left alone. + */ + dock(paneId: PaneId): void +} + +/** Cross-plugin right-Sidebar face (ctx.sidebarRight). */ +export class SidebarRightController implements ISidebarRight { + private binding: SidebarRightBinding | undefined + + /** + * The Tab domain this controller navigates into; synced from each adopted + * store's commits, read by the seat for each body's owner share. + */ + readonly tabDomain: TabDomain + + /** + * @param tabs - the tab-type registry consulted to claim an address. + * @param pin - `ctx.resources.pin`, which the Tab domain holds addresses with. + * @param adopted - plugin-owned session stores used by occurrence actions. + */ + constructor( + private readonly tabs: SidebarRightTabRegistry, + pin: PinResource, + private readonly adopted = new Map(), + ) { + this.tabDomain = new TabDomain(this, pin) + } + + /** + * Adopt the mounted seat's binding, replacing any previous one. + * + * Called from the seat while it is mounted, and released when it leaves. + * @param binding - the mounted seat's session, actions, and the store's surfaces. + * @returns a release callback that clears exactly this binding. + */ + bind(binding: SidebarRightBinding): () => void { + this.binding = binding + return () => { + // A newer seat may already have taken over; only the binding that is + // still ours may be cleared. + if (this.binding === binding) this.binding = undefined + } + } + + /** + * Open a resource: claim it, place it, reveal the column, record the navigation. + * @param address - a `dsh-resource:///…` address. + * @param options - placement, the opening type, and navigation parameters. + */ + openResource(address: string, options: SidebarRightOpenResourceOptions = {}): void { + const { sessionId, actions } = this.require() + this.placeResource(sessionId, actions, address, options) + } + + /** + * Open a page type by kind at the address this package records pages under. + * @param kind - the page type's kind. + * @param options - placement and that kind's navigation parameters. + */ + openTab(kind: K, options: SidebarRightOpenTabOptions = {}): void { + const { sessionId, actions } = this.require() + this.placeTab(sessionId, actions, kind, options) + } + + /** + * Open a resource in one session, for a tab's own action; nothing happens + * for a session whose store was never adopted or whose adoption was released. + * Not part of `ISidebarRight`: the Tab domain's path. + * @param sessionId - the session the acting tab is in. + * @param address - a `dsh-resource:///…` address. + * @param options - placement, the opening type, and navigation parameters. + */ + openResourceIn(sessionId: SessionId, address: string, options: SidebarRightOpenResourceOptions = {}): void { + const actions = this.actionsFor(sessionId) + if (actions !== undefined) this.placeResource(sessionId, actions, address, options) + } + + /** + * Open a page type in one session, for a tab's own action; nothing happens + * for a session whose store was never adopted or whose adoption was released. + * Not part of `ISidebarRight`: the Tab domain's path. + * @param sessionId - the session the acting tab is in. + * @param kind - the page type's kind. + * @param options - placement and that kind's navigation parameters. + */ + openTabIn(sessionId: SessionId, kind: K, options: SidebarRightOpenTabOptions = {}): void { + const actions = this.actionsFor(sessionId) + if (actions !== undefined) this.placeTab(sessionId, actions, kind, options) + } + + /** + * Close a tab of one session, for the tab's own action; nothing happens + * for a session whose store was never adopted or whose adoption was released. + * Not part of `ISidebarRight`: the Tab domain's path. + * @param sessionId - the session the tab is in. + * @param tabId - the tab to close. + */ + closeIn(sessionId: SessionId, tabId: TabId): void { + const actions = this.actionsFor(sessionId) + if (actions !== undefined) actions.closeTab(sessionId, tabId) + } + + /** Claim a resource and place it in one session; an address outside the scheme or one no type claims throws. */ + private placeResource( + sessionId: SessionId, + actions: SurfaceActions, + address: string, + options: SidebarRightOpenResourceOptions, + ): void { + if (!address.startsWith(RESOURCE_SCHEME)) { + throw new Error(`sidebarRight: no registered tab type claims "${address}"`) + } + this.place(sessionId, actions, this.tabs.claim(address, options.kind), address, options, options.params) + } + + /** Place a page type in one session at the address pages are recorded under; an unregistered kind throws. */ + private placeTab( + sessionId: SessionId, + actions: SurfaceActions, + kind: K, + options: SidebarRightOpenTabOptions, + ): void { + const definition = this.tabs.get(kind) + if (definition === undefined) throw new Error(`sidebarRight: no tab type is registered as "${kind}"`) + const address = pageAddress(kind) + this.place(sessionId, actions, { kind, contentId: address, title: definition.title(address) }, address, options, options.params) + } + + /** The steps both opens share: one store intent, and the navigation record for the tab it settles on. */ + private place( + sessionId: SessionId, + actions: SurfaceActions, + claim: SidebarRightTabClaim, + address: string, + placement: SidebarRightPlacement, + params: SidebarRightNavigationParams, + ): void { + actions.openContent(sessionId, { + kind: claim.kind, + contentId: claim.contentId, + title: claim.title, + ...placement.paneId === undefined ? {} : { paneId: placement.paneId }, + ...placement.replaceTab === undefined ? {} : { replaceTab: placement.replaceTab }, + ...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened }, + }, (tabId) => { this.tabDomain.navigate(sessionId, tabId, { address, params }) }) + } + + /** + * Close one tab of the mounted session. + * @param tabId - the tab to close. + */ + close(tabId: TabId): void { + const { sessionId, actions } = this.require() + actions.closeTab(sessionId, tabId) + } + + /** + * The active tab of the active pane. + * @returns the record, or `undefined` with no mounted surface. + */ + active(): TabRecord | undefined { + const layout = this.mounted()?.layout + if (layout === undefined) return undefined + const { activeTabId } = getPane(layout, layout.activePaneId) + return Object.values(layout.tabs).find(tab => tab.id === activeTabId) + } + + /** + * Whether the column is currently showing its panel. + * @returns `true` while expanded; `false` while collapsed or with no mounted surface. + */ + isExpanded(): boolean { + return this.mounted()?.layout.expanded ?? false + } + + /** Collapse an expanded column, or expand a collapsed one. */ + toggleExpanded(): void { + const { sessionId, actions } = this.require() + actions.toggleExpanded(sessionId) + } + + /** + * Focus a tab and the pane holding it; a missing tab is left alone. + * @param tabId - the tab to focus. + */ + focus(tabId: TabId): void { + const { sessionId, actions } = this.require() + if (this.mounted()?.layout.tabs[tabId] === undefined) return + actions.focusTab(sessionId, tabId) + } + + /** + * Split a docked pane to its right when the budget and the room rule allow. + * @param paneId - the pane to split; defaults to the active docked pane. + * @returns the new pane's id, or `undefined` when nothing was split. + */ + split(paneId?: PaneId): PaneId | undefined { + const { sessionId, actions, canSplitPane } = this.require() + const layout = this.mounted()?.layout + if (layout === undefined) return undefined + const target = paneId ?? activeDockPaneId(layout) + const node = layout.nodes[target] + if (node === undefined || node.kind !== 'pane' || node.host !== 'dock') return undefined + if (!canSplit(layout) || dockPaneIds(layout).length >= 2 || !canSplitPane(target)) return undefined + let created: PaneId | undefined + actions.splitPane(sessionId, target, (id) => { created = id }) + return created + } + + /** + * Take a docked tab out into a floating panel; a missing or floating tab is left alone. + * @param tabId - the tab to float. + * @param rect - the panel's rectangle; defaults to the cascade from the last panel. + */ + float(tabId: TabId, rect?: FloatRect): void { + const { sessionId, actions } = this.require() + const layout = this.mounted()?.layout + if (layout === undefined || layout.tabs[tabId] === undefined) return + if (findTabPane(layout, tabId).host !== 'dock') return + actions.floatTab(sessionId, tabId, rect) + } + + /** + * Return a floating panel's tab to the active docked pane; a missing or docked pane is left alone. + * @param paneId - the floating pane. + */ + dock(paneId: PaneId): void { + const { sessionId, actions } = this.require() + const node = this.mounted()?.layout.nodes[paneId] + if (node === undefined || node.kind !== 'pane' || node.host !== 'float') return + actions.unfloatPane(sessionId, paneId) + } + + /** + * Step the mounted session's surface back one intent. + * + * @internal Not part of the product: the sequence is an architectural fact + * with no user-facing control yet. Kept reachable for tests. + */ + _undo(): void { + const { sessionId, actions } = this.require() + actions.undo(sessionId) + } + + /** + * Step the mounted session's surface forward one intent. + * + * @internal See `_undo`. + */ + _redo(): void { + const { sessionId, actions } = this.require() + actions.redo(sessionId) + } + + /** The mounted session's surface; `undefined` without a seat or before its first open. */ + private mounted(): SurfaceState | undefined { + const { binding } = this + return binding === undefined ? undefined : binding.surfaces[binding.sessionId] + } + + /** + * The store actions a tab's own action on `sessionId` runs through: that + * session's adopted store. `undefined` — nothing to act on — for a session + * whose store was never minted or whose adoption was released. + */ + private actionsFor(sessionId: SessionId): SurfaceActions | undefined { + return this.adopted.get(sessionId)?.store.actions + } + + private require(): SidebarRightBinding { + // Reads answer for the no-session case (there is nothing expanded), but a + // write has no session to write to. Callers are UI gestures and tool + // results, both of which belong to a session that is on screen. + if (this.binding === undefined) { + throw new Error('sidebarRight: no session surface is mounted') + } + return this.binding + } +} diff --git a/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css b/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css new file mode 100644 index 0000000000..d13872a336 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css @@ -0,0 +1,38 @@ +/* + * Sits in the conversation header's corner seat, level with the Session log + * control, so it takes that row's 32px height and the same hover fill; square + * with the header's corner radius rather than a pill, because it is an icon + * button, not a labelled one. The placeholder is the same box with nothing in + * it, so the seat's width holds while the panel is shown. + */ +.placeholder { + display: inline-block; + flex: none; + width: 32px; + height: 32px; +} + +.button { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + color: var(--dsw-alias-label-secondary); + background: transparent; + border: none; + border-radius: 8px; + cursor: pointer; +} + +.button:hover { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +/* The left sidebar's panel icon, mirrored: the divider moves to the right side. */ +.icon { + transform: scaleX(-1); +} diff --git a/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx b/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx new file mode 100644 index 0000000000..a106bba675 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx @@ -0,0 +1,48 @@ +/** + * The way into a hidden panel: one button in the conversation header's corner + * seat, shown only while the panel is collapsed. + * + * It lives in the conversation's own header rather than in the frame's right + * column so that a collapsed Sidebar costs the conversation nothing — no rail, + * no width, and the transcript's scrollbar stays at the column's edge. The + * corner seat is its own, past the utilities' edge, so the button neither joins + * the utilities row nor moves it: while the panel is shown this renders a + * same-size placeholder, and the seat's width stays reserved. It shares the + * panel's per-session store, which the slot runtime allows because both seats + * are session-scoped. + * + * The glyph is the left sidebar's collapse icon mirrored: the same affordance, + * on the other edge. + */ +import type { ReactNode } from 'react' +import { IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { createSidebarRightStore } from '../stores.ts' +import css from './ExpandButton.module.css' + +/** The button's props: the header corner seat, the shared store, and copy. */ +export type ExpandButtonProps = + & PropsRuntime<'conversation.session.header.corner'> + & PropsStore> + & PropsLocale<'sidebarRight'> + +/** The expand control while the panel is collapsed; its footprint while it is shown. */ +export function ExpandButton({ sessionId, useStore, actions, t }: ExpandButtonProps): ReactNode { + // A session with no surface yet is collapsed: the panel seat materializes the + // surface on its own mount, and until then there is nothing expanded. + const expanded = useStore(state => state.bySession[sessionId]?.layout.expanded ?? false) + if (expanded) return + return ( + + ) +} diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css new file mode 100644 index 0000000000..1940785098 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css @@ -0,0 +1,108 @@ +/* + * The Sidebar's own chrome only: the panel box, its two strip-end controls, and + * the placeholder tab bodies. Everything inside the split tree is styled by the + * docking kit; the expand button in the conversation header has its own sheet. + * Tokens only. + */ + +/* + * One content tree uses the frame's width normally and fills the viewport in + * fullscreen. The frame retains a shown wide panel's track in both modes. + * + * Hidden, the panel is translated off the frame's right edge rather than + * unmounted, so showing and hiding are one slide in both presentations. The + * transform rides the same variables as the frame's track transition (ui-theme + * base.css); while squeezing, the panel's left edge and the conversation's right + * edge therefore travel together. Visibility flips after the slide so the box + * is out of reach once off-edge, and flips back before the slide starts. + * + * The normal panel is below frame overlays (20); fullscreen (40) covers the + * frame and remains below independently floating panels (60). + */ +.panel { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 10; + display: flex; + flex-direction: column; + min-width: 0; + /* The same ground as the conversation: this is a column of the page, not a + raised surface. */ + background: var(--dsw-alias-bg-base); + border-left: 0.5px solid var(--dsw-alias-border-l1); + transform: translateX(100%); + visibility: hidden; + transition: + transform var(--ds-transition-duration-slow) var(--ds-ease-in-out), + visibility 0s linear var(--ds-transition-duration-slow); +} + +.panel[data-sidebar-right-open] { + transform: none; + visibility: visible; + transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out); +} + +.panel[data-sidebar-right-panel='fullscreen'] { + position: fixed; + inset: 0; + z-index: 40; + border: none; +} + +@media (prefers-reduced-motion: reduce) { + .panel, + .panel[data-sidebar-right-open] { + transition: none; + } +} + +.iconButton { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 1; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.iconButton:hover { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +.panelBody { + display: flex; + flex: 1 1 auto; + min-height: 0; +} + +.unavailable { + margin: 0; + color: var(--dsw-alias-label-tertiary); + font-size: var(--dsh-content-font-size-secondary, 13px); +} + +/* + * Host for the portalled floating panels. Panels leave the column so they can + * cross it and the conversation, which puts them beside the app root rather than + * inside it — and a sibling of the root does not inherit its stacking, so the + * level is stated here. The value sits above the frame's own overlay layer; + * there is no z-index token layer to draw from yet. + */ +.floatHost { + position: fixed; + inset: 0; + z-index: 60; + pointer-events: none; +} diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx new file mode 100644 index 0000000000..ec0c9333e4 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -0,0 +1,423 @@ +/** + * The Sidebar's seat in the frame, and the panel it draws. + * + * The frame owns the right column's geometry; this package owns one content + * tree at the column width or fixed across the viewport. A shown wide panel + * retains its track in fullscreen, preserving the conversation width. Below + * 768px fullscreen is derived from viewport width, without changing manual mode. + * + * The panel stays mounted while collapsed, translated off the frame's right + * edge, so opening and closing are one gesture in both presentations: a slide + * from and to that edge. Normal presentation moves the frame's tracks with + * the panel. A fullscreen opening reserves its underlying track only after + * the panel covers the frame, without animating those hidden columns. + * + * The panel has no header of its own: its two controls — presentation switch + * and collapse — ride the docking kit's chrome seat at the end of the top-right + * pane's tab strip, so the strip is the panel's whole top edge. The way back in + * while collapsed is not here either: it is one button in the conversation + * header (`ExpandButton.tsx`), because it exists only while this panel is + * hidden. Floating panels portal out because they must cross the column and the + * conversation, and the kit already positions them in viewport coordinates. + * + * Tab bodies do not live here. Each one is a registration under its type's kind, + * dispatched through the keyed `sidebar.right.pane.tab` seat (and a live chip + * title through `sidebar.right.pane.tab.title`), so a new tab type needs no edit + * to this file. What a body receives beyond the record — navigation, lifetime + * signal, actions — is read through the slot-owned useTabInfo hook. The Tab + * domain follows each session's store commits, including sessions off screen. + */ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import type { ReactNode, RefObject } from 'react' +import { createPortal } from 'react-dom' +import type { + HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, +} from '@deepseek-ai/dsh-client-ui-slots' +// The frame declares the `rightbar` seat this component fills. +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type { DockIntents, DockMode, FloatRect, TabId, TabRecord, TabRenderer } from '@deepseek-ai/dsh-client-ui-dockkit' +import { canSplit, dockPaneIds, DockSurface, findPaneContentTab, FloatLayer } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { HalvesFit, LayoutState, PaneId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { GUIDE_KIND, pageAddress } from '../contract/seed.ts' +import { dockLabels } from '../labels.ts' +import type { SidebarRightOpenTabOptions } from '../service.ts' +import type { SidebarRightTabDefinition } from '../tab-registry.ts' +import type { createSidebarRightStore, SurfaceState } from '../stores.ts' +import type { TabOccurrence } from '../tab-domain.ts' +import type { SidebarRightTabNavigation } from '../contract/slots.ts' +import type { TabHookContext } from '../tab-info.ts' +import css from './SidebarRight.module.css' + +/** The store share the seat receives. */ +type Store = PropsStore> + +/** The child seats this component renders. */ +type Children = PropsRenderSlots<'sidebar.right.pane.tab' | 'sidebar.right.pane.tab.title' | 'sidebar.right.tab.menu.item'> + +/** What the panel reports to the frame: drawn or not, and whether it wants a track. */ +export interface SidebarRightPresentation { + /** Whether the panel is drawn at all. */ + readonly shown: boolean + /** Whether the drawn panel wants the conversation to make room for it. */ + readonly track: boolean + /** Whether the panel fills the viewport, independently of its retained track. */ + readonly fullscreen: boolean +} + +/** What this package needs from its host beyond the framework shares. */ +export interface SidebarRightInjected { + /** + * Report the panel's presentation to the frame. + * + * The frame sizes the track and places the resize handle; this only tells it + * the composition of the facts this package owns, and is called whenever that + * composition changes. + */ + readonly syncPresentation: (presentation: SidebarRightPresentation) => void + /** + * Publish this seat's session, actions, and the store's surfaces to `ctx.sidebarRight`. + * + * The service is root-scoped and cannot read a per-entry store, so the only + * honest source is the mounted seat. Held for as long as the seat is mounted. + * @param binding - what a command needs to act on this session, and what a tab's own action needs to act on its. + * @returns a release callback. + */ + readonly bindService: (binding: { + sessionId: SessionId + actions: Store['actions'] + /** Every session's surface as last committed; the mounted one is `surfaces[sessionId]`. */ + surfaces: Readonly> + /** The room rule's verdict for a docked pane, as the kit last measured it. */ + canSplitPane: (paneId: PaneId) => boolean + }) => () => void + /** + * The navigation face's `openTab`, for the strip's add control: a new tab is + * the guide opened by kind, through the same path as every other open. + */ + readonly openTab: (kind: string, options?: SidebarRightOpenTabOptions) => void + readonly hooks: { + readonly tabTypes: HostObservable + } + readonly keyedHooks: { + readonly tabNavigation: (key: string) => HostObservable + } + /** Read a committed record's lifetime; never creates an occurrence. */ + readonly occurrence: (tab: Pick) => TabOccurrence +} + +/** The column seat's props: session scope, so the session arrives as a standard prop. */ +export type RightbarSeatProps = + & PropsRuntime<'rightbar'> + & Children + & Store + & PropsLocale<'sidebarRight'> + & InjectFace + +/** Everything the panel needs, already bound to one session. */ +interface PanelProps { + readonly sessionId: SessionId + readonly surface: SurfaceState + readonly actions: Store['actions'] + readonly t: RightbarSeatProps['t'] + readonly renderSlot: Children['renderSlot'] + readonly openTab: SidebarRightInjected['openTab'] + readonly useTabTypes: RightbarSeatProps['useTabTypes'] + readonly useTabNavigation: RightbarSeatProps['useTabNavigation'] + readonly useStore: Store['useStore'] + readonly occurrence: SidebarRightInjected['occurrence'] + readonly fullscreen: boolean + readonly autoFullscreen: boolean + /** Receives the kit's room-rule readings for the service's `split`. */ + readonly reportRoom: (fits: ReadonlyMap) => void +} + +/** The guide tab one pane holds, if any: a pane holds at most one. */ +function guideIn(layout: LayoutState, paneId: PaneId): TabId | undefined { + return findPaneContentTab(layout, paneId, pageAddress(GUIDE_KIND), GUIDE_KIND) +} + +/** + * Build the kit's intent face for one session out of the store's actions. + * @param sessionId - the session the seat draws; every action is bound to it. + * @param actions - the seat's bound store actions. + * @param openTab - the navigation face's `openTab`, which the strip's add control asks for a guide through. + * @returns the intents the kit reports gestures to. + */ +export function intentsFor(sessionId: SessionId, actions: Store['actions'], openTab: PanelProps['openTab']): DockIntents { + return { + focusTab: (tabId) => { actions.focusTab(sessionId, tabId) }, + focusPane: (paneId) => { actions.focusPane(sessionId, paneId) }, + splitPane: (paneId) => { actions.splitPane(sessionId, paneId) }, + // The guide is unique per pane: the control is drawn only while its pane + // holds none (`canAddTab` below) and asks for one there without regard to + // guides in other panes; the store settles the open on a guide the pane + // already holds, so the ask is idempotent all the same. + addTab: (paneId) => { openTab(GUIDE_KIND, { paneId, revealIfOpened: false }) }, + closeTab: (tabId) => { actions.closeTab(sessionId, tabId) }, + duplicateTab: (tabId) => { actions.duplicateTab(sessionId, tabId) }, + floatTab: (tabId, rect?: FloatRect) => { actions.floatTab(sessionId, tabId, rect) }, + unfloatPane: (paneId) => { actions.unfloatPane(sessionId, paneId) }, + placeTab: (tabId, toPaneId, index) => { actions.placeTab(sessionId, tabId, toPaneId, index) }, + dropTab: (tabId, paneId, zone) => { actions.dropTab(sessionId, tabId, paneId, zone) }, + moveFloat: (paneId, x, y) => { actions.moveFloat(sessionId, paneId, x, y) }, + resizeFloat: (paneId, rect) => { actions.resizeFloat(sessionId, paneId, rect) }, + resizeSplit: (splitId, sizes) => { actions.resizeSplit(sessionId, splitId, sizes) }, + } +} + +/** One tab's slot dispatch: which seat, and what to render when no type registered. */ +interface TabSlotProps extends Pick { + readonly tab: TabRecord + readonly seat: 'sidebar.right.pane.tab' | 'sidebar.right.pane.tab.title' + readonly fallback: ReactNode +} + +/** + * Dispatch one tab's body or title with stable framework hooks and record lifetime. + */ +function TabSlot({ + renderSlot, occurrence, useTabTypes, useTabNavigation, useStore, fullscreen, tab, seat, fallback, +}: TabSlotProps): ReactNode { + const { signal, tabActions } = occurrence(tab) + const definition = useTabTypes(types => types.find(definition => definition.kind === tab.kind)) + const hookContext = useMemo((): TabHookContext => ({ + tabId: tab.id, + title: seat === 'sidebar.right.pane.tab.title', + fullscreen, + signal, + actions: tabActions, + useStore, + useTabNavigation, + }), [tab.id, seat, fullscreen, signal, tabActions, useStore, useTabNavigation]) + return renderSlot(seat, {}, { entryKey: definition?.id ?? tab.kind, fallback, hookContext }) +} + +/** + * Dispatch a tab's body to its registered type. + * + * A kind with no registrant is a real state, not a defect: a session log can + * carry a tab whose type shipped in a plugin that is no longer mounted. Saying so + * is better than an empty pane. + */ +function bodiesFor(panel: PanelProps): TabRenderer { + const { t, ...rest } = panel + // Keyed by record: the kit draws one body per pane in one place, and the + // keyed slot below keys on the type, so two tabs of one kind would otherwise + // share a component instance and its local state (a scroll position, a ref). + return tab => ( + {t('tab.unavailable')}

    } + /> + ) +} + +/** Dispatch a tab's title to its registered type; without one the chip shows the title captured at open time. */ +function titlesFor(panel: PanelProps): TabRenderer { + return tab => +} + +/** Expand-to-viewport glyph. */ +function FullscreenGlyph(): ReactNode { + return ( + + ) +} + +/** Restore-from-fullscreen glyph. */ +function ExitFullscreenGlyph(): ReactNode { + return ( + + ) +} + +/** The collapse glyph. */ +function CloseGlyph(): ReactNode { + return ( + + ) +} + +/** The panel's two controls, placed by the kit at the top-right pane's strip end. */ +function PanelChrome({ sessionId, fullscreen, autoFullscreen, actions, t }: Pick): ReactNode { + const next: DockMode = fullscreen ? 'push' : 'fullscreen' + return ( + <> + + + + ) +} + +/** + * The panel: the docked surface with the two controls in its top-right strip, + * anchored to the frame's right edge and slid off it while collapsed. + */ +function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject }): ReactNode { + const { sessionId, surface, actions, t, renderSlot, openTab, width, reportRoom, fullscreen, autoFullscreen, panelRef } = panel + const { expanded } = surface.layout + return ( + + ) +} + +/** Portal the floating layer out of whichever seat rendered it. */ +function Floats(panel: PanelProps): ReactNode { + const { sessionId, surface, actions, t, openTab } = panel + if (surface.layout.floats.length === 0) return null + return createPortal( +
    + +
    , + document.body, + ) +} + +/** + * The right column's occupant: the panel, anchored to the column's edge and + * shown or hidden by sliding, plus the floating layer. It is also where the + * frame learns the panel's presentation, and where `ctx.sidebarRight` learns + * which session it is acting on, because this is the seat that knows both. + */ +export function RightbarSeat({ + sessionId, width, viewportWidth, canShow, useStore, actions, t, renderSlot, syncPresentation, bindService, openTab, + useTabTypes, useTabNavigation, occurrence, +}: RightbarSeatProps): ReactNode { + // One store instance per session, so this map holds this session's surface. + // The binding published below serves the public face's commands on the + // mounted session; a tab's own actions route through the controller's + // adopted stores instead. + const surfaces = useStore(state => state.bySession) + const surface = surfaces[sessionId] + const shown = surface !== undefined && surface.layout.expanded + const autoFullscreen = viewportWidth < 768 + const fullscreen = autoFullscreen || surface?.layout.mode === 'fullscreen' + const panelRef = useRef(null) + // The kit's room-rule readings, kept in a ref: the service reads them at + // call time through the binding, and a reading never re-renders anything. + const room = useRef>(new Map()) + const reportRoom = useCallback((fits: ReadonlyMap): void => { room.current = fits }, []) + const track = shown && !autoFullscreen + + useEffect(() => { + if (surface === undefined) actions.open(sessionId) + }, [actions, sessionId, surface]) + + useLayoutEffect(() => { + if (shown && !fullscreen && !canShow) actions.setExpanded(sessionId, false) + }, [actions, sessionId, shown, fullscreen, canShow]) + + // Fullscreen leaves the previous column report in force until its own slide + // completes. Normal presentation and zero-duration transitions report before paint. + useLayoutEffect(() => { + let disposed = false + const reportWhenCovered = (): void => { + if (disposed) return + // A shown panel renders unconditionally and attaches its ref before this effect. + const entering = shown && fullscreen + ? (panelRef.current as HTMLDivElement).getAnimations().filter(animation => + 'transitionProperty' in animation && animation.transitionProperty === 'transform' + && animation.playState !== 'finished' && animation.playState !== 'idle') + : [] + if (entering.length === 0) { + syncPresentation({ shown, track, fullscreen }) + return + } + // Cancellation can replace the transition or remove it for reduced motion. + void Promise.allSettled(entering.map(animation => animation.finished)).then(reportWhenCovered) + } + reportWhenCovered() + return () => { disposed = true } + }, [sessionId, shown, track, fullscreen, syncPresentation]) + // Leaving is part of that report: a seat that unmounts with its session must + // hand the track back rather than leave one sized for a surface nobody draws. + useLayoutEffect(() => () => { syncPresentation({ shown: false, track: false, fullscreen: false }) }, [syncPresentation]) + + // Republished on every committed change: the service's readers answer from the + // last commit, and its commands act on the session actually on screen. + useEffect( + () => bindService({ sessionId, actions, surfaces, canSplitPane: paneId => room.current.get(paneId)?.row !== false }), + [bindService, sessionId, actions, surfaces], + ) + // The Tab domain is not synced here: the controller adopted this session's + // store as the runtime minted it and reconciles on the store's own commits, + // on screen or not. + + if (surface === undefined) return null + const panel: PanelProps = { + sessionId, actions, t, renderSlot, surface, openTab, useTabTypes, useTabNavigation, useStore, occurrence, + fullscreen, autoFullscreen, reportRoom, + } + return ( + <> + + + + ) +} diff --git a/packages/client/ui-sidebar-right/src/client/stores.ts b/packages/client/ui-sidebar-right/src/client/stores.ts new file mode 100644 index 0000000000..2301388974 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/stores.ts @@ -0,0 +1,353 @@ +/** + * The store shell over the docking kit: one surface per session, held as plain + * data so the kit's pure functions are the only thing that ever computes a + * layout. + * + * Every action follows the same steps — mint the ids the intent needs, ask the + * kit's planner what operations carry it out, let the settle planner keep every + * pane populated, record it all as one history entry — and then assigns the + * session's whole surface back in one go. Nothing here reaches into a draft to + * edit a layout in place, which is what keeps the kit testable without a store + * and keeps snapshot identity honest. + * + * The settle step is this product's rule, not the kit's: a docked pane never + * stays empty, and the last pane reseeds the guide tab, so there is always at + * least one tab to look at. + * + * A focus that changes nothing — a tab already active in its already-active + * pane, a pane already active — plans nothing and records nothing, whoever + * asks: the kit's chip click and `ctx.sidebarRight.focus` alike. + * + * So is the guide's uniqueness: a pane holds at most one guide tab. Opening the + * guide into a pane that has one focuses it, and a guide dragged, dropped, or + * docked into such a pane merges into it — the arriving guide closes and the + * pane's own is focused. The kit plans none of this; it is decided here before + * its planners run. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { + DockMode, DockZone, FloatRect, History, LayoutOp, LayoutState, Mint, PaneId, SplitId, TabId, +} from '@deepseek-ai/dsh-client-ui-dockkit' +import { + activeDockPaneId, createInitialState, dockPaneIds, EMPTY_HISTORY, findPaneContentTab, findTabPane, + planDropTab, planDuplicateTab, planFloatTab, planOpenContent, planPlaceTab, planResizeSplit, planSetExpanded, + planSetMode, planSettle, planSplitPane, planUnfloatPane, record, replay, stepBack, stepForward, +} from '@deepseek-ai/dsh-client-ui-dockkit' +import { GUIDE_KIND, makeGuideTab, pageAddress } from './contract/seed.ts' + +/** One session's docking surface: the layout, its sequence, and the id counter. */ +export interface SurfaceState { + readonly layout: LayoutState + readonly history: History + /** How many ids this surface has minted; carried so replay stays reproducible. */ + readonly minted: number +} + +/** + * Every session's surface, keyed by session id. + * + * Written mutable because an action receives this type as its draft; the + * immutability that matters is behavioural — actions only ever assign a whole + * new map, never reach into one. + */ +export interface SidebarRightState { + bySession: Record +} + +/** A planner call, as the store needs it: state and a mint in, operations out. */ +type SurfacePlan = (state: LayoutState, mint: Mint) => readonly LayoutOp[] + +/** + * What the navigation controller asks the store to open, the address already + * claimed. Placement is by `replaceTab` first, then `paneId`, then the active pane. + */ +export interface OpenContentIntent { + readonly kind: string + readonly contentId: string + readonly title: string + /** Land a new tab in this pane. */ + readonly paneId?: PaneId + /** Take this tab's pane and slot, and close it in the same entry. */ + readonly replaceTab?: TabId + /** `false` opens another tab even when the identity is already shown; defaults to `true`. */ + readonly revealIfOpened?: boolean +} + +/** A mint that counts, so the surface can carry its position forward. */ +function counting(from: number): { mint: Mint; used: () => number } { + let counter = from + // The mint is where a string becomes a branded id: the prefix names the kind, + // the counter keeps every id of this surface unique. + const mint = ((prefix: string): string => { + counter += 1 + return `${prefix}${counter}` + }) as Mint + return { mint, used: () => counter } +} + +/** + * The surface a session starts with: collapsed, one pane, one guide tab. + * @param seedTitle - the guide type's display name at mint time. + * @returns the initial surface. + */ +export function createSurface(seedTitle: () => string): SurfaceState { + const counter = counting(0) + return { + layout: createInitialState({ next: counter.mint }, id => makeGuideTab(id, seedTitle())), + history: EMPTY_HISTORY, + minted: counter.used(), + } +} + +/** The guide tab a pane holds, if any. */ +function paneGuide(state: LayoutState, paneId: PaneId): TabId | undefined { + return findPaneContentTab(state, paneId, pageAddress(GUIDE_KIND), GUIDE_KIND) +} + +/** Whether a tab is the guide, which a pane holds at most once and which is therefore never copied. */ +function isGuide(state: LayoutState, tabId: TabId): boolean { + return state.tabs[tabId]?.kind === GUIDE_KIND +} + +/** Focus a tab: nothing to plan while it is its pane's active tab and its pane is the active one. */ +function planFocusTab(state: LayoutState, tabId: TabId): readonly LayoutOp[] { + const pane = findTabPane(state, tabId) + return pane.activeTabId === tabId && state.activePaneId === pane.id ? [] : [{ type: 'focusTab', tabId }] +} + +/** Focus a pane: nothing to plan while it is the active one. */ +function planFocusPane(state: LayoutState, paneId: PaneId): readonly LayoutOp[] { + return state.activePaneId === paneId ? [] : [{ type: 'focusPane', paneId }] +} + +/** + * Plan a tab's arrival in a docked pane: a guide arriving where one already is + * merges into it, anything else plans as the kit does. + * @param state - current layout. + * @param tabId - the arriving tab. + * @param toPaneId - the pane it arrives in. + * @param otherwise - the kit's plan for the move. + * @returns the operations. + */ +function arriving(state: LayoutState, tabId: TabId, toPaneId: PaneId, otherwise: () => readonly LayoutOp[]): readonly LayoutOp[] { + if (!isGuide(state, tabId)) return otherwise() + const existing = paneGuide(state, toPaneId) + if (existing === undefined || existing === tabId) return otherwise() + return [{ type: 'closeTab', tabId }, { type: 'focusTab', tabId: existing }] +} + +/** + * Run one planner against a surface, settle what it left behind, and record the + * whole intent as one history entry. + * @param surface - the session's current surface. + * @param plan - the kit planner to consult. + * @param seedTitle - the guide type's display name, for a reseeded root pane. + * @returns the next surface, or the same one when the intent changes nothing. + */ +function advance(surface: SurfaceState, plan: SurfacePlan, seedTitle: () => string): SurfaceState { + const counter = counting(surface.minted) + const planned = plan(surface.layout, counter.mint) + if (planned.length === 0) return surface + // The settle planner reads the state the intent produces, so it is applied + // to a scratch copy first; the record then applies both parts once. + const after = replay(surface.layout, planned) + const settled = planSettle(after, counter.mint, id => makeGuideTab(id, seedTitle())) + const stepped = record(surface.history, surface.layout, [...planned, ...settled]) + return { layout: stepped.state, history: stepped.history, minted: counter.used() } +} + +/** + * Replace one session's surface, leaving every other session by reference. + * + * A session with no surface yet gets its initial one even when the intent + * changes nothing: materializing is itself the change. + */ +function seat( + state: SidebarRightState, + sessionId: string, + seedTitle: () => string, + next: (surface: SurfaceState) => SurfaceState, +): Record { + const existing = state.bySession[sessionId] + const updated = next(existing ?? createSurface(seedTitle)) + return updated === existing ? state.bySession : { ...state.bySession, [sessionId]: updated } +} + +/** Declared write set; each entry is one settled intent. */ +type SidebarRightActions = { + open: (draft: SidebarRightState, sessionId: string) => void + setExpanded: (draft: SidebarRightState, sessionId: string, expanded: boolean) => void + toggleExpanded: (draft: SidebarRightState, sessionId: string) => void + setMode: (draft: SidebarRightState, sessionId: string, mode: DockMode) => void + splitPane: (draft: SidebarRightState, sessionId: string, paneId?: PaneId, settled?: (paneId: PaneId) => void) => void + openContent: ( + draft: SidebarRightState, + sessionId: string, + intent: OpenContentIntent, + settled: (tabId: TabId) => void, + ) => void + duplicateTab: (draft: SidebarRightState, sessionId: string, tabId: TabId) => void + closeTab: (draft: SidebarRightState, sessionId: string, tabId: TabId) => void + focusTab: (draft: SidebarRightState, sessionId: string, tabId: TabId) => void + focusPane: (draft: SidebarRightState, sessionId: string, paneId: PaneId) => void + placeTab: (draft: SidebarRightState, sessionId: string, tabId: TabId, toPaneId: PaneId, index: number) => void + dropTab: (draft: SidebarRightState, sessionId: string, tabId: TabId, paneId: PaneId, zone: DockZone) => void + floatTab: (draft: SidebarRightState, sessionId: string, tabId: TabId, rect?: FloatRect) => void + unfloatPane: (draft: SidebarRightState, sessionId: string, paneId: PaneId) => void + moveFloat: (draft: SidebarRightState, sessionId: string, paneId: PaneId, x: number, y: number) => void + resizeFloat: (draft: SidebarRightState, sessionId: string, paneId: PaneId, rect: FloatRect) => void + resizeSplit: (draft: SidebarRightState, sessionId: string, splitId: SplitId, sizes: readonly number[]) => void + undo: (draft: SidebarRightState, sessionId: string) => void + redo: (draft: SidebarRightState, sessionId: string) => void +} + +/** One direction through the recorded sequence. */ +type HistoryStepper = + (history: History, state: LayoutState) => { history: History; state: LayoutState } | undefined + +/** Step a surface through the history in one direction. */ +function stepped(surface: SurfaceState, step: HistoryStepper): SurfaceState { + const moved = step(surface.history, surface.layout) + return moved === undefined ? surface : { ...surface, layout: moved.state, history: moved.history } +} + +/** + * Create the Sidebar store handle. + * + * The seed title arrives as a thunk rather than a string: a pane is seeded + * whenever one is created, which can be long after the store was built and in a + * language the user has since changed to. + * @param seedTitle - the guide type's display name, read at each mint. + * @returns the handle (spec, type, identity, and factory in one). + */ +export function createSidebarRightStore( + seedTitle: () => string, +): EngineStoreHandle { + return defineStore({ + init: (): SidebarRightState => ({ bySession: {} }), + actions: { + // Materialize a session's surface without changing it, so the first read + // after a session switch sees the collapsed default rather than nothing. + open: (d, sessionId: string) => { d.bySession = seat(d, sessionId, seedTitle, surface => surface) }, + setExpanded: (d, sessionId: string, expanded: boolean) => { + d.bySession = seat(d, sessionId, seedTitle, s => advance(s, state => planSetExpanded(state, expanded), seedTitle)) + }, + toggleExpanded: (d, sessionId: string) => { + d.bySession = seat(d, sessionId, seedTitle, s => advance(s, state => planSetExpanded(state, !state.expanded), seedTitle)) + }, + // Switching presentation is recorded like any other change, so stepping + // back through the sequence puts the surface back the way it was drawn. + setMode: (d, sessionId: string, mode: DockMode) => { + d.bySession = seat(d, sessionId, seedTitle, s => advance(s, state => planSetMode(state, mode), seedTitle)) + }, + // `settled` reports the pane the split created, synchronously, because + // actions return nothing; it is not called when nothing was split. + splitPane: (d, sessionId: string, paneId?: PaneId, settled?: (paneId: PaneId) => void) => { + d.bySession = seat(d, sessionId, seedTitle, (s) => { + const next = advance(s, (state, mint) => dockPaneIds(state).length >= 2 + ? [] + : planSplitPane(state, mint, paneId, id => makeGuideTab(id, seedTitle())), seedTitle) + if (settled !== undefined && next !== s) { + const before = new Set(dockPaneIds(s.layout)) + for (const id of dockPaneIds(next.layout)) { + if (!before.has(id)) settled(id) + } + } + return next + }) + }, + // One entry carries the whole open: revealing the column (an open behind + // a collapsed panel is not an open), focusing or seating the tab, and + // closing the tab it replaces. `settled` reports the tab the planner + // landed on, synchronously, because actions return nothing. + openContent: (d, sessionId: string, intent, settled) => { + d.bySession = seat(d, sessionId, seedTitle, s => advance(s, (state, mint) => { + const { kind, contentId, title, replaceTab: replace } = intent + const ops: LayoutOp[] = [...planSetExpanded(state, true)] + // A replaced tab lends its pane and slot; one that floats cannot (a + // floating pane holds one tab), so the new tab lands as if unplaced. + const replaced = replace === undefined ? undefined : findTabPane(state, replace) + const lent = replace !== undefined && replaced !== undefined && replaced.host === 'dock' ? replaced : undefined + const paneId = lent?.id ?? intent.paneId + const index = lent === undefined || replace === undefined ? undefined : lent.tabs.indexOf(replace) + // The guide is unique per pane: the pane it would land in may already + // hold one, which is then the tab this open settles on. + const held = kind === GUIDE_KIND ? paneGuide(state, paneId ?? activeDockPaneId(state)) : undefined + const planned = held !== undefined + ? { ops: [{ type: 'focusTab' as const, tabId: held }], tabId: held } + : planOpenContent(state, mint, { + kind, + contentId, + title, + ...paneId === undefined ? {} : { paneId }, + ...index === undefined ? {} : { index }, + ...intent.revealIfOpened === undefined ? {} : { revealIfOpened: intent.revealIfOpened }, + }) + ops.push(...planned.ops) + if (replace !== undefined && replace !== planned.tabId) ops.push({ type: 'closeTab', tabId: replace }) + settled(planned.tabId) + return ops + }, seedTitle)) + }, + // The guide is never copied: the copy would sit beside it in the same pane. + duplicateTab: (d, sessionId: string, tabId: TabId) => { + d.bySession = seat(d, sessionId, seedTitle, s => + advance(s, (state, mint) => isGuide(state, tabId) ? [] : planDuplicateTab(state, mint, tabId).ops, seedTitle)) + }, + // A tab already gone — closed twice by a racing callback and the user — is + // left alone rather than handed to the kit, which refuses an unknown tab. + closeTab: (d, sessionId: string, tabId: TabId) => { + d.bySession = seat(d, sessionId, seedTitle, s => + advance(s, state => state.tabs[tabId] === undefined ? [] : [{ type: 'closeTab', tabId }], seedTitle)) + }, + focusTab: (d, sessionId: string, tabId: TabId) => { + d.bySession = seat(d, sessionId, seedTitle, s => advance(s, state => planFocusTab(state, tabId), seedTitle)) + }, + focusPane: (d, sessionId: string, paneId: PaneId) => { + d.bySession = seat(d, sessionId, seedTitle, s => advance(s, state => planFocusPane(state, paneId), seedTitle)) + }, + placeTab: (d, sessionId: string, tabId: TabId, toPaneId: PaneId, index: number) => { + d.bySession = seat(d, sessionId, seedTitle, s => + advance(s, state => arriving(state, tabId, toPaneId, () => planPlaceTab(state, tabId, toPaneId, index)), seedTitle)) + }, + // Only a centre release lands in the target pane; an edge release makes a + // new pane, where nothing can already be. + dropTab: (d, sessionId: string, tabId: TabId, paneId: PaneId, zone: DockZone) => { + d.bySession = seat(d, sessionId, seedTitle, s => advance(s, (state, mint) => { + if (zone === 'top' || zone === 'bottom') return [] + if (zone !== 'center' && dockPaneIds(state).length >= 2) return [] + const plan = (): readonly LayoutOp[] => planDropTab(state, mint, tabId, paneId, zone) + return zone === 'center' ? arriving(state, tabId, paneId, plan) : plan() + }, seedTitle)) + }, + floatTab: (d, sessionId: string, tabId: TabId, rect?: FloatRect) => { + d.bySession = seat(d, sessionId, seedTitle, s => + advance(s, (state, mint) => planFloatTab(state, mint, tabId, rect).ops, seedTitle)) + }, + // A floating pane holds one tab; docking it back lands in the active + // docked pane, and only a guide is subject to the merge rule there. + unfloatPane: (d, sessionId: string, paneId: PaneId) => { + d.bySession = seat(d, sessionId, seedTitle, s => advance(s, (state) => { + const guide = paneGuide(state, paneId) + const plan = (): readonly LayoutOp[] => planUnfloatPane(state, paneId) + return guide === undefined ? plan() : arriving(state, guide, activeDockPaneId(state), plan) + }, seedTitle)) + }, + moveFloat: (d, sessionId: string, paneId: PaneId, x: number, y: number) => { + d.bySession = seat(d, sessionId, seedTitle, s => advance(s, () => [{ type: 'moveFloat', paneId, x, y }], seedTitle)) + }, + resizeFloat: (d, sessionId: string, paneId: PaneId, rect: FloatRect) => { + d.bySession = seat(d, sessionId, seedTitle, s => advance(s, () => [{ type: 'resizeFloat', paneId, rect }], seedTitle)) + }, + resizeSplit: (d, sessionId: string, splitId: SplitId, sizes: readonly number[]) => { + d.bySession = seat(d, sessionId, seedTitle, s => advance(s, () => planResizeSplit(splitId, sizes, 0.2), seedTitle)) + }, + undo: (d, sessionId: string) => { + d.bySession = seat(d, sessionId, seedTitle, s => stepped(s, stepBack)) + }, + redo: (d, sessionId: string) => { + d.bySession = seat(d, sessionId, seedTitle, s => stepped(s, stepForward)) + }, + }, + }) +} diff --git a/packages/client/ui-sidebar-right/src/client/tab-domain.ts b/packages/client/ui-sidebar-right/src/client/tab-domain.ts new file mode 100644 index 0000000000..53b259584e --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/tab-domain.ts @@ -0,0 +1,184 @@ +/** + * The Tab domain: what a tab record carries that the layout does not. + * + * One `TabOccurrence` per open record, keyed by (session, tab id): where the + * tab was navigated to, an abort signal spanning the record's life, and the + * actions the tab may take on itself. Holding a record also pins its address in + * the resource model, so switching tabs unmounts a body without dropping its + * content. + * + * `sync` reconciles occurrences against one session's layout after every + * commit: a record that appeared is pinned, one that vanished (closed, or its + * open undone) is aborted and dropped. A record restored by undo is a new + * occurrence and is fetched again if the resource model already let it go. The + * controller syncs each session from that session's adopted store on every + * commit, on screen or not, so a record closed from another session's seat is + * aborted on that commit. The slot framework binds the navigation sources for + * each record's `useTabInfo` reader. + */ +import type { LayoutState, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' +import { findTabPane } from '@deepseek-ai/dsh-client-ui-dockkit' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SidebarRightNavigationParams } from './contract/params.ts' +import type { SidebarRightTabActions, SidebarRightTabNavigation, SidebarRightTabPlacement } from './contract/slots.ts' +import type { SidebarRightOpenResourceOptions, SidebarRightOpenTabOptions, SidebarRightPlacement } from './service.ts' + +/** + * The navigation face a tab's actions call back into, aimed at the session the + * tab is in; nothing happens for a session whose store is not adopted. + */ +export interface SidebarRightNavigator { + /** Open a resource in one session; see `ISidebarRight.openResource`. */ + openResourceIn(sessionId: SessionId, address: string, options?: SidebarRightOpenResourceOptions): void + /** Open a page type in one session; see `ISidebarRight.openTab`. */ + openTabIn(sessionId: SessionId, kind: string, options?: SidebarRightOpenTabOptions): void + /** Close a tab of one session. */ + closeIn(sessionId: SessionId, tabId: TabId): void +} + +/** `ctx.resources.pin`: hold an address's content open for as long as `signal` lives. */ +export type PinResource = (address: string, signal: AbortSignal) => void + +/** What one open tab record holds beyond its layout entry. */ +export interface TabOccurrence { + readonly sessionId: SessionId + readonly tabId: TabId + /** Aborted when the record disappears or this package unloads. */ + readonly signal: AbortSignal + /** The latest navigation aimed at the record; `set` on every `navigate`. */ + readonly navigation: SnapshotStore + /** Stable for the occurrence's life, so a body may hold it. */ + readonly tabActions: SidebarRightTabActions +} + +/** An occurrence plus what only the domain touches. */ +interface Held extends TabOccurrence { + readonly controller: AbortController + /** The docked pane holding the record at the last sync; `undefined` while it floats or before any sync. */ + paneId: PaneId | undefined + /** Whether the resource model has been asked to hold the address. */ + pinned: boolean +} + +/** Every session's occurrences. */ +export class TabDomain { + private readonly bySession = new Map>() + + /** + * @param navigator - where tab actions go, aimed at the tab's session; the navigation controller. + * @param pin - `ctx.resources.pin`, called once per occurrence at its first sync. + */ + constructor( + private readonly navigator: SidebarRightNavigator, + private readonly pin: PinResource, + ) {} + + /** + * Reconcile one session's occurrences with its committed layout. + * + * Called by the seat after every commit, and only then: aborting a vanished + * record runs the types' cleanup, which writes their stores. + * @param sessionId - the session whose layout committed. + * @param layout - that session's layout as committed. + */ + sync(sessionId: SessionId, layout: LayoutState): void { + const held = this.session(sessionId) + for (const [tabId, occurrence] of held) { + if (layout.tabs[tabId] !== undefined) continue + held.delete(tabId) + occurrence.controller.abort() + } + for (const tab of Object.values(layout.tabs)) { + const occurrence = held.get(tab.id) ?? this.hold(sessionId, tab.id, { address: tab.contentId, params: undefined, revision: 0 }) + const pane = findTabPane(layout, tab.id) + occurrence.paneId = pane.host === 'dock' ? pane.id : undefined + if (occurrence.pinned) continue + occurrence.pinned = true + this.pin(occurrence.navigation.getSnapshot().address, occurrence.signal) + } + } + + /** + * Read an occurrence created by navigation or committed-store reconciliation. + * @param sessionId - the session the record is in. + * @param tab - the record being drawn. + * @returns its occurrence. + * @throws when the record has not been reconciled or has disappeared. + */ + occurrence(sessionId: SessionId, tab: Pick): TabOccurrence { + const occurrence = this.bySession.get(sessionId)?.get(tab.id) + if (occurrence === undefined) throw new Error(`sidebarRight: tab "${tab.id}" has no committed occurrence in session "${sessionId}"`) + return occurrence + } + + /** + * Record that an `open` settled on a tab. + * + * A record the layout has not yet shown the seat gets its occurrence here, so + * the body's first render already carries the opener's `params`. + * @param sessionId - the session opened into. + * @param tabId - the tab the open settled on. + * @param target - the address and the opener's params. + */ + navigate(sessionId: SessionId, tabId: TabId, target: { address: string; params: SidebarRightNavigationParams }): void { + const existing = this.session(sessionId).get(tabId) + if (existing === undefined) { + this.hold(sessionId, tabId, { ...target, revision: 1 }) + return + } + existing.navigation.set({ ...target, revision: existing.navigation.getSnapshot().revision + 1 }) + } + + /** Abort every occurrence of every session; the package is unloading. */ + dispose(): void { + for (const held of this.bySession.values()) { + for (const occurrence of held.values()) occurrence.controller.abort() + } + this.bySession.clear() + } + + private session(sessionId: SessionId): Map { + let held = this.bySession.get(sessionId) + if (held === undefined) { + held = new Map() + this.bySession.set(sessionId, held) + } + return held + } + + private hold(sessionId: SessionId, tabId: TabId, navigation: SidebarRightTabNavigation): Held { + const controller = new AbortController() + const { navigator } = this + // Where an open from this tab lands, read at call time because the tab may + // have been dragged since: `replaceTab: true` names this tab; otherwise the + // pane holding it, unless the caller named another. + const place = (placement: SidebarRightTabPlacement): SidebarRightPlacement => ({ + ...placement.replaceTab === true + ? { replaceTab: tabId } + : held.paneId === undefined ? {} : { paneId: held.paneId }, + ...placement.paneId === undefined ? {} : { paneId: placement.paneId }, + ...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened }, + }) + const held: Held = { + sessionId, + tabId, + controller, + signal: controller.signal, + navigation: createSnapshotStore(navigation), + paneId: undefined, + pinned: false, + tabActions: { + openResource: (address, options = {}) => { + navigator.openResourceIn(sessionId, address, { ...place(options), params: options.params }) + }, + openTab: (kind, options = {}) => { + navigator.openTabIn(sessionId, kind, { ...place(options), params: options.params }) + }, + close: () => { navigator.closeIn(sessionId, tabId) }, + }, + } + this.session(sessionId).set(tabId, held) + return held + } +} diff --git a/packages/client/ui-sidebar-right/src/client/tab-info.ts b/packages/client/ui-sidebar-right/src/client/tab-info.ts new file mode 100644 index 0000000000..86812e09d1 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/tab-info.ts @@ -0,0 +1,60 @@ +/** Slot-owned tab information derived from framework-bound store and navigation hooks. */ +import { useMemo } from 'react' +import { findTabPane } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { KeyedSnapshotSelectorHook, PropsStore, SlotHookFactory } from '@deepseek-ai/dsh-client-ui-slots' +import type { SidebarRightTabActions, SidebarRightTabNavigation, UseSidebarRightTabInfo } from './contract/slots.ts' +import type { createSidebarRightStore } from './stores.ts' + +/** Stable dispatch identity and framework hooks; never passed as tab component props. */ +export interface TabHookContext { + readonly tabId: TabId + readonly title: boolean + readonly fullscreen: boolean + readonly signal: AbortSignal + readonly actions: SidebarRightTabActions + readonly useStore: PropsStore>['useStore'] + readonly useTabNavigation: KeyedSnapshotSelectorHook +} + +/** + * Bind a tab occurrence without subscribing or creating records during factory evaluation. + * @param standard - framework session identity. + * @param context - stable record lifetime and framework-bound readers. + * @returns the tab information hook. + */ +export const tabInfoFactory: SlotHookFactory<'sidebar.right.pane.tab', UseSidebarRightTabInfo> = (standard, context) => { + const { sessionId } = standard + const { tabId, title, fullscreen, signal, actions, useStore, useTabNavigation } = context + return function useTabInfo() { + const layout = useStore(state => state.bySession[sessionId]?.layout) + const navigation = useTabNavigation(tabId) + return useMemo(() => { + const tab = layout?.tabs[tabId] + if (layout === undefined || tab === undefined || navigation === undefined) { + throw new Error(`sidebarRight: tab "${tabId}" is not committed in session "${sessionId}"`) + } + const pane = findTabPane(layout, tabId) + return { + sidebar: { expanded: layout.expanded, fullscreen }, + panel: { id: pane.id }, + tab: { + ...tab, + visible: pane.host === 'float' || (layout.expanded && (title || pane.activeTabId === tabId)), + navigation, + signal, + actions, + }, + } + }, [layout, navigation, tabId, title, fullscreen, signal, actions]) + } +} + +/** + * Forward the framework-bound tab hook to a guide replacement. + * @param _standard - the guide's framework standard props. + * @param useTabInfo - the enclosing tab's framework-bound reader. + * @returns the same reader for the replacement. + */ +export const guideTabInfoFactory: SlotHookFactory<'sidebar.right.tab.guide', UseSidebarRightTabInfo> = + (_standard, useTabInfo) => useTabInfo diff --git a/packages/client/ui-sidebar-right/src/client/tab-registry.ts b/packages/client/ui-sidebar-right/src/client/tab-registry.ts new file mode 100644 index 0000000000..9162dc7205 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/tab-registry.ts @@ -0,0 +1,399 @@ +/** + * Stage one of tab-type registration: what a type IS. + * + * A registration is purely static — which addresses the type recognizes, how it + * ranks against other types that recognize the same one, what the tab chip says, + * and whether the type offers an entry box on the guide page. Nothing here is + * per-tab, per-session, or a runtime hook: stage two is the keyed + * `sidebar.right.pane.tab` registration that supplies the body under the same + * `kind`, and everything a body needs at runtime arrives in its props. + * + * Address recognition follows VS Code's editor resolver: a glob declaration + * narrows the candidates, an optional `canOpen` predicate vetoes, and the + * survivors are ranked by priority band, then by matched-pattern length, then by + * registration order. Addresses are `scheme://` URIs; the one local change to + * VS Code's glob rule is that a pattern containing `:` matches the whole address + * (`dsh-resource://file/**`, `sidebar://guide`) rather than the URI's path. + * + * A kind may carry one `builtin` and one `extension` registration at once: the + * extension is the one in force — claims, `get`, the guide page, and the body + * and title, which the seat finds under the definition's own `id` — and the + * builtin resumes when the extension unregisters. Everything else colliding on + * a kind throws, as does a second registration of an `id`. + * + * Thunked copy (`title`, `guide[].title`) is read again on every use, so a + * language change needs no re-registration. + */ +import type { ComponentType } from 'react' +import type { Context } from '@deepseek-ai/cordis' +import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' +// The POSIX build: the browser bundle must not reach for node's `path`, and +// addresses are `/`-separated regardless of the host platform. +import picomatch from 'picomatch/posix' + +/** + * How strongly a type wants an address it recognizes, as one of three literal + * bands (a string, not an imported constant, so a type shipped from another + * package needs no runtime import from here). + * + * - `extension` — a type from outside the product, and the highest: a type that + * declares nothing outranks every viewer shipped here, exactly as in VS Code. + * It is also the band that may take over a `builtin` kind. + * - `builtin` — the ordinary band for types shipped with the product. + * - `fallback` — plain-content viewers that anything more specific should beat. + * VS Code's text editor holds this position implicitly; ours is a separate + * package, so it says so. + */ +export type SidebarRightTabPriority = 'extension' | 'builtin' | 'fallback' + +/** Rank of each band, highest first. */ +const RANKS: Readonly> = { + extension: 3, + builtin: 2, + fallback: 1, +} + +/** The band a definition that names none is in. */ +const DEFAULT_BAND: SidebarRightTabPriority = 'extension' + +/** One entry box the guide page offers, contributed by the type it opens (picking it opens that type as a page). */ +export interface SidebarRightGuideEntry { + /** Ascending position among every registered type's entries. */ + readonly order: number + /** + * The box's heading. + * @returns the heading in the current language. + */ + readonly title: () => string + /** + * One line under the heading. + * @returns the line in the current language. + */ + readonly description: () => string + /** Optional glyph, drawn at the box's leading edge. */ + readonly icon?: ComponentType +} + +/** A guide entry as the registry lists it: with the kind of the type that contributed it, which is what picking it opens. */ +export interface SidebarRightGuideBox extends SidebarRightGuideEntry { + readonly kind: string +} + + +/** One registered tab type: its static face, and nothing else. */ +export interface SidebarRightTabDefinition { + /** + * This implementation's identity in the tab system, unique across every + * registration (a package name is the natural value). A kind is not unique — + * an extension may take a builtin's over — so the implementation carries its + * own name, and it is the key its body and title register under in the + * `sidebar.right.pane.tab` and `sidebar.right.pane.tab.title` seats. + */ + readonly id: string + /** Type discriminator: what the tabs of this type are, and what `openTab` names. */ + readonly kind: string + /** + * Resource-address globs this type recognizes; omit for a page type, which is + * opened by kind and recognizes no address. + * + * A pattern containing `:` is matched against the whole address + * (`dsh-resource://file/**`); one without is matched against the URI's path at + * any depth (`*.md` matches `dsh-resource://file/session/s1/home/me/notes.md`), + * and an address that is not a URI matches no such pattern. Matching ignores + * case and does not hide dotfiles. + */ + readonly patterns?: readonly string[] + /** Defaults to `extension`: a type that says nothing is one from outside the product. */ + readonly priority?: SidebarRightTabPriority + /** + * Veto an address this type's globs matched. + * + * Synchronous and cheap: it runs on every routing decision. Omit it to accept + * every match. + * @param address - the matched address. + * @returns whether this type will open it. + */ + readonly canOpen?: (address: string) => boolean + /** + * The tab chip's initial text, captured into the layout record at open time. + * @param address - the address being opened. + * @returns the title in the current language. + */ + readonly title: (address: string) => string + /** Entry boxes for the guide page. Omit to stay off it. */ + readonly guide?: readonly SidebarRightGuideEntry[] +} + +/** What a routing decision settles on: who draws the address, and as what. */ +export interface SidebarRightTabClaim { + /** The claiming type. */ + readonly kind: string + /** + * Stable identity of the content, which is the address itself. + * + * Two opens of the same address are the same tab, which is what makes opening + * idempotent. + */ + readonly contentId: string + /** Title for the tab chip. */ + readonly title: string +} + +/** A registered type with its patterns compiled. */ +interface Registered { + readonly definition: SidebarRightTabDefinition + readonly band: SidebarRightTabPriority + /** One matcher per declared pattern, in declaration order. */ + readonly matchers: readonly { readonly pattern: string; readonly test: (address: string) => boolean }[] + /** Registration position across every kind, the last tiebreaker. */ + readonly order: number +} + +/** + * Everything registered under one kind: the registration in force and, while + * an `extension` holds a kind a `builtin` also registered, the builtin it + * shadows. A kind is in the registry's map exactly while something is in force + * for it, so a held slot always answers. + */ +interface KindSlot { + inForce: Registered + shadowed: Registered | undefined +} + +/** + * Whether a band may join a held kind: an `extension` and a `builtin` pair up + * once, and a `fallback` shares its kind with nothing. + */ +function coexists(slot: KindSlot, band: SidebarRightTabPriority): boolean { + return band !== 'fallback' && slot.inForce.band !== 'fallback' && slot.inForce.band !== band && slot.shadowed === undefined +} + +/** How a candidate ranked, kept only while `candidates` is sorting. */ +interface Ranked { + readonly definition: SidebarRightTabDefinition + readonly rank: number + /** Length of the longest pattern that matched, VS Code's specificity measure. */ + readonly length: number + /** Registration position, the last tiebreaker. */ + readonly order: number +} + +/** + * The address's URI path: what a pattern with no scheme separator matches + * against. `dsh-resource://file/session/s1/home/me/b.md` gives `/session/s1/home/me/b.md`; + * `sidebar://guide` gives `''`; an address that is not a URI gives nothing. + */ +function pathOf(address: string): string | undefined { + try { + return new URL(address).pathname + } catch { + // The only thrower is the URL parser rejecting a non-URI address, which by + // the rule above matches no path pattern. + return undefined + } +} + +/** Compile one declared pattern into the test the router runs. */ +function matcherFor(pattern: string): (address: string) => boolean { + // `basename: true` is what makes `*.md` match at any depth; it applies only to + // patterns without a separator, which is exactly the path case. + const whole = pattern.includes(':') + const match = picomatch(pattern, { nocase: true, dot: true, ...whole ? {} : { basename: true } }) + return (address) => { + if (whole) return match(address) + const path = pathOf(address) + return path !== undefined && match(path) + } +} + +/** + * The registered tab types. + * + * Registration order is part of the contract: it breaks ties between types that + * recognize an address equally well. + */ +export class SidebarRightTabRegistry { + private readonly kinds = new Map() + private readonly ids = new Set() + private readonly listeners = new Set<() => void>() + private registrations = 0 + private cached: readonly SidebarRightTabDefinition[] = [] + private guideEntries: readonly SidebarRightGuideBox[] = [] + + /** @param ctx - Context whose effects own the contributed types. */ + constructor(private readonly ctx: Context) {} + + /** + * Register one tab type for the caller's lifetime. + * + * The caller holds the returned disposer inside its own `ctx.effect`, so a + * type's registration lives exactly as long as the plugin that contributed it. + * An `extension` may register a kind a `builtin` already holds and takes it + * over until it unregisters; a second registration in the same band, or any + * registration meeting a `fallback` of the same kind, is a wiring mistake, and + * so is an `id` already in use. + * @param definition - the contributed type. + * @returns idempotent disposer. + * @throws when the id is taken, or the kind is already registered in a way this one cannot coexist with. + */ + register(definition: SidebarRightTabDefinition): () => void { + const { id, kind } = definition + const band = definition.priority ?? DEFAULT_BAND + if (this.ids.has(id)) throw new Error(`sidebarRight: tab type id "${id}" is already registered`) + const held = this.kinds.get(kind) + if (held !== undefined && !coexists(held, band)) { + throw new Error(`sidebarRight: tab kind "${kind}" is already registered (${held.inForce.band})`) + } + this.registrations += 1 + const entry: Registered = { + definition, + band, + matchers: (definition.patterns ?? []).map(pattern => ({ pattern, test: matcherFor(pattern) })), + order: this.registrations, + } + const dispose = this.ctx.effect(() => { + this.ids.add(id) + const slot = this.enter(kind, entry) + this.refresh() + return () => { + this.ids.delete(id) + this.leave(kind, slot, entry) + this.refresh() + } + }, `sidebarRight.tabs.register(${JSON.stringify(id)})`) + return () => { void dispose() } + } + + /** Add a registration to its kind's slot, the higher band in force; `coexists` has already admitted it. */ + private enter(kind: string, entry: Registered): KindSlot { + const held = this.kinds.get(kind) + if (held === undefined) { + const slot: KindSlot = { inForce: entry, shadowed: undefined } + this.kinds.set(kind, slot) + return slot + } + if (RANKS[entry.band] > RANKS[held.inForce.band]) { + held.shadowed = held.inForce + held.inForce = entry + } else { + held.shadowed = entry + } + return held + } + + /** Remove a registration from its kind's slot: a shadowed builtin resumes, and an emptied kind is freed. */ + private leave(kind: string, slot: KindSlot, entry: Registered): void { + if (slot.inForce !== entry) { + slot.shadowed = undefined + } else if (slot.shadowed === undefined) { + this.kinds.delete(kind) + } else { + slot.inForce = slot.shadowed + slot.shadowed = undefined + } + } + + /** Every kind's registration in force, in registration order. */ + private active(): Registered[] { + return [...this.kinds.values()].map(slot => slot.inForce).sort((left, right) => left.order - right.order) + } + + /** + * Registered types in registration order. + * @returns reference-stable entries. + */ + entries(): readonly SidebarRightTabDefinition[] { + return this.cached + } + + /** + * Every type in force's guide entries, in `order`, each naming the kind it opens. + * @returns reference-stable entries. + */ + guide(): readonly SidebarRightGuideBox[] { + return this.guideEntries + } + + /** + * The type in force for a kind. + * @param kind - the type discriminator. + * @returns the type, or `undefined` when nothing registered it. + */ + get(kind: string): SidebarRightTabDefinition | undefined { + return this.kinds.get(kind)?.inForce.definition + } + + /** + * Every type that would open an address, best first. + * + * Ranked by priority band, then by the length of the pattern that matched, + * then by registration order. Types whose `canOpen` vetoes are absent. + * @param address - the address a caller wants opened. + * @returns the ranked types; empty when nothing recognizes the address. + */ + candidates(address: string): readonly SidebarRightTabDefinition[] { + const ranked: Ranked[] = [] + for (const { definition, band, matchers, order } of this.active()) { + let length = -1 + for (const matcher of matchers) { + if (matcher.test(address) && matcher.pattern.length > length) length = matcher.pattern.length + } + if (length < 0) continue + if (definition.canOpen !== undefined && !definition.canOpen(address)) continue + ranked.push({ definition, rank: RANKS[band], length, order }) + } + ranked.sort((left, right) => + right.rank - left.rank || right.length - left.length || left.order - right.order) + return ranked.map(entry => entry.definition) + } + + /** + * Decide which type opens an address, and as what. + * + * Without `kind`, the best candidate wins. With `kind`, that type opens the + * address if its `canOpen` agrees — its globs are not consulted, because + * naming the type IS the decision. + * + * An address no type will open is a wiring mistake, not a user error, so this + * throws rather than reporting absence. + * @param address - the address a caller wants opened. + * @param kind - a type named by the caller, overriding the ranking. + * @returns the claiming type and the record to open. + */ + claim(address: string, kind?: string): SidebarRightTabClaim { + if (kind !== undefined) { + const definition = this.get(kind) + if (definition === undefined) { + throw new Error(`sidebarRight: no tab type is registered as "${kind}"`) + } + if (definition.canOpen !== undefined && !definition.canOpen(address)) { + throw new Error(`sidebarRight: tab type "${kind}" refuses "${address}"`) + } + return { kind, contentId: address, title: definition.title(address) } + } + const [chosen] = this.candidates(address) + if (chosen === undefined) { + throw new Error(`sidebarRight: no registered tab type claims "${address}"`) + } + return { kind: chosen.kind, contentId: address, title: chosen.title(address) } + } + + /** + * Observe low-frequency registry changes. + * @param listener - synchronous invalidation callback. + * @returns unsubscribe callback. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + private refresh(): void { + this.cached = this.active().map(entry => entry.definition) + this.guideEntries = this.cached + .flatMap(definition => (definition.guide ?? []).map(entry => ({ ...entry, kind: definition.kind }))) + .sort((left, right) => left.order - right.order) + notifySubscribers(this.listeners, '[ui-sidebar-right] tab registry') + } +} diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css new file mode 100644 index 0000000000..db793e261f --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css @@ -0,0 +1,79 @@ +/* The guide body's own sheet: its domain owns these rules, so the shell's + stylesheet does not carry them. Tokens only. */ +.guide { + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + padding-top: 24px; + text-align: center; +} + +.guideTitle { + margin: 0; + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size, 14px); + font-weight: 500; + line-height: 1.6; +} + +.guideBody { + margin: 0; + color: var(--dsw-alias-label-secondary); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: 1.6; +} + +/* Entry boxes: as many per row as the pane's width allows. */ +.entries { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 8px; + width: 100%; + max-width: 480px; + margin-top: 16px; +} + +.entry { + display: flex; + gap: 8px; + align-items: flex-start; + padding: 10px 12px; + color: inherit; + font: inherit; + text-align: left; + background: transparent; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: 8px; + cursor: pointer; +} + +.entry:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.entryIcon { + display: flex; + flex: none; + margin-top: 1px; + color: var(--dsw-alias-label-secondary); +} + +.entryText { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.entryTitle { + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size, 14px); + line-height: 1.4; +} + +.entryDescription { + color: var(--dsw-alias-label-secondary); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: 1.4; +} diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx new file mode 100644 index 0000000000..46b67581a5 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx @@ -0,0 +1,84 @@ +/** + * The guide tab's body: a chain host, and the guide it falls back to. + * + * The chain is the replacement seam. A product with its own idea of what an + * empty sidebar should say registers into `sidebar.right.tab.guide`, and its entry + * takes the whole body; with no entry, or with every entry declining, the guide + * below renders. The shipped guide is the owner's fallback rather than a chain + * entry of its own, so there is always exactly one body and the shipped one + * cannot be outvoted by accident. + * + * The shipped guide is a centred title, one line under it, and the entry boxes + * every registered type contributed. Picking a box opens that type as a page in + * this tab's place, so the guide is a doorway rather than a page that stays open. + */ +import type { ReactNode } from 'react' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { ChainRenderOpts, HookContextOf, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { SidebarRightGuideBox } from '../../tab-registry.ts' +import css from './GuideBody.module.css' + +/** What the guide body needs from its host beyond the framework shares. */ +export interface GuideInjected { + /** The registry's guide entries in `order`; observable, so a type registering later appears. */ + readonly hooks: { readonly guideEntries: ObservableSnapshot } +} + +/** The guide body's composed props: the tab it draws, its chain child, its copy, and the entries. */ +export type GuideBodyProps = + & PropsRuntime<'sidebar.right.pane.tab'> + & PropsRenderSlots<'sidebar.right.tab.guide'> + & PropsLocale<'sidebarRight'> + & InjectFace + +/** One entry box: the contributing type's glyph, heading, and line. */ +function EntryBox({ entry, onPick }: { entry: SidebarRightGuideBox; onPick: (entry: SidebarRightGuideBox) => void }): ReactNode { + const Icon = entry.icon + return ( + + ) +} + +/** The shipped guide: what the column is for, and the doors out of it. */ +function ShippedGuide({ entries, onPick, t }: { + entries: readonly SidebarRightGuideBox[] + onPick: (entry: SidebarRightGuideBox) => void + t: GuideBodyProps['t'] +}): ReactNode { + return ( +
    +

    {t('guide.lead')}

    +

    {t('guide.body')}

    + {entries.length > 0 && ( +
    + {/* Keyed by position in the ordered list: one type may contribute several boxes, and `order` is not unique. */} + {entries.map((entry, index) => )} +
    + )} +
    + ) +} + +/** The guide tab's body, replaceable through its chain child. */ +export function GuideBody({ useTabInfo, useGuideEntries, renderSlotChain, t }: GuideBodyProps): ReactNode { + const { tab } = useTabInfo() + const entries = useGuideEntries(entries => entries) + const options = { + hookContext: useTabInfo, + fallback: ( + { tab.actions.openTab(entry.kind, { replaceTab: true }) }} t={t} /> + ), + } satisfies ChainRenderOpts & { hookContext: HookContextOf<'sidebar.right.tab.guide'> } + return renderSlotChain('sidebar.right.tab.guide', {}, options) +} diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/definition.ts b/packages/client/ui-sidebar-right/src/client/tabs/guide/definition.ts new file mode 100644 index 0000000000..a19be4a7f4 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/definition.ts @@ -0,0 +1,27 @@ +/** + * Stage one of the guide type's registration: what it IS. + */ +import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' +import type { SidebarRightTabDefinition } from '../../tab-registry.ts' +import { GUIDE_KIND } from '../../contract/seed.ts' + +/** The shipped guide implementation's identity: the key its body registers under. */ +export const GUIDE_ID = '@deepseek-ai/dsh-client-ui-sidebar-right/guide' + +/** + * The guide type's registry definition. + * + * A page type: it recognizes no resource address, because a guide views + * nothing, and is opened by kind; `builtin` is the ordinary band for a type + * shipped here. + * @param t - namespace-bound translate, read fresh on every title call. + * @returns the definition to register. + */ +export function guideDefinition(t: TranslateNS<'sidebarRight'>): SidebarRightTabDefinition { + return { + id: GUIDE_ID, + kind: GUIDE_KIND, + priority: 'builtin', + title: () => t('tab.guide.title'), + } +} diff --git a/packages/client/ui-sidebar-right/src/css-modules.d.ts b/packages/client/ui-sidebar-right/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-sidebar-right/src/index.ts b/packages/client/ui-sidebar-right/src/index.ts new file mode 100644 index 0000000000..ec75b76799 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/index.ts @@ -0,0 +1,4 @@ +/** Pure host half; the whole Sidebar lives in the browser export. */ + +/** Host plugin body: the Sidebar contributes nothing to the host tree. */ +export function apply(): void {} diff --git a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts new file mode 100644 index 0000000000..c54f98f2af --- /dev/null +++ b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts @@ -0,0 +1,204 @@ +/** + * The plugin's wiring, and its removal when the plugin goes. + * + * The registry and the navigation controller are real, because "provided" + * means what those faces do; the slot, locale, frame, and resource faces are + * recorders, because what matters here is what was handed to them — two seats + * over one store, the guide's body under its own id, the frame reports, the + * service binding — and that every registration is gone after dispose, which + * is what makes a reload safe. The seats' components have their own specs. + */ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { apply, inject } from '../src/client/index.ts' +import type { GuideInjected, SidebarRightInjected } from '../src/client/index.ts' +import { apply as hostApply } from '../src/index.ts' +import { SidebarRightController } from '../src/client/service.ts' +import { SidebarRightTabRegistry } from '../src/client/tab-registry.ts' +import type { createSidebarRightStore } from '../src/client/stores.ts' +import { RightbarSeat } from '../src/client/shell/SidebarRight.tsx' +import { ExpandButton } from '../src/client/shell/ExpandButton.tsx' +import { GuideBody } from '../src/client/tabs/guide/GuideBody.tsx' +import { GUIDE_ID } from '../src/client/tabs/guide/definition.ts' +import { en, zh } from '../src/client/locales.ts' + +const SESSION = 's-test' as SessionId + +interface Recorded { + name: string + key?: string + locale: string + store?: unknown + children?: unknown + inject?: (sessionId: SessionId) => unknown + component: unknown +} + +async function boot() { + const ctx = new Context() + const registered: Recorded[] = [] + const slots = { + inject: vi.fn((_name: string, register: () => () => void) => register()), + register: vi.fn((options: Omit, component: unknown) => { + const entry: Recorded = { ...options, component } + registered.push(entry) + return () => { registered.splice(registered.indexOf(entry), 1) } + }), + } + const dictionaries = new Map() + const locale = { + // Copy is the dictionary's contract; the key stands in for the translation. + bind: vi.fn(() => (key: string) => key), + register: vi.fn((ns: string, dicts: unknown) => { + dictionaries.set(ns, dicts) + return () => { dictionaries.delete(ns) } + }), + } + const layout = { openRightbar: vi.fn(), closeRightbar: vi.fn() } + const resources = { pin: vi.fn<(address: string, signal: AbortSignal) => void>() } + ctx.provide('slots', slots as never) + ctx.provide('locale', locale as never) + ctx.provide('layout', layout as never) + ctx.provide('resources', resources as never) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const seat = (name: string): Recorded => { + const entry = registered.find(candidate => candidate.name === name) + if (entry === undefined) throw new Error(`expected a registration into ${name}`) + return entry + } + const injectedOf = (entry: Recorded): unknown => { + if (entry.inject === undefined) throw new Error(`expected ${entry.name} to inject`) + return entry.inject(SESSION) + } + return { ctx, registered, dictionaries, layout, resources, fiber, seat, injectedOf } +} + +describe('ui-sidebar-right apply', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + + it('provides both faces, and registers the guide through the same two-stage path as any other type', async () => { + const { ctx, registered, dictionaries, seat } = await boot() + expect(ctx.sidebarRightTabs).toBeInstanceOf(SidebarRightTabRegistry) + expect(ctx.sidebarRight).toBeInstanceOf(SidebarRightController) + expect('adopt' in ctx.sidebarRight).toBe(false) + expect(dictionaries.get('sidebarRight')).toEqual({ zh, en }) + const guide = ctx.sidebarRightTabs.get('guide') + expect(guide?.id).toBe(GUIDE_ID) + expect(guide?.priority).toBe('builtin') + expect(guide?.title('sidebar://guide')).toBe('tab.guide.title') + // Three registrations: the panel seat, the header's corner seat, and the + // guide body under the guide implementation's id. + expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([ + ['rightbar', undefined, 'sidebarRight', RightbarSeat], + ['conversation.session.header.corner', undefined, 'sidebarRight', ExpandButton], + ['sidebar.right.pane.tab', GUIDE_ID, 'sidebarRight', GuideBody], + ]) + // The panel declares the extension seats; the guide declares its chain child. + expect(Object.keys(seat('rightbar').children as object)).toEqual([ + 'sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'sidebar.right.tab.menu.item', + ]) + expect(seat('sidebar.right.pane.tab').children).toMatchObject({ 'sidebar.right.tab.guide': { kind: 'chain', scope: 'session' } }) + // Both seats read one store: the button only needs to know whether the panel is expanded. + expect(seat('rightbar').store).toBeDefined() + expect(seat('conversation.session.header.corner').store).toBe(seat('rightbar').store) + }) + + it('hands the panel seat the frame report, the service binding, the opens, the observable registry, and the Tab domain', async () => { + const { ctx, layout, resources, seat, injectedOf } = await boot() + const injected = injectedOf(seat('rightbar')) as SidebarRightInjected + // The frame learns the composition of expanded and presentation, nothing else. + injected.syncPresentation({ shown: true, track: true, fullscreen: false }) + expect(layout.openRightbar).toHaveBeenLastCalledWith(true, false) + injected.syncPresentation({ shown: true, track: true, fullscreen: true }) + expect(layout.openRightbar).toHaveBeenLastCalledWith(true, true) + injected.syncPresentation({ shown: true, track: false, fullscreen: true }) + expect(layout.openRightbar).toHaveBeenLastCalledWith(false, true) + injected.syncPresentation({ shown: false, track: false, fullscreen: false }) + expect(layout.closeRightbar).toHaveBeenCalledOnce() + // The registry, observable: what the seat dispatches a kind to. + expect(injected.hooks.tabTypes.getSnapshot().find(type => type.kind === 'guide')?.id).toBe(GUIDE_ID) + const seen = vi.fn() + const unsubscribe = injected.hooks.tabTypes.subscribe(seen) + ctx.sidebarRightTabs.register({ id: 'spec/text', kind: 'text', patterns: ['dsh-resource://file/**'], title: () => 'text' }) + expect(seen).toHaveBeenCalledOnce() + unsubscribe() + // The binding makes the service act on this seat's session; the seat's + // store instance is minted here from the handle the registration declared. + const handle = seat('rightbar').store as ReturnType + const instance = handle.create() + const release = injected.bindService({ sessionId: SESSION, actions: instance.actions, surfaces: {}, canSplitPane: () => true }) + injected.openTab('guide', { revealIfOpened: false }) + const surface = instance.getSnapshot().bySession[SESSION] + expect(surface?.layout.expanded).toBe(true) + expect(Object.values(surface?.layout.tabs ?? {}).map(tab => tab.kind)).toEqual(['guide']) + // Holding a record pins its address through the resource model. + if (surface === undefined) throw new Error('expected a surface') + ctx.sidebarRight.tabDomain.sync(SESSION, surface.layout) + expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', expect.any(AbortSignal)) + release() + expect(() => { ctx.sidebarRight.toggleExpanded() }).toThrow('no session surface is mounted') + }) + + it('adopts each session\'s store instance as the runtime mints it, so a tab\'s own actions land with no seat bound', async () => { + const { ctx, resources, seat } = await boot() + const handle = seat('rightbar').store as ReturnType + // Both seats declare the same wrapped handle, so either minting adopts. + expect(seat('conversation.session.header.corner').store).toBe(handle) + const instance = handle.create(SESSION) + instance.actions.open(SESSION) + const guide = Object.values(instance.getSnapshot().bySession[SESSION]?.layout.tabs ?? {})[0] + if (guide === undefined) throw new Error('expected the seeded guide') + // Held and pinned from the store's own commit: no seat synced anything. + const occurrence = ctx.sidebarRight.tabDomain.occurrence(SESSION, guide) + expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', occurrence.signal) + occurrence.tabActions.close() + expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeUndefined() + expect(occurrence.signal.aborted).toBe(true) + }) + + it('hands the guide body the registry\'s entry boxes, observable', async () => { + const { ctx, seat, injectedOf } = await boot() + const { hooks: { guideEntries } } = injectedOf(seat('sidebar.right.pane.tab')) as GuideInjected + expect(guideEntries.getSnapshot()).toEqual([]) + const seen = vi.fn() + guideEntries.subscribe(seen) + ctx.sidebarRightTabs.register({ + id: 'spec/files', + kind: 'files', + title: () => 'Files', + guide: [{ order: 10, title: () => 'Files', description: () => 'The workspace tree' }], + }) + expect(seen).toHaveBeenCalledOnce() + expect(guideEntries.getSnapshot().map(entry => entry.kind)).toEqual(['files']) + }) + + it('takes every registration and both faces back when disposed, aborting the open records, so a reload registers again', async () => { + const { ctx, registered, dictionaries, fiber, seat, injectedOf } = await boot() + const injected = injectedOf(seat('rightbar')) as SidebarRightInjected + const handle = seat('rightbar').store as ReturnType + // Minted under the session key, so the instance is adopted and the teardown releases it. + const instance = handle.create(SESSION) + injected.bindService({ sessionId: SESSION, actions: instance.actions, surfaces: {}, canSplitPane: () => true }) + injected.openTab('guide') + const surface = instance.getSnapshot().bySession[SESSION] + const guide = Object.values(surface?.layout.tabs ?? {})[0] + if (guide === undefined) throw new Error('expected the guide tab') + const { signal, tabActions } = ctx.sidebarRight.tabDomain.occurrence(SESSION, guide) + await fiber.dispose() + expect(signal.aborted).toBe(true) + // The adoption went with the plugin: a late action from the dead occurrence changes nothing. + tabActions.close() + expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeDefined() + expect(ctx.get('sidebarRight')).toBeUndefined() + expect(ctx.get('sidebarRightTabs')).toBeUndefined() + expect(registered).toEqual([]) + expect(dictionaries.size).toBe(0) + await ctx.plugin({ inject: [...inject], apply }).await() + expect(ctx.sidebarRightTabs.get('guide')?.id).toBe(GUIDE_ID) + expect(registered).toHaveLength(3) + }) +}) diff --git a/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx b/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx new file mode 100644 index 0000000000..41a402c8ca --- /dev/null +++ b/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +/** + * The way back into a hidden panel: the header's corner button exists exactly + * while the panel is collapsed, asks for it to expand, and leaves a same-size + * footprint while the panel is shown so the header row never moves. + */ +import { describe, expect, it } from 'vitest' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { useSyncExternalStore } from 'react' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { ExpandButton } from '../src/client/shell/ExpandButton.tsx' +import type { ExpandButtonProps } from '../src/client/shell/ExpandButton.tsx' +import { createSidebarRightStore } from '../src/client/stores.ts' + +const SESSION = 's-test' as SessionId + +/** Test-local selector hook over a framework-neutral store instance. */ +function hookOf(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) { + return function useSelector(sel: (s: T) => S): S { + return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) + } +} + +/** + * Mount the button over a real store instance. It reads four of its props; the + * rest of the standard kit is framework-injected and never touched here, so one + * documented cast keeps the harness to what is actually exercised. + */ +function mountButton() { + const instance = createSidebarRightStore(() => 'Start').create() + const props = { + sessionId: SESSION, + useStore: hookOf(instance), + actions: instance.actions, + // Copy is the dictionary's contract; the key stands in for the translation. + t: (key: string) => key, + } as unknown as ExpandButtonProps + const view = render() + const control = (): HTMLElement | null => view.container.querySelector('[data-sidebar-right-expand]') + const placeholder = (): HTMLElement | null => view.container.querySelector('[data-sidebar-right-expand-placeholder]') + return { instance, view, control, placeholder } +} + +describe('ExpandButton', () => { + it('offers the way in while the session has no surface yet, and asks the panel to expand', () => { + const { instance, control, placeholder } = mountButton() + const button = control() + if (button === null) throw new Error('expected the expand control') + expect(button.getAttribute('aria-label')).toBe('chrome.expand') + expect(placeholder()).toBeNull() + fireEvent.click(button) + expect(instance.getSnapshot().bySession[SESSION]?.layout.expanded).toBe(true) + // Shown: the control gives way to its footprint, so the seat keeps its width. + expect(control()).toBeNull() + expect(placeholder()).not.toBeNull() + cleanup() + }) + + it('comes back when the panel collapses again', () => { + const { instance, control, placeholder } = mountButton() + act(() => { instance.actions.setExpanded(SESSION, true) }) + expect(control()).toBeNull() + act(() => { instance.actions.setExpanded(SESSION, false) }) + expect(control()).not.toBeNull() + expect(placeholder()).toBeNull() + cleanup() + }) +}) diff --git a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx new file mode 100644 index 0000000000..3e25bc1b4a --- /dev/null +++ b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx @@ -0,0 +1,110 @@ +// @vitest-environment jsdom +/** + * The guide tab's body: the chain seam, and the shipped guide behind it. + * + * The contract a type relies on is the entry box: one per guide entry every + * registered type contributed, in the registry's order, and picking one opens + * that type as a page in the guide's own tab. The chain is asserted through + * what the body hands it — the tab and the shipped guide as the fallback. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import type { ReactNode } from 'react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' +import { GuideBody } from '../src/client/tabs/guide/GuideBody.tsx' +import type { GuideBodyProps } from '../src/client/tabs/guide/GuideBody.tsx' +import type { SidebarRightGuideBox } from '../src/client/tab-registry.ts' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' + +afterEach(cleanup) + +const TAB = { id: 'tab-1', kind: 'guide', contentId: 'sidebar://guide', title: 'Start' } + +/** A glyph that marks its box, so a spec can tell an entry with an icon from one without. */ +function Glyph({ size }: IconProps): ReactNode { + return +} + +/** One entry box as the registry lists it. */ +function box(kind: string, order: number, icon?: SidebarRightGuideBox['icon']): SidebarRightGuideBox { + return { + kind, + order, + title: () => `${kind} title`, + description: () => `${kind} description`, + ...icon === undefined ? {} : { icon }, + } +} + +/** + * Mount the body with the entries observable and a chain that renders its + * fallback, which is what the chain does with no registrant. + */ +function mountGuide(entries: readonly SidebarRightGuideBox[]) { + const guideEntries = createSnapshotStore(entries) + const openTab = vi.fn() + const renderSlot = vi.fn((_seat: string, _owner: unknown, options: { fallback: ReactNode }) => options.fallback) + const props = { + useTabInfo: () => ({ tab: { ...TAB, actions: { openResource: vi.fn(), openTab, close: vi.fn() } } }), + useGuideEntries: bindSnapshotSelector(guideEntries), + renderSlotChain: renderSlot, + // Copy is the dictionary's contract; the key stands in for the translation. + t: (key: string) => key, + } as unknown as GuideBodyProps + const view = render() + const boxes = (): string[] => + [...view.container.querySelectorAll('[data-sidebar-right-guide-entry]')].map(node => node.getAttribute('data-sidebar-right-guide-entry') ?? '') + return { view, guideEntries, openTab, renderSlot, boxes, useTabInfo: props.useTabInfo } +} + +describe('GuideBody', () => { + it('renders the chain with the same tab hook, and the shipped guide as its fallback', () => { + const { view, renderSlot, boxes, useTabInfo } = mountGuide([box('files', 10, Glyph), box('terminal', 20)]) + expect(renderSlot).toHaveBeenCalledWith('sidebar.right.tab.guide', {}, { + hookContext: useTabInfo, fallback: expect.anything() as ReactNode, + }) + const guide = view.container.querySelector('[data-sidebar-right-guide]') + expect(guide?.textContent).toContain('guide.lead') + expect(guide?.textContent).toContain('guide.body') + // One box per entry, in the registry's order, each with its own words; only the first brought a glyph. + expect(boxes()).toEqual(['files', 'terminal']) + const [files, terminal] = [...view.container.querySelectorAll('[data-sidebar-right-guide-entry]')] + expect(files?.textContent).toBe('files titlefiles description') + expect(files?.querySelector('[data-guide-glyph]')?.getAttribute('data-guide-glyph')).toBe('16') + expect(terminal?.querySelector('[data-guide-glyph]')).toBeNull() + cleanup() + }) + + it('picking a box opens that type in the guide\'s own place', () => { + const { view, openTab } = mountGuide([box('files', 10)]) + const entry = view.container.querySelector('[data-sidebar-right-guide-entry="files"]') + if (entry === null) throw new Error('expected the files box') + fireEvent.click(entry) + expect(openTab).toHaveBeenCalledWith('files', { replaceTab: true }) + cleanup() + }) + + it('draws the words alone while no type contributed an entry, and follows the registry when one does', () => { + const { view, guideEntries, boxes } = mountGuide([]) + expect(view.container.querySelector('[data-sidebar-right-guide]')).not.toBeNull() + expect(boxes()).toEqual([]) + act(() => { guideEntries.set([box('files', 10)]) }) + expect(boxes()).toEqual(['files']) + cleanup() + }) + + it('keeps two boxes of one type at the same order apart', () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const { boxes } = mountGuide([box('notes', 10), box('notes', 10)]) + expect(boxes()).toEqual(['notes', 'notes']) + // React reports colliding keys through console.error; two boxes rendered + // without one is the whole assertion. + expect(errors).not.toHaveBeenCalled() + } finally { + errors.mockRestore() + cleanup() + } + }) +}) diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx new file mode 100644 index 0000000000..adf00ef762 --- /dev/null +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -0,0 +1,510 @@ +// @vitest-environment jsdom +/** Sidebar presentation and tab subscriptions through the production slot renderer. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent } from '@testing-library/react' +import { useState } from 'react' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PaneId, SplitId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import { dockPaneIds, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { apply, inject } from '../src/client/index.ts' +import { intentsFor } from '../src/client/shell/SidebarRight.tsx' +import type { SidebarRightTabInfo, SidebarRightTabMenuOwnerProps } from '../src/client/contract/slots.ts' +import type { createSidebarRightStore } from '../src/client/stores.ts' + +declare module '../src/client/contract/params.ts' { + interface SidebarRightResourceParamsMap { + test: { line?: number; x?: number } + } +} + +const SESSION = 's-test' as SessionId +const OTHER = 's-other' as SessionId +const runtimes: SlotTestRuntime[] = [] +let getAnimationsDescriptor: PropertyDescriptor | undefined + +beforeEach(() => { + getAnimationsDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'getAnimations') + Object.defineProperty(Element.prototype, 'getAnimations', { configurable: true, writable: true, value: () => [] }) +}) + +afterEach(async () => { + try { + for (const runtime of runtimes.splice(0)) await runtime.dispose() + } finally { + vi.restoreAllMocks() + if (getAnimationsDescriptor === undefined) Reflect.deleteProperty(Element.prototype, 'getAnimations') + else Object.defineProperty(Element.prototype, 'getAnimations', getAnimationsDescriptor) + } +}) + +/** Browser-owned animation completion controlled independently of the test clock. */ +function transition(property = 'transform') { + const done = Promise.withResolvers() + let state: AnimationPlayState = 'running' + const animation = { + transitionProperty: property, + get playState() { return state }, + finished: done.promise, + } as CSSTransition + return { + animation, + finish: () => { state = 'finished'; done.resolve(animation) }, + cancel: () => { state = 'idle'; done.reject(new DOMException('Transition canceled', 'AbortError')) }, + } +} + +async function mountSeat(viewportWidth = 1440, canShow = true) { + const runtime = await SlotTestRuntime.create() + runtimes.push(runtime) + const frame = { openRightbar: vi.fn(), closeRightbar: vi.fn() } + const pin = vi.fn<(address: string, signal: AbortSignal) => void>() + runtime.ctx.provide('layout', frame as never) + runtime.ctx.provide('resources', { pin } as never) + const locale = new LocaleRuntime(runtime.ctx) + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + await runtime.declare({ + 'rightbar': { kind: 'single', scope: 'session' }, + 'conversation.session.header.corner': { kind: 'single', scope: 'session' }, + }) + await runtime.sessions.add({ id: SESSION }) + const feature = await runtime.mount({ inject: [...inject], apply }) + const bodies = new Map() + const titles = new Map() + const hooks = new Map['useTabInfo']>() + let mounts = 0 + function Body(props: PropsRuntime<'sidebar.right.pane.tab'>) { + const info = props.useTabInfo() + const [instance] = useState(() => ++mounts) + bodies.set(info.tab.id, info) + hooks.set(info.tab.id, props.useTabInfo) + expect(['tabInfo', 'tab', 'paneId', 'visible', 'navigation', 'signal', 'tabActions'].filter(key => key in props)).toEqual([]) + return + } + function Title({ useTabInfo }: PropsRuntime<'sidebar.right.pane.tab.title'>) { + const info = useTabInfo() + titles.set(info.tab.id, info) + return {info.tab.title} + } + await act(async () => { + runtime.ctx.sidebarRightTabs.register({ + id: 'test/text', kind: 'text', priority: 'builtin', patterns: ['dsh-resource://file/**'], + title: address => address.slice(address.lastIndexOf('/') + 1), + }) + runtime.slots.register({ name: 'sidebar.right.pane.tab', key: 'test/text' }, Body) + runtime.slots.register({ name: 'sidebar.right.pane.tab.title', key: 'test/text' }, Title) + }) + const view = runtime.renderSlot('rightbar', { width: 420, viewportWidth, canShow }) + const instance = runtime.storeOf('rightbar', SESSION) as ReturnType['create']> + const controller = runtime.ctx.sidebarRight + const layout = () => instance.getSnapshot().bySession[SESSION]!.layout + const open = (name = 'a.txt', options?: Parameters[1]) => { + act(() => { controller.openResource(`dsh-resource://file/session/s-test/${name}`, options) }) + return controller.active()! + } + return { runtime, feature, controller, instance, actions: instance.actions, layout, open, frame, pin, bodies, titles, hooks, view } +} + +function element(container: HTMLElement, selector: string): HTMLElement { + const node = container.querySelector(selector) + if (node === null) throw new Error(`expected ${selector}`) + return node +} + +describe('RightbarSeat presentation', () => { + it('keeps the panel mounted while collapsed and releases the frame on unmount', async () => { + const h = await mountSeat() + const panel = element(h.view.container, '[data-sidebar-right-panel]') + expect(panel.getAttribute('aria-hidden')).toBe('true') + expect(h.frame.closeRightbar).toHaveBeenCalled() + h.open() + expect(element(h.view.container, '[data-sidebar-right-panel]')).toBe(panel) + expect(panel.hasAttribute('data-sidebar-right-open')).toBe(true) + expect(h.frame.openRightbar).toHaveBeenLastCalledWith(true, false) + await h.runtime.dispose() + expect(h.frame.closeRightbar).toHaveBeenCalled() + }) + + it('fills the viewport without replacing the content tree or releasing the wide track', async () => { + const h = await mountSeat() + const tab = h.open() + const panel = element(h.view.container, '[data-sidebar-right-panel]') + const body = element(h.view.container, '[data-tab-body]') + expect(panel.style.width).toBe('420px') + fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]')) + expect(h.layout().mode).toBe('fullscreen') + expect(panel.style.width).toBe('100%') + expect(panel.dataset['sidebarRightPanel']).toBe('fullscreen') + expect(element(h.view.container, '[data-tab-body]')).toBe(body) + expect(h.frame.openRightbar).toHaveBeenLastCalledWith(true, true) + expect(h.bodies.get(tab.id)?.sidebar).toEqual({ expanded: true, fullscreen: true }) + fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]')) + expect(panel.style.width).toBe('420px') + expect(element(h.view.container, '[data-tab-body]')).toBe(body) + expect(h.frame.openRightbar).toHaveBeenLastCalledWith(true, false) + fireEvent.click(element(h.view.container, '[data-sidebar-right-toggle]')) + expect(h.layout().expanded).toBe(false) + expect(h.frame.closeRightbar).toHaveBeenCalled() + }) + + it('derives narrow fullscreen without recording mode and returns to normal when widened', async () => { + const h = await mountSeat(767, false) + h.open() + expect(h.layout().mode).toBe('push') + expect(h.frame.openRightbar).toHaveBeenLastCalledWith(false, true) + const stored = h.instance.getSnapshot() + const body = element(h.view.container, '[data-tab-body]') + h.view.update({ width: 420, viewportWidth: 768, canShow: true }) + expect(h.instance.getSnapshot()).toBe(stored) + expect(element(h.view.container, '[data-tab-body]')).toBe(body) + expect(h.frame.openRightbar).toHaveBeenLastCalledWith(true, false) + }) + + it('closes on automatic fullscreen exit and stays closed after widening', async () => { + const h = await mountSeat(500, false) + const tab = h.open() + const signal = h.bodies.get(tab.id)!.tab.signal + fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]')) + expect(h.layout().expanded).toBe(false) + expect(h.layout().mode).toBe('push') + const stored = h.instance.getSnapshot() + h.view.update({ width: 420, viewportWidth: 1440, canShow: true }) + expect(h.instance.getSnapshot()).toBe(stored) + expect(h.layout().tabs[tab.id]).toBeDefined() + expect(signal.aborted).toBe(false) + expect(h.layout().expanded).toBe(false) + }) + + it('preserves manual fullscreen through narrow and wide viewport changes', async () => { + const h = await mountSeat() + h.open() + fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]')) + const stored = h.instance.getSnapshot() + h.view.update({ width: 420, viewportWidth: 500, canShow: false }) + expect(h.frame.openRightbar).toHaveBeenLastCalledWith(false, true) + h.view.update({ width: 420, viewportWidth: 1440, canShow: true }) + expect(h.frame.openRightbar).toHaveBeenLastCalledWith(true, true) + expect(h.instance.getSnapshot()).toBe(stored) + }) + + it('collapses a normal panel that cannot fit without clearing records or reopening on growth', async () => { + const h = await mountSeat() + const tab = h.open() + const signal = h.bodies.get(tab.id)!.tab.signal + h.view.update({ width: 420, viewportWidth: 900, canShow: false }) + expect(h.layout().expanded).toBe(false) + expect(h.layout().tabs[tab.id]).toBeDefined() + expect(signal.aborted).toBe(false) + const stored = h.instance.getSnapshot() + h.view.update({ width: 420, viewportWidth: 1440, canShow: true }) + expect(h.instance.getSnapshot()).toBe(stored) + expect(h.layout().expanded).toBe(false) + }) +}) + +describe('RightbarSeat fullscreen entry', () => { + it('retains the previous report until its transform finishes, then leaves the track in place on exit', async () => { + const h = await mountSeat() + act(() => { h.actions.setMode(SESSION, 'fullscreen') }) + const panel = element(h.view.container, '[data-sidebar-right-panel]') + const slide = transition() + const unrelated = transition('opacity') + vi.spyOn(panel, 'getAnimations').mockReturnValue([slide.animation, unrelated.animation]) + h.frame.closeRightbar.mockClear() + h.open() + expect(panel.hasAttribute('data-sidebar-right-open')).toBe(true) + expect(panel.dataset['sidebarRightPanel']).toBe('fullscreen') + expect(h.frame.openRightbar).not.toHaveBeenCalled() + expect(h.frame.closeRightbar).not.toHaveBeenCalled() + await act(async () => { slide.finish(); await slide.animation.finished }) + expect(h.frame.openRightbar).toHaveBeenCalledExactlyOnceWith(true, true) + fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]')) + expect(h.frame.openRightbar).toHaveBeenLastCalledWith(true, false) + unrelated.finish() + }) + + it('reports immediately without a transform transition, including zero-duration and reduced-motion entry', async () => { + const h = await mountSeat() + act(() => { h.actions.setMode(SESSION, 'fullscreen') }) + const unrelated = transition('opacity') + const ended = transition() + ended.finish() + vi.spyOn(element(h.view.container, '[data-sidebar-right-panel]'), 'getAnimations') + .mockReturnValue([unrelated.animation, ended.animation]) + h.open() + expect(h.frame.openRightbar).toHaveBeenCalledExactlyOnceWith(true, true) + unrelated.finish() + }) + + it('reports when reduced motion cancels the entering transition', async () => { + const h = await mountSeat(767, false) + const slide = transition() + vi.spyOn(element(h.view.container, '[data-sidebar-right-panel]'), 'getAnimations').mockReturnValue([slide.animation]) + h.open() + expect(h.frame.openRightbar).not.toHaveBeenCalled() + await act(async () => { slide.cancel(); await Promise.allSettled([slide.animation.finished]) }) + expect(h.frame.openRightbar).toHaveBeenCalledExactlyOnceWith(false, true) + }) + + it('waits for a replacement transform after cancellation', async () => { + const h = await mountSeat(767, false) + const first = transition() + const replacement = transition() + const animations = vi.spyOn(element(h.view.container, '[data-sidebar-right-panel]'), 'getAnimations') + .mockReturnValue([first.animation]) + h.open() + animations.mockReturnValue([replacement.animation]) + await act(async () => { first.cancel(); await Promise.allSettled([first.animation.finished]) }) + expect(h.frame.openRightbar).not.toHaveBeenCalled() + await act(async () => { replacement.finish(); await replacement.animation.finished }) + expect(h.frame.openRightbar).toHaveBeenCalledExactlyOnceWith(false, true) + }) + + it.each(['close', 'push', 'session', 'unmount'])('ignores a late completion after %s', async (change) => { + const h = await mountSeat() + act(() => { h.actions.setMode(SESSION, 'fullscreen') }) + const slide = transition() + vi.spyOn(element(h.view.container, '[data-sidebar-right-panel]'), 'getAnimations').mockReturnValue([slide.animation]) + h.open() + expect(h.frame.openRightbar).not.toHaveBeenCalled() + if (change === 'close') fireEvent.click(element(h.view.container, '[data-sidebar-right-toggle]')) + else if (change === 'push') fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]')) + else if (change === 'session') { + await h.runtime.sessions.add({ id: OTHER }) + act(() => { h.controller.openResource('dsh-resource://file/session/s-other/b.txt') }) + } else await h.runtime.dispose() + const openCalls = [...h.frame.openRightbar.mock.calls] + const closeCalls = h.frame.closeRightbar.mock.calls.length + await act(async () => { slide.finish(); await slide.animation.finished }) + expect(h.frame.openRightbar.mock.calls).toEqual(openCalls) + expect(h.frame.closeRightbar).toHaveBeenCalledTimes(closeCalls) + }) + + it('uses the current viewport report when entry crosses the fullscreen breakpoint', async () => { + const h = await mountSeat() + act(() => { h.actions.setMode(SESSION, 'fullscreen') }) + const slide = transition() + vi.spyOn(element(h.view.container, '[data-sidebar-right-panel]'), 'getAnimations').mockReturnValue([slide.animation]) + h.open() + h.view.update({ width: 420, viewportWidth: 500, canShow: false }) + expect(h.frame.openRightbar).not.toHaveBeenCalled() + await act(async () => { slide.finish(); await slide.animation.finished }) + expect(h.frame.openRightbar).toHaveBeenCalledExactlyOnceWith(false, true) + }) + + it('does not delay normal presentation behind its slide', async () => { + const h = await mountSeat() + const slide = transition() + const animations = vi.spyOn(element(h.view.container, '[data-sidebar-right-panel]'), 'getAnimations') + .mockReturnValue([slide.animation]) + h.open() + expect(h.frame.openRightbar).toHaveBeenCalledExactlyOnceWith(true, false) + expect(animations).not.toHaveBeenCalled() + slide.finish() + }) +}) + +describe('slot-owned useTabInfo', () => { + it('updates body and title navigation with no layout commit and retains the bound hook', async () => { + const h = await mountSeat() + const tab = h.open('a.txt', { params: { line: 3 } }) + const stored = h.instance.getSnapshot() + const hook = h.hooks.get(tab.id) + const signal = h.bodies.get(tab.id)!.tab.signal + act(() => { h.controller.tabDomain.navigate(SESSION, tab.id, { address: tab.contentId, params: { line: 7 } }) }) + expect(h.instance.getSnapshot()).toBe(stored) + expect(h.hooks.get(tab.id)).toBe(hook) + expect(h.bodies.get(tab.id)?.tab.navigation).toEqual({ address: tab.contentId, params: { line: 7 }, revision: 2 }) + expect(h.titles.get(tab.id)?.tab.navigation).toBe(h.bodies.get(tab.id)?.tab.navigation) + expect(h.bodies.get(tab.id)?.tab.signal).toBe(signal) + expect(element(h.view.container, '[data-tab-body]').dataset['revision']).toBe('2') + }) + + it('isolates same-kind record state and reports inactive titles, hiding, floating and docking', async () => { + const h = await mountSeat() + const a = h.open('a.txt') + const b = h.open('b.txt') + const bodyB = element(h.view.container, '[data-tab-body]') + expect(h.titles.get(a.id)?.tab.visible).toBe(true) + act(() => { h.actions.focusTab(SESSION, a.id) }) + const bodyA = element(h.view.container, '[data-tab-body]') + expect(bodyA.dataset['instance']).not.toBe(bodyB.dataset['instance']) + expect(bodyA.dataset['tabBody']).toBe(a.id) + const signal = h.bodies.get(a.id)!.tab.signal + act(() => { h.actions.setExpanded(SESSION, false) }) + expect(h.bodies.get(a.id)?.tab.visible).toBe(false) + expect(h.titles.get(a.id)?.tab.visible).toBe(false) + expect(h.titles.get(b.id)?.tab.visible).toBe(false) + expect(element(h.view.container, '[data-tab-body]')).toBe(bodyA) + expect(signal.aborted).toBe(false) + act(() => { h.controller.float(a.id) }) + expect(h.bodies.get(a.id)?.tab.visible).toBe(true) + const paneId = h.bodies.get(a.id)!.panel.id + expect(h.layout().floats).toContain(paneId) + act(() => { h.controller.dock(paneId) }) + expect(h.layout().floats).toHaveLength(0) + expect(h.bodies.get(a.id)?.tab.signal).toBe(signal) + }) + + it('keeps session records and navigation while unmounted, aborting only removal and plugin unload', async () => { + const h = await mountSeat() + const own = h.open() + const info = h.bodies.get(own.id)! + const stored = h.instance.getSnapshot() + await h.runtime.sessions.add({ id: OTHER }) + expect(h.instance.getSnapshot()).toBe(stored) + expect(info.tab.signal.aborted).toBe(false) + act(() => { h.controller.openResource('dsh-resource://file/session/s-other/other.txt', { params: { line: 9 } }) }) + const otherTab = h.controller.active()! + expect(otherTab.id).toBe(own.id) + const otherInfo = h.bodies.get(otherTab.id)! + expect(otherInfo.tab.signal).not.toBe(info.tab.signal) + act(() => { info.tab.actions.openResource('dsh-resource://file/session/s-test/b.txt') }) + expect(Object.values(h.layout().tabs).map(tab => tab.title)).toContain('b.txt') + expect(h.controller.active()?.contentId).toBe(otherTab.contentId) + expect(h.bodies.get(otherTab.id)?.tab.navigation.params).toEqual({ line: 9 }) + act(() => { info.tab.actions.close() }) + expect(info.tab.signal.aborted).toBe(true) + expect(otherInfo.tab.signal.aborted).toBe(false) + await h.runtime.sessions.setCurrent(SESSION) + const remaining = h.bodies.get(h.controller.active()!.id)! + expect(remaining.tab.navigation.revision).toBe(1) + await h.feature.dispose() + expect(remaining.tab.signal.aborted).toBe(true) + expect(otherInfo.tab.signal.aborted).toBe(true) + expect(h.runtime.ctx.get('sidebarRight')).toBeUndefined() + }) + + it('updates guide replacements through the same hook and guide boxes through framework injection', async () => { + const h = await mountSeat() + let captured: SidebarRightTabInfo | undefined + await act(async () => { + h.runtime.ctx.sidebarRightTabs.register({ + id: 'test/files', kind: 'files', title: () => 'Files', + guide: [{ order: 1, title: () => 'Files', description: () => 'Browse' }], + }) + }) + expect(h.view.container.querySelector('[data-sidebar-right-guide-entry="files"]')).not.toBeNull() + await act(async () => { + h.runtime.slots.register({ name: 'sidebar.right.tab.guide', select: () => true }, + ({ useTabInfo }: PropsRuntime<'sidebar.right.tab.guide'>) => { + captured = useTabInfo() + return + }) + }) + expect(h.view.container.querySelector('[data-sidebar-right-guide]')).toBeNull() + expect(captured?.tab.kind).toBe('guide') + act(() => { h.controller.openTab('guide') }) + const stored = h.instance.getSnapshot() + const guide = h.controller.active()! + act(() => { h.controller.tabDomain.navigate(SESSION, guide.id, { address: guide.contentId, params: undefined }) }) + expect(h.instance.getSnapshot()).toBe(stored) + expect(captured?.tab.navigation.revision).toBe(2) + expect(element(h.view.container, '[data-guide-replacement]').dataset['guideReplacement']).toBe('2') + expect(captured?.sidebar.expanded).toBe(true) + }) + + it('follows type replacement and returns to the builtin when it leaves', async () => { + const h = await mountSeat() + h.open() + let release = () => {} + await act(async () => { + release = h.runtime.ctx.sidebarRightTabs.register({ id: 'extension/text', kind: 'text', title: () => 'Extension' }) + h.runtime.slots.register({ name: 'sidebar.right.pane.tab', key: 'extension/text' }, + ({ useTabInfo }: PropsRuntime<'sidebar.right.pane.tab'>) => {useTabInfo().tab.title}) + }) + expect(element(h.view.container, '[data-extension]').textContent).toBe('a.txt') + await act(async () => { release() }) + expect(h.view.container.querySelector('[data-extension]')).toBeNull() + expect(h.view.container.querySelector('[data-tab-body]')).not.toBeNull() + }) + + it('renders unavailable kinds and keeps menu tab/dismiss arguments', async () => { + const h = await mountSeat() + let menu: SidebarRightTabMenuOwnerProps | undefined + await act(async () => { + h.runtime.slots.register({ name: 'sidebar.right.tab.menu.item', id: 'test' }, + (props: PropsRuntime<'sidebar.right.tab.menu.item'>) => { menu = props; return null }) + h.actions.openContent(SESSION, { kind: 'missing', contentId: 'missing://content', title: 'Missing' }, () => {}) + }) + expect(h.view.container.querySelector('[data-sidebar-right-unavailable]')).not.toBeNull() + const chip = element(h.view.container, '[data-dockkit-tab]') + fireEvent.contextMenu(chip) + expect(menu?.tab.id).toBe(chip.getAttribute('data-dockkit-tab')) + act(() => { menu?.dismiss() }) + expect(document.querySelector('[data-dockkit-tab-menu]')).toBeNull() + }) + + it('hides split controls at two panes and adds a guide only to a pane without one', async () => { + const h = await mountSeat() + h.open() + const splitButtons = () => h.view.container.querySelectorAll('[data-dockkit-split-button]') + expect(splitButtons()).toHaveLength(1) + act(() => { h.controller.split() }) + expect(dockPaneIds(h.layout())).toHaveLength(2) + const stored = h.instance.getSnapshot() + act(() => { expect(h.controller.split()).toBeUndefined() }) + expect(h.instance.getSnapshot()).toBe(stored) + expect(splitButtons()).toHaveLength(0) + const right = dockPaneIds(h.layout())[1]! + h.open('right.txt', { paneId: right }) + const guide = getPane(h.layout(), right).tabs.find(id => h.layout().tabs[id]?.kind === 'guide')! + act(() => { h.actions.closeTab(SESSION, guide) }) + const add = element(h.view.container, '[data-dockkit-add-tab]') + expect(add.closest('[data-dockkit-pane]')?.getAttribute('data-dockkit-pane')).toBe(right) + fireEvent.click(add) + expect(getPane(h.layout(), right).tabs.filter(id => h.layout().tabs[id]?.kind === 'guide')).toHaveLength(1) + const closing = [...getPane(h.layout(), right).tabs] + act(() => { for (const tabId of closing) h.actions.closeTab(SESSION, tabId) }) + expect(dockPaneIds(h.layout())).toHaveLength(1) + expect(splitButtons()).toHaveLength(1) + expect(splitButtons()[0]?.disabled).toBe(false) + }) +}) + +describe('intentsFor — the kit\'s gestures as one session\'s store actions', () => { + it('binds every intent to the session, and asks the navigation face for a guide on add', () => { + const actions = { + focusTab: vi.fn(), focusPane: vi.fn(), splitPane: vi.fn(), closeTab: vi.fn(), duplicateTab: vi.fn(), + floatTab: vi.fn(), unfloatPane: vi.fn(), placeTab: vi.fn(), dropTab: vi.fn(), moveFloat: vi.fn(), + resizeFloat: vi.fn(), resizeSplit: vi.fn(), + } + const openTab = vi.fn() + const intents = intentsFor(SESSION, actions as unknown as Parameters[1], openTab) + const rect = { x: 1, y: 2, width: 300, height: 200 } + const TAB_1 = 'tab-1' as TabId + const PANE_1 = 'pane-1' as PaneId + const PANE_2 = 'pane-2' as PaneId + const SPLIT_1 = 'split-1' as SplitId + intents.focusTab(TAB_1) + intents.focusPane(PANE_1) + intents.splitPane(PANE_1) + intents.closeTab(TAB_1) + intents.duplicateTab(TAB_1) + intents.floatTab(TAB_1, rect) + intents.unfloatPane(PANE_2) + intents.placeTab(TAB_1, PANE_1, 0) + intents.dropTab(TAB_1, PANE_1, 'right') + intents.moveFloat(PANE_2, 30, 40) + intents.resizeFloat(PANE_2, rect) + intents.resizeSplit(SPLIT_1, [0.3, 0.7]) + expect(actions.focusTab).toHaveBeenCalledWith(SESSION, TAB_1) + expect(actions.focusPane).toHaveBeenCalledWith(SESSION, PANE_1) + expect(actions.splitPane).toHaveBeenCalledWith(SESSION, PANE_1) + expect(actions.closeTab).toHaveBeenCalledWith(SESSION, TAB_1) + expect(actions.duplicateTab).toHaveBeenCalledWith(SESSION, TAB_1) + expect(actions.floatTab).toHaveBeenCalledWith(SESSION, TAB_1, rect) + expect(actions.unfloatPane).toHaveBeenCalledWith(SESSION, PANE_2) + expect(actions.placeTab).toHaveBeenCalledWith(SESSION, TAB_1, PANE_1, 0) + expect(actions.dropTab).toHaveBeenCalledWith(SESSION, TAB_1, PANE_1, 'right') + expect(actions.moveFloat).toHaveBeenCalledWith(SESSION, PANE_2, 30, 40) + expect(actions.resizeFloat).toHaveBeenCalledWith(SESSION, PANE_2, rect) + expect(actions.resizeSplit).toHaveBeenCalledWith(SESSION, SPLIT_1, [0.3, 0.7]) + // The add control is the guide opened by kind, in that pane, beside any guide elsewhere. + intents.addTab(PANE_1) + expect(openTab).toHaveBeenCalledWith('guide', { paneId: PANE_1, revealIfOpened: false }) + }) +}) diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts new file mode 100644 index 0000000000..6e1c4025e7 --- /dev/null +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -0,0 +1,580 @@ +/** + * What `ctx.sidebarRight` promises other plugins. + * + * The service is root-scoped and the surface is per session, so every command + * depends on a binding the mounted seat publishes; the interesting cases are all + * about that seam. Opening is asserted against a real store instance, because + * "already open" and "in that tab's place" mean whatever the planner means by + * them and nothing else, and against the Tab domain, because an open is not + * complete until the tab knows how it was navigated to. + */ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { LayoutState, PaneId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import { dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' +import { createSidebarRightController } from '../src/client/service.ts' +import { SidebarRightTabRegistry } from '../src/client/tab-registry.ts' +import { createSidebarRightStore } from '../src/client/stores.ts' +import { guideDefinition } from '../src/client/tabs/guide/definition.ts' + +// The params map is empty in this package; a test-only scheme lets specs hand +// parameters through the typed `open`/`navigate` faces. +declare module '../src/client/contract/params.ts' { + interface SidebarRightResourceParamsMap { + test: { line?: number; x?: number } + } +} + +const SESSION = 's-test' as SessionId + +/** Key-echoing translate: this file asserts behaviour, not copy. */ +const t = ((key: string) => key) as Parameters[0] + +/** A controller over a real registry and a real store, bound as a mounted seat would be. */ +function harness() { + const ctx = new Context() + const tabs = new SidebarRightTabRegistry(ctx) + tabs.register(guideDefinition(t)) + // A type claiming `file:` addresses, standing in for whatever package owns + // them: this service's contract is that SOME registered type claims, not that + // a particular one does. + tabs.register({ + id: 'test/text', + kind: 'text', + patterns: ['dsh-resource://file/**'], + priority: 'fallback', + title: address => address.slice(address.lastIndexOf('/') + 1), + }) + const pin = vi.fn<(address: string, signal: AbortSignal) => void>() + const { controller, adopt } = createSidebarRightController(tabs, pin) + const instance = createSidebarRightStore(() => 'seed').create() + const layout = (): LayoutState => { + const surface = instance.getSnapshot().bySession[SESSION] + if (surface === undefined) throw new Error('expected a surface') + return surface.layout + } + /** Republish the way the seat does after each commit, and sync the domain the way it does too. */ + /** The room rule's verdict the seat would report; a spec flips it to model a narrow pane. */ + const room = { allowed: true } + const publish = (): (() => void) => { + const surface = instance.getSnapshot().bySession[SESSION] + if (surface !== undefined) controller.tabDomain.sync(SESSION, surface.layout) + return controller.bind({ + sessionId: SESSION, actions: instance.actions, surfaces: instance.getSnapshot().bySession, canSplitPane: () => room.allowed, + }) + } + const titles = (): string[] => { + const surface = instance.getSnapshot().bySession[SESSION] + return surface === undefined ? [] : Object.values(surface.layout.tabs).map(tab => tab.title) + } + const tabOf = (title: string): TabId => { + const found = Object.values(layout().tabs).find(tab => tab.title === title) + if (found === undefined) throw new Error(`expected a tab titled ${title}`) + return found.id + } + const entries = (): number => instance.getSnapshot().bySession[SESSION]?.history.entries.length ?? 0 + return { controller, adopt, tabs, instance, pin, publish, titles, tabOf, layout, entries, room } +} + +describe('SidebarRightController — opening', () => { + it('refuses every write while no seat is mounted', () => { + const { controller } = harness() + expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted') + expect(() => { controller.openTab('guide') }).toThrow('no session surface is mounted') + expect(() => { controller.close('tab1' as TabId) }).toThrow('no session surface is mounted') + expect(() => { controller.toggleExpanded() }).toThrow('no session surface is mounted') + expect(() => { controller.focus('tab1' as TabId) }).toThrow('no session surface is mounted') + expect(() => { controller.split() }).toThrow('no session surface is mounted') + expect(() => { controller.float('tab1' as TabId) }).toThrow('no session surface is mounted') + expect(() => { controller.dock('pane1' as PaneId) }).toThrow('no session surface is mounted') + }) + + it('refuses an address no registered type claims, before touching the surface', () => { + const { controller, publish, titles } = harness() + publish() + expect(() => { controller.openResource('https://example.com') }).toThrow('no registered tab type claims') + expect(titles()).toEqual([]) + }) + + it('opens claimed content and reveals the column in one history entry', () => { + const { controller, publish, layout, titles, entries } = harness() + publish() + controller.openResource('dsh-resource://file/session/s-test/notes/readme.txt') + expect(layout().expanded).toBe(true) + expect(titles()).toContain('readme.txt') + // One intent, one entry: stepping back removes the tab and re-collapses. + expect(entries()).toBe(1) + publish() + controller._undo() + expect(layout().expanded).toBe(false) + expect(titles()).not.toContain('readme.txt') + }) + + it('focuses the tab already showing the same (kind, contentId) instead of opening a second one', () => { + const { controller, publish, titles, layout, tabOf } = harness() + publish() + controller.openResource('dsh-resource://file/session/s-test/notes/readme.txt') + publish() + controller.openTab('guide') + publish() + controller.openResource('dsh-resource://file/session/s-test/notes/readme.txt') + expect(titles().filter(title => title === 'readme.txt')).toHaveLength(1) + expect(getPane(layout(), layout().activePaneId).activeTabId).toBe(tabOf('readme.txt')) + }) + + it('opens another tab for the same address when told not to reveal, and when another kind is named', () => { + const { controller, tabs, publish, titles } = harness() + tabs.register({ id: 'test/hex', kind: 'hex', patterns: [], title: () => 'hex view' }) + publish() + controller.openResource('dsh-resource://file/session/s-test/notes/readme.txt') + publish() + controller.openResource('dsh-resource://file/session/s-test/notes/readme.txt', { revealIfOpened: false }) + publish() + controller.openResource('dsh-resource://file/session/s-test/notes/readme.txt', { kind: 'hex' }) + expect(titles().filter(title => title === 'readme.txt')).toHaveLength(2) + expect(titles()).toContain('hex view') + }) + + it('lands a new tab in the pane the caller names', () => { + const { controller, instance, publish, layout, tabOf } = harness() + publish() + instance.actions.splitPane(SESSION) + publish() + const [left, right] = Object.values(layout().nodes).filter(node => node.kind === 'pane').map(node => node.id) + if (left === undefined || right === undefined) throw new Error('expected two panes') + controller.openResource('dsh-resource://file/session/s-test/a.txt', { paneId: left }) + expect(findTabPane(layout(), tabOf('a.txt')).id).toBe(left) + }) + + it('takes the replaced tab\'s pane and slot, closes it, and records one entry', () => { + const { controller, publish, layout, tabOf, entries } = harness() + publish() + controller.openResource('dsh-resource://file/session/s-test/a.txt') + publish() + controller.openResource('dsh-resource://file/session/s-test/b.txt') + publish() + const a = tabOf('a.txt') + const before = entries() + controller.openResource('dsh-resource://file/session/s-test/c.txt', { replaceTab: a }) + const pane = findTabPane(layout(), tabOf('c.txt')) + // The seeded guide sits at 0; a took 1; c took a's slot. + expect(pane.tabs.indexOf(tabOf('c.txt'))).toBe(1) + expect(layout().tabs[a]).toBeUndefined() + expect(entries()).toBe(before + 1) + publish() + controller._undo() + expect(layout().tabs[a]).toBeDefined() + expect(Object.values(layout().tabs).map(tab => tab.title)).not.toContain('c.txt') + }) + + it('still closes the replaced tab when the address is already open elsewhere', () => { + const { controller, publish, layout, tabOf, titles } = harness() + publish() + controller.openResource('dsh-resource://file/session/s-test/a.txt') + publish() + controller.openResource('dsh-resource://file/session/s-test/b.txt') + publish() + const spare = tabOf('b.txt') + controller.openResource('dsh-resource://file/session/s-test/a.txt', { replaceTab: spare }) + expect(layout().tabs[spare]).toBeUndefined() + expect(titles().filter(title => title === 'a.txt')).toHaveLength(1) + expect(getPane(layout(), layout().activePaneId).activeTabId).toBe(tabOf('a.txt')) + }) + + it('refuses to copy the guide, and records nothing for the attempt', () => { + const { controller, instance, publish, layout, tabOf, entries } = harness() + instance.actions.open(SESSION) + publish() + controller.openResource('dsh-resource://file/session/s-test/a.txt') + publish() + const before = entries() + instance.actions.duplicateTab(SESSION, tabOf('seed')) + expect(Object.values(layout().tabs).filter(tab => tab.contentId === 'sidebar://guide')).toHaveLength(1) + expect(entries()).toBe(before) + // Any other tab still copies beside itself. + instance.actions.duplicateTab(SESSION, tabOf('a.txt')) + expect(Object.values(layout().tabs).filter(tab => tab.title === 'a.txt')).toHaveLength(2) + }) + + it('reveals an open guide for a plain open, as for any address', () => { + const { controller, instance, publish, layout, tabOf } = harness() + instance.actions.open(SESSION) + publish() + controller.openResource('dsh-resource://file/session/s-test/a.txt') + publish() + controller.openTab('guide') + expect(Object.values(layout().tabs).filter(tab => tab.contentId === 'sidebar://guide')).toHaveLength(1) + expect(getPane(layout(), layout().activePaneId).activeTabId).toBe(tabOf('seed')) + }) + + it('keeps the guide to one per pane: opening it into a pane that holds one settles on that one', () => { + const { controller, instance, publish, layout, tabOf } = harness() + instance.actions.open(SESSION) + publish() + controller.openResource('dsh-resource://file/session/s-test/a.txt') + publish() + const root = getPane(layout(), layout().rootId).id + let settled: TabId | undefined + instance.actions.openContent(SESSION, { + kind: 'guide', contentId: 'sidebar://guide', title: 'seed', paneId: root, revealIfOpened: false, + }, (tabId) => { settled = tabId }) + expect(settled).toBe(tabOf('seed')) + expect(Object.values(layout().tabs).filter(tab => tab.contentId === 'sidebar://guide')).toHaveLength(1) + // A pane without one gets its own. + instance.actions.closeTab(SESSION, tabOf('seed')) + instance.actions.openContent(SESSION, { + kind: 'guide', contentId: 'sidebar://guide', title: 'seed', paneId: root, revealIfOpened: false, + }, () => {}) + expect(Object.values(layout().tabs).filter(tab => tab.contentId === 'sidebar://guide')).toHaveLength(1) + }) + + it('merges a guide placed, dropped, or docked into a pane that already holds one', () => { + const { instance, publish, layout } = harness() + instance.actions.open(SESSION) + publish() + const guides = (): TabId[] => Object.values(layout().tabs).filter(tab => tab.contentId === 'sidebar://guide').map(tab => tab.id) + // Place: the split's guide dragged into the first pane's strip. + instance.actions.splitPane(SESSION) + let [left, right] = dockPaneIds(layout()) + if (left === undefined || right === undefined) throw new Error('expected two panes') + const [own] = getPane(layout(), left).tabs + const arrivingByPlace = getPane(layout(), right).tabs[0] + if (own === undefined || arrivingByPlace === undefined) throw new Error('expected seeded guides') + instance.actions.placeTab(SESSION, arrivingByPlace, left, 0) + expect(guides()).toEqual([own]) + expect(dockPaneIds(layout())).toHaveLength(1) + expect(getPane(layout(), left).activeTabId).toBe(own) + // Drop on the centre: the same merge. An edge drop makes a new pane and keeps both. + instance.actions.splitPane(SESSION) + ;[left, right] = dockPaneIds(layout()) + if (left === undefined || right === undefined) throw new Error('expected two panes') + const arrivingByDrop = getPane(layout(), right).tabs[0] + if (arrivingByDrop === undefined) throw new Error('expected the seeded guide') + instance.actions.dropTab(SESSION, arrivingByDrop, left, 'right') + expect(guides()).toHaveLength(2) + instance.actions.dropTab(SESSION, arrivingByDrop, left, 'center') + expect(guides()).toEqual([own]) + // Dock: a floating guide returning to a pane that reseeded its own. + instance.actions.floatTab(SESSION, own) + expect(layout().floats).toHaveLength(1) + const reseeded = guides().find(id => id !== own) + if (reseeded === undefined) throw new Error('expected the root to reseed a guide') + const [float] = layout().floats + if (float === undefined) throw new Error('expected the float') + instance.actions.unfloatPane(SESSION, float) + expect(layout().floats).toHaveLength(0) + expect(guides()).toEqual([reseeded]) + }) + + it('records the navigation in the Tab domain: params delivered, revision counting every open', () => { + const { controller, publish, tabOf } = harness() + publish() + controller.openResource('dsh-resource://file/session/s-test/a.txt', { params: { line: 3 } }) + const occurrence = controller.tabDomain.occurrence(SESSION, { id: tabOf('a.txt') }) + expect(occurrence.navigation.getSnapshot()).toEqual({ address: 'dsh-resource://file/session/s-test/a.txt', params: { line: 3 }, revision: 1 }) + publish() + controller.openResource('dsh-resource://file/session/s-test/a.txt') + expect(occurrence.navigation.getSnapshot()).toEqual({ address: 'dsh-resource://file/session/s-test/a.txt', params: undefined, revision: 2 }) + }) + + it('closes a tab of the mounted session', () => { + const { controller, publish, tabOf, titles } = harness() + publish() + controller.openResource('dsh-resource://file/session/s-test/a.txt') + publish() + controller.close(tabOf('a.txt')) + expect(titles()).not.toContain('a.txt') + }) + + it('records the presentation switch, so it steps back with everything else', () => { + const { controller, instance, publish, layout } = harness() + publish() + instance.actions.setMode(SESSION, 'fullscreen') + expect(layout().mode).toBe('fullscreen') + publish() + controller._undo() + expect(layout().mode).toBe('push') + controller._redo() + expect(layout().mode).toBe('fullscreen') + }) +}) + +describe('SidebarRightController — the two opens', () => { + it('refuses an address outside dsh-resource:// on the same path as an unclaimed one', () => { + const { controller, instance, publish, entries } = harness() + instance.actions.open(SESSION) + publish() + const before = entries() + expect(() => { controller.openResource('sidebar://guide') }).toThrow('no registered tab type claims') + expect(() => { controller.openResource('https://example.com/a.txt') }).toThrow('no registered tab type claims') + expect(() => { controller.openResource('dsh-resource://unknown/x') }).toThrow('no registered tab type claims') + expect(entries()).toBe(before) + }) + + it('opens a page by kind at the address the package records it under, and refuses an unregistered kind', () => { + const { controller, instance, publish, layout, entries } = harness() + instance.actions.open(SESSION) + publish() + controller.openResource('dsh-resource://file/session/s-test/a.txt') + publish() + // The seeded guide is the page in force: opening it by kind reveals that tab. + controller.openTab('guide') + publish() + expect(Object.values(layout().tabs).filter(tab => tab.contentId === 'sidebar://guide')).toHaveLength(1) + expect(controller.active()?.kind).toBe('guide') + const before = entries() + expect(() => { controller.openTab('nope') }).toThrow('no tab type is registered as "nope"') + expect(entries()).toBe(before) + }) + + it('replaceTab opens in the named tab\'s place and closes it, as one entry', () => { + const { controller, instance, publish, layout, tabOf, entries } = harness() + instance.actions.open(SESSION) + publish() + controller.openResource('dsh-resource://file/session/s-test/a.txt') + publish() + const before = entries() + controller.openTab('guide', { replaceTab: tabOf('a.txt') }) + expect(Object.values(layout().tabs).map(tab => tab.title)).toEqual(['seed']) + expect(entries()).toBe(before + 1) + }) +}) + +describe('SidebarRightController — layout operations', () => { + it('focuses an existing tab and its pane, and leaves a missing tab alone', () => { + const { controller, publish, layout, tabOf, entries } = harness() + publish() + controller.openResource('dsh-resource://file/session/s-test/a.txt') + publish() + expect(getPane(layout(), layout().activePaneId).activeTabId).toBe(tabOf('a.txt')) + const before = entries() + controller.focus(tabOf('seed')) + expect(getPane(layout(), layout().activePaneId).activeTabId).toBe(tabOf('seed')) + expect(entries()).toBe(before + 1) + controller.focus('tab-nowhere' as TabId) + expect(entries()).toBe(before + 1) + }) + + it('splits nothing before the seat has materialized the surface', () => { + const { controller, publish } = harness() + publish() + expect(controller.split()).toBeUndefined() + }) + + it('splits the active docked pane and names the new one, or nothing when the budget or the room rule says no', () => { + const { controller, instance, publish, layout, entries, room } = harness() + instance.actions.open(SESSION) + publish() + const created = controller.split() + expect(created).toBeDefined() + expect(dockPaneIds(layout())).toHaveLength(2) + expect(dockPaneIds(layout())).toContain(created) + publish() + // The room rule blocks: nothing is split and nothing is recorded. + room.allowed = false + const before = entries() + expect(controller.split()).toBeUndefined() + expect(entries()).toBe(before) + room.allowed = true + // A floating pane cannot be split. + const seed = Object.values(layout().tabs).find(tab => tab.title === 'seed') + if (seed === undefined) throw new Error('expected the seeded guide') + controller.float(seed.id) + publish() + const [float] = layout().floats + expect(controller.split(float)).toBeUndefined() + // The budget: split to two panes, then no more. + while (dockPaneIds(layout()).length < 2) { + expect(controller.split(dockPaneIds(layout()).at(-1))).toBeDefined() + publish() + } + expect(controller.split()).toBeUndefined() + }) + + it('floats a docked tab, and leaves a floating or missing tab alone', () => { + const { controller, publish, layout, tabOf, entries } = harness() + publish() + controller.openResource('dsh-resource://file/session/s-test/a.txt') + publish() + controller.float(tabOf('a.txt'), { x: 10, y: 20, width: 300, height: 200 }) + expect(layout().floats).toHaveLength(1) + expect(findTabPane(layout(), tabOf('a.txt')).rect).toEqual({ x: 10, y: 20, width: 300, height: 200 }) + publish() + const before = entries() + controller.float(tabOf('a.txt')) + controller.float('tab-nowhere' as TabId) + expect(entries()).toBe(before) + expect(layout().floats).toHaveLength(1) + }) + + it('docks a floating panel back into the active docked pane, and leaves a docked or missing pane alone', () => { + const { controller, publish, layout, tabOf, entries } = harness() + publish() + controller.openResource('dsh-resource://file/session/s-test/a.txt') + publish() + controller.float(tabOf('a.txt')) + publish() + const [float] = layout().floats + if (float === undefined) throw new Error('expected a float') + controller.dock(float) + expect(layout().floats).toHaveLength(0) + expect(findTabPane(layout(), tabOf('a.txt')).host).toBe('dock') + publish() + const before = entries() + controller.dock(getPane(layout(), layout().rootId).id) + controller.dock('pane-nowhere' as PaneId) + expect(entries()).toBe(before) + }) +}) + +describe('SidebarRightController — a tab\'s own actions', () => { + const OTHER = 's-other' as SessionId + const A_TXT = 'dsh-resource://file/session/s-test/a.txt' + const B_TXT = 'dsh-resource://file/session/s-test/b.txt' + + it('land in the session the tab is in through its own adopted store, after another session\'s seat took over', () => { + const { controller, adopt, instance, publish, layout } = harness() + const releaseOwn = adopt(SESSION, instance) + publish() + controller.openResource(A_TXT) + const own = Object.values(layout().tabs).find(tab => tab.title === 'a.txt') + const guide = Object.values(layout().tabs).find(tab => tab.kind === 'guide') + if (own === undefined || guide === undefined) throw new Error('expected the opened tab and the seeded guide') + const fromOwn = controller.tabDomain.occurrence(SESSION, own).tabActions + const guideOccurrence = controller.tabDomain.occurrence(SESSION, guide) + // The user switches sessions: the other seat binds with the other session's + // own instance, whose store knows nothing of this session. + const other = createSidebarRightStore(() => 'seed').create(OTHER) + const releaseOther = adopt(OTHER, other) + other.actions.open(OTHER) + const otherSurface = other.getSnapshot().bySession[OTHER] + if (otherSurface === undefined) throw new Error('expected the other surface') + controller.bind({ sessionId: OTHER, actions: other.actions, surfaces: other.getSnapshot().bySession, canSplitPane: () => true }) + fromOwn.openResource(B_TXT) + fromOwn.openTab('guide', { revealIfOpened: false }) + expect(Object.values(layout().tabs).map(tab => tab.title)).toContain('b.txt') + expect(other.getSnapshot().bySession[OTHER]).toBe(otherSurface) + // Tab ids repeat across sessions: the seeded guides share one. Closing this + // session's closes this session's, aborts its occurrence at once although + // no seat draws the session, and the other keeps its own. + expect(otherSurface.layout.tabs[guide.id]).toBeDefined() + guideOccurrence.tabActions.close() + expect(layout().tabs[guide.id]).toBeUndefined() + expect(guideOccurrence.signal.aborted).toBe(true) + expect(other.getSnapshot().bySession[OTHER]).toBe(otherSurface) + expect(controller.active()?.kind).toBe('guide') + releaseOther() + releaseOwn() + }) + + it('do nothing for a session whose store is not adopted, and again once its adoption is released', () => { + const { controller, adopt, instance, publish, layout, titles } = harness() + publish() + controller.openResource(A_TXT) + publish() + const own = Object.values(layout().tabs).find(tab => tab.title === 'a.txt') + if (own === undefined) throw new Error('expected the opened tab') + const { tabActions } = controller.tabDomain.occurrence(SESSION, own) + const before = instance.getSnapshot().bySession + tabActions.openResource(B_TXT) + tabActions.openTab('guide') + tabActions.close() + expect(instance.getSnapshot().bySession).toBe(before) + // Adopted, they land; released, they stop again. + const release = adopt(SESSION, instance) + tabActions.openResource(B_TXT) + expect(titles()).toContain('b.txt') + release() + tabActions.close() + expect(titles()).toContain('a.txt') + }) + + it('adoption syncs the Tab domain on each commit of that store: the seeded guide is pinned, a closed tab aborted', () => { + const { controller, adopt, instance, pin } = harness() + const first = adopt(SESSION, instance) + // Nothing is synced at adoption, and a commit that materializes another + // session leaves this session's occurrences alone. + instance.actions.open(OTHER) + expect(pin).not.toHaveBeenCalled() + instance.actions.open(SESSION) + expect(pin).toHaveBeenCalledWith('sidebar://guide', expect.any(AbortSignal)) + instance.actions.openContent(SESSION, { kind: 'text', contentId: A_TXT, title: 'a' }, () => {}) + const surface = instance.getSnapshot().bySession[SESSION] + const tab = Object.values(surface?.layout.tabs ?? {}).find(record => record.contentId === A_TXT) + if (tab === undefined) throw new Error('expected the opened tab') + const occurrence = controller.tabDomain.occurrence(SESSION, tab) + expect(pin).toHaveBeenCalledWith(A_TXT, occurrence.signal) + instance.actions.closeTab(SESSION, tab.id) + expect(occurrence.signal.aborted).toBe(true) + // A release ends the syncing; a stale release leaves a newer adoption standing. + first() + const second = adopt(SESSION, instance) + first() + instance.actions.openContent(SESSION, { kind: 'text', contentId: B_TXT, title: 'b' }, () => {}) + const again = Object.values(instance.getSnapshot().bySession[SESSION]?.layout.tabs ?? {}).find(record => record.contentId === B_TXT) + if (again === undefined) throw new Error('expected the second tab') + const held = controller.tabDomain.occurrence(SESSION, again) + expect(pin).toHaveBeenCalledWith(B_TXT, held.signal) + // Adopting another instance for the session ends the earlier adoption's + // subscription with its routing: the old store's commits sync nothing, the + // new store's commits reconcile the session against its own layout. + const replacement = createSidebarRightStore(() => 'seed').create() + const third = adopt(SESSION, replacement) + const pins = pin.mock.calls.length + instance.actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/c.txt', title: 'c' }, () => {}) + expect(pin.mock.calls.length).toBe(pins) + expect(held.signal.aborted).toBe(false) + replacement.actions.open(SESSION) + expect(held.signal.aborted).toBe(true) + second() + third() + }) +}) + +describe('SidebarRightController — the readable slice', () => { + it('answers for the no-session case rather than throwing', () => { + const { controller } = harness() + expect(controller.isExpanded()).toBe(false) + expect(controller.active()).toBeUndefined() + }) + + it('reports the last committed surface', () => { + const { controller, publish, layout } = harness() + publish() + controller.toggleExpanded() + publish() + expect(controller.isExpanded()).toBe(true) + controller.openResource('dsh-resource://file/session/s-test/a.txt') + publish() + expect(Object.values(layout().tabs).filter(tab => tab.contentId === 'dsh-resource://file/session/s-test/a.txt')).toHaveLength(1) + expect(controller.active()?.contentId).toBe('dsh-resource://file/session/s-test/a.txt') + }) +}) + +describe('SidebarRightController — binding lifetime', () => { + it('acts on the newest binding when a seat republishes', () => { + const { controller, publish, layout } = harness() + publish() + controller.toggleExpanded() + publish() + expect(controller.isExpanded()).toBe(true) + expect(layout().expanded).toBe(true) + }) + + it('goes back to refusing writes once the seat releases', () => { + const { controller, publish } = harness() + const release = publish() + release() + expect(() => { controller.toggleExpanded() }).toThrow('no session surface is mounted') + expect(controller.isExpanded()).toBe(false) + }) + + it('a stale release does not clear a newer binding', () => { + const { controller, publish } = harness() + const stale = publish() + publish() + stale() + expect(() => { controller.toggleExpanded() }).not.toThrow() + }) +}) diff --git a/packages/client/ui-sidebar-right/tests/stores.client.spec.ts b/packages/client/ui-sidebar-right/tests/stores.client.spec.ts new file mode 100644 index 0000000000..d7a040734c --- /dev/null +++ b/packages/client/ui-sidebar-right/tests/stores.client.spec.ts @@ -0,0 +1,272 @@ +/** + * The store shell over the docking kit: every action is one settled intent + * recorded as one history entry, or no entry when it changes nothing. + * + * The opens, the guide's uniqueness, and the merge rule are asserted through + * `ctx.sidebarRight` in service.client.spec.ts; here are the intents only the + * kit's gestures reach — floating-panel moves and resizes, divider drags — and + * the sequence's ends. + */ +import { describe, expect, it, vi } from 'vitest' +import type { LayoutState, PaneId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import { dockPaneIds, findTabPane, getPane, getSplit } from '@deepseek-ai/dsh-client-ui-dockkit' +import { createSidebarRightStore } from '../src/client/stores.ts' + +const SESSION = 's-test' + +function harness() { + const instance = createSidebarRightStore(() => 'Start').create() + instance.actions.open(SESSION) + const surface = () => { + const held = instance.getSnapshot().bySession[SESSION] + if (held === undefined) throw new Error('expected a surface') + return held + } + const layout = (): LayoutState => surface().layout + const entries = (): number => surface().history.entries.length + const guide = (): TabId => { + const seeded = Object.values(layout().tabs)[0] + if (seeded === undefined) throw new Error('expected the seeded guide') + return seeded.id + } + return { instance, actions: instance.actions, surface, layout, entries, guide } +} + +describe('createSidebarRightStore — the sequence', () => { + it.each(['left', 'right'] as const)('splits one pane at its %s edge and records the tab move as one reversible intent', (zone) => { + const { actions, layout, entries, guide } = harness() + actions.openContent(SESSION, { + kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a', + }, () => {}) + const text = Object.values(layout().tabs).find(tab => tab.kind === 'text') + if (text === undefined) throw new Error('expected the text tab') + const before = layout() + const home = getPane(before, before.rootId).id + const recorded = entries() + + actions.dropTab(SESSION, text.id, home, zone) + + const split = getSplit(layout(), layout().rootId) + const destination = findTabPane(layout(), text.id) + expect(split.axis).toBe('row') + expect(split.children).toEqual(zone === 'left' ? [destination.id, home] : [home, destination.id]) + expect(split.sizes).toEqual([0.5, 0.5]) + expect(destination.tabs).toEqual([text.id]) + expect(destination.activeTabId).toBe(text.id) + expect(getPane(layout(), home).tabs).toEqual([guide()]) + expect(layout().tabs).toEqual(before.tabs) + expect(entries()).toBe(recorded + 1) + const dropped = layout() + + actions.undo(SESSION) + expect(layout()).toEqual(before) + actions.redo(SESSION) + expect(layout()).toEqual(dropped) + }) + + it('allows two docked panes and rejects further splits without recording', () => { + const { actions, layout, entries } = harness() + actions.splitPane(SESSION) + expect(dockPaneIds(layout())).toHaveLength(2) + const before = entries() + const settled = vi.fn() + actions.splitPane(SESSION, undefined, settled) + expect(dockPaneIds(layout())).toHaveLength(2) + expect(entries()).toBe(before) + expect(settled).not.toHaveBeenCalled() + }) + + it('rejects vertical drops and keeps split ratios within twenty to eighty percent', () => { + const { actions, layout, entries, guide } = harness() + actions.openContent(SESSION, { kind: 'text', contentId: 'file:a', title: 'a' }, () => {}) + const before = entries() + actions.dropTab(SESSION, guide(), layout().activePaneId, 'top') + actions.dropTab(SESSION, guide(), layout().activePaneId, 'bottom') + expect(entries()).toBe(before) + actions.splitPane(SESSION) + const split = getSplit(layout(), layout().rootId).id + actions.resizeSplit(SESSION, split, [0.01, 0.99]) + expect(getSplit(layout(), split).sizes).toEqual([0.2, 0.8]) + actions.resizeSplit(SESSION, split, [0.99, 0.01]) + expect(getSplit(layout(), split).sizes).toEqual([0.8, 0.2]) + const splitCount = entries() + actions.dropTab(SESSION, guide(), layout().activePaneId, 'right') + expect(entries()).toBe(splitCount) + }) + + it('materializes a session on open without recording, then records each change of expansion once', () => { + const { actions, layout, entries } = harness() + expect(layout().expanded).toBe(false) + expect(entries()).toBe(0) + actions.setExpanded(SESSION, true) + expect(layout().expanded).toBe(true) + expect(entries()).toBe(1) + // Already expanded: nothing to plan, nothing recorded, the surface kept by reference. + const before = layout() + actions.setExpanded(SESSION, true) + expect(layout()).toBe(before) + expect(entries()).toBe(1) + }) + + it('closes a tab once: a second close of a record already gone records nothing and throws nothing', () => { + const { actions, layout, entries } = harness() + actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a' }, () => {}) + const tab = Object.values(layout().tabs).find(record => record.title === 'a') + if (tab === undefined) throw new Error('expected the opened tab') + actions.closeTab(SESSION, tab.id) + expect(layout().tabs[tab.id]).toBeUndefined() + const recorded = entries() + // A racing callback closing what the user already closed. + expect(() => { actions.closeTab(SESSION, tab.id) }).not.toThrow() + expect(entries()).toBe(recorded) + }) + + it('steps nowhere before the first entry, and forward again through an undone one', () => { + const { actions, surface, layout } = harness() + const start = surface() + actions.undo(SESSION) + expect(surface()).toBe(start) + actions.redo(SESSION) + expect(surface()).toBe(start) + actions.toggleExpanded(SESSION) + actions.undo(SESSION) + expect(layout().expanded).toBe(false) + actions.redo(SESSION) + expect(layout().expanded).toBe(true) + }) +}) + +describe('createSidebarRightStore — floating panels and dividers', () => { + it('moves and resizes a floating panel, one entry each', () => { + const { actions, layout, entries, guide } = harness() + actions.floatTab(SESSION, guide(), { x: 10, y: 20, width: 300, height: 200 }) + const [float] = layout().floats + if (float === undefined) throw new Error('expected a floating pane') + const recorded = entries() + actions.moveFloat(SESSION, float, 40, 60) + expect(findTabPane(layout(), guide()).rect).toEqual({ x: 40, y: 60, width: 300, height: 200 }) + actions.resizeFloat(SESSION, float, { x: 40, y: 60, width: 420, height: 260 }) + expect(findTabPane(layout(), guide()).rect).toEqual({ x: 40, y: 60, width: 420, height: 260 }) + expect(entries()).toBe(recorded + 2) + }) + + it('focuses a pane as one entry, which a click on a floating panel records', () => { + const { actions, layout, entries, guide } = harness() + actions.floatTab(SESSION, guide(), { x: 10, y: 20, width: 300, height: 200 }) + const [float] = layout().floats + if (float === undefined) throw new Error('expected a floating pane') + // The root pane was reseeded when its guide floated away, so it is docked and focusable. + const docked = getPane(layout(), layout().rootId).id + actions.focusPane(SESSION, docked) + expect(layout().activePaneId).toBe(docked) + const recorded = entries() + actions.focusPane(SESSION, float) + expect(layout().activePaneId).toBe(float) + expect(entries()).toBe(recorded + 1) + }) + + it('records a divider drag as the split\'s new fractions', () => { + const { actions, layout, entries } = harness() + actions.splitPane(SESSION) + const rootId = getSplit(layout(), layout().rootId).id + expect(getSplit(layout(), rootId).sizes).toEqual([0.5, 0.5]) + const recorded = entries() + actions.resizeSplit(SESSION, rootId, [0.3, 0.7]) + expect(getSplit(layout(), rootId).sizes).toEqual([0.3, 0.7]) + expect(entries()).toBe(recorded + 1) + }) + + it('reports the pane a split created, and only then', () => { + const { actions, layout } = harness() + const settled = vi.fn<(paneId: PaneId) => void>() + const before = dockPaneIds(layout()) + actions.splitPane(SESSION, undefined, settled) + const created = dockPaneIds(layout()).filter(id => !before.includes(id)) + expect(created).toHaveLength(1) + expect(settled).toHaveBeenCalledExactlyOnceWith(created[0]) + // A floating pane cannot be split: nothing changes and nothing is reported. + const [tab] = getPane(layout(), created[0] as PaneId).tabs + if (tab === undefined) throw new Error('expected the seeded guide') + actions.floatTab(SESSION, tab) + const [float] = layout().floats + if (float === undefined) throw new Error('expected a floating pane') + actions.splitPane(SESSION, float, settled) + expect(settled).toHaveBeenCalledTimes(1) + }) +}) + +describe('createSidebarRightStore — focus', () => { + it('records a focus only when it changes which tab or pane is active, docked or floating', () => { + const { actions, layout, entries, guide } = harness() + actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a' }, () => {}) + const text = Object.values(layout().tabs).find(tab => tab.kind === 'text') + if (text === undefined) throw new Error('expected the text tab') + const home = layout().activePaneId + expect(getPane(layout(), home).activeTabId).toBe(text.id) + const recorded = entries() + // Already the active tab of the active pane, already the active pane: nothing to record. + actions.focusTab(SESSION, text.id) + actions.focusPane(SESSION, home) + expect(entries()).toBe(recorded) + // Another tab of the same pane: recorded. + actions.focusTab(SESSION, guide()) + expect(getPane(layout(), home).activeTabId).toBe(guide()) + expect(entries()).toBe(recorded + 1) + // Two panes: the home pane's active tab, focused while the other pane is active, is recorded. + actions.splitPane(SESSION) + const other = dockPaneIds(layout()).find(id => id !== home) + if (other === undefined) throw new Error('expected a second pane') + actions.focusPane(SESSION, other) + const split = entries() + actions.focusTab(SESSION, guide()) + expect(layout().activePaneId).toBe(home) + expect(entries()).toBe(split + 1) + // A floating pane: focusing it while it is active records nothing; after + // a docked pane took focus, focusing its pane and its tab record again. + actions.floatTab(SESSION, text.id) + const [float] = layout().floats + if (float === undefined) throw new Error('expected a floating pane') + expect(layout().activePaneId).toBe(float) + const floated = entries() + actions.focusPane(SESSION, float) + actions.focusTab(SESSION, text.id) + expect(entries()).toBe(floated) + actions.focusPane(SESSION, home) + actions.focusPane(SESSION, float) + expect(layout().activePaneId).toBe(float) + actions.focusPane(SESSION, home) + actions.focusTab(SESSION, text.id) + expect(layout().activePaneId).toBe(float) + expect(entries()).toBe(floated + 4) + }) +}) + +describe('createSidebarRightStore — the guide\'s uniqueness', () => { + it('places and drops any other tab as the kit plans it, guides in the target pane or not', () => { + const { actions, layout } = harness() + actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a' }, () => {}) + const text = Object.values(layout().tabs).find(tab => tab.kind === 'text') + if (text === undefined) throw new Error('expected the text tab') + actions.splitPane(SESSION) + const [left, right] = dockPaneIds(layout()) + if (left === undefined || right === undefined) throw new Error('expected two panes') + // Placed beside the right pane's guide: both stay, the text tab at the named slot. + actions.placeTab(SESSION, text.id, right, 0) + expect(getPane(layout(), right).tabs[0]).toBe(text.id) + expect(getPane(layout(), right).tabs).toHaveLength(2) + // Dropped on the left pane's centre: it moves in beside that pane's guide too. + actions.dropTab(SESSION, text.id, left, 'center') + expect(getPane(layout(), left).tabs).toContain(text.id) + expect(Object.values(layout().tabs).filter(tab => tab.kind === 'guide')).toHaveLength(2) + }) + + it('reorders the guide within its own pane as a plain place', () => { + const { actions, layout, guide } = harness() + const pane = layout().activePaneId + actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a' }, () => {}) + expect(getPane(layout(), pane).tabs[0]).toBe(guide()) + actions.placeTab(SESSION, guide(), pane, 2) + expect(getPane(layout(), pane).tabs.at(-1)).toBe(guide()) + expect(Object.values(layout().tabs).filter(tab => tab.kind === 'guide')).toHaveLength(1) + }) +}) diff --git a/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts new file mode 100644 index 0000000000..793bda8083 --- /dev/null +++ b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts @@ -0,0 +1,210 @@ +/** + * The Tab domain: what a record carries that the layout does not, and how long. + * + * Every assertion here is a lifetime rule a tab type relies on through its + * owner props: the signal aborts exactly when the record is gone, the pin lasts + * exactly as long, an undone close is a new occurrence, another session's + * records are left alone, and a tab's own actions name the tab's session and land where the tab is now. + */ +import { describe, expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { LayoutState, TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import { DockController, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' +import { TabDomain } from '../src/client/tab-domain.ts' +import type { SidebarRightNavigator } from '../src/client/tab-domain.ts' + +// The params map is empty in this package; a test-only scheme lets specs hand +// parameters through the typed `open`/`navigate` faces. +declare module '../src/client/contract/params.ts' { + interface SidebarRightResourceParamsMap { + test: { line?: number; x?: number } + } +} + +const SESSION = 's-one' as SessionId +const OTHER = 's-two' as SessionId + +/** A layout driven by the kit's own controller; the domain only ever reads snapshots. */ +function layouts() { + const controller = new DockController({ + makeInitialTab: id => ({ id, kind: 'guide', contentId: 'sidebar://guide', title: 'Start' }), + makePaneTab: id => ({ id, kind: 'guide', contentId: 'sidebar://guide', title: 'Start' }), + }) + const current = (): LayoutState => controller.getSnapshot().state + return { controller, current } +} + +function harness() { + const navigator = { openResourceIn: vi.fn(), openTabIn: vi.fn(), closeIn: vi.fn() } satisfies + SidebarRightNavigator & Record> + const pin = vi.fn<(address: string, signal: AbortSignal) => void>() + const domain = new TabDomain(navigator, pin) + return { domain, navigator, pin, ...layouts() } +} + +/** The record for a tab id, as the seat would hand it over. */ +function recordOf(state: LayoutState, tabId: TabId) { + const tab = state.tabs[tabId] + if (tab === undefined) throw new Error(`expected tab ${tabId}`) + return tab +} + +describe('TabDomain — occurrences follow records', () => { + it('pins a record\'s address when it first appears, with the occurrence\'s signal', () => { + const { domain, pin, controller, current } = harness() + const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' }) + domain.sync(SESSION, current()) + const occurrence = domain.occurrence(SESSION, recordOf(current(), tabId)) + expect(pin).toHaveBeenCalledWith('dsh-resource://file/session/s-one/a.txt', occurrence.signal) + expect(occurrence.signal.aborted).toBe(false) + // Seeded records are occurrences too; the guide is pinned like anything else. + expect(pin).toHaveBeenCalledWith('sidebar://guide', expect.any(AbortSignal)) + expect(pin).toHaveBeenCalledTimes(2) + }) + + it('pins once per occurrence, however many times the layout commits', () => { + const { domain, pin, controller, current } = harness() + controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' }) + domain.sync(SESSION, current()) + controller.setExpanded(true) + domain.sync(SESSION, current()) + expect(pin).toHaveBeenCalledTimes(2) + }) + + it('aborts the occurrence when its record vanishes, and builds a new one when undo restores it', () => { + const { domain, pin, controller, current } = harness() + const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' }) + domain.sync(SESSION, current()) + const first = domain.occurrence(SESSION, recordOf(current(), tabId)) + controller.closeTab(tabId) + domain.sync(SESSION, current()) + expect(first.signal.aborted).toBe(true) + controller.undo() + domain.sync(SESSION, current()) + const second = domain.occurrence(SESSION, recordOf(current(), tabId)) + expect(second).not.toBe(first) + expect(second.signal.aborted).toBe(false) + expect(second.navigation.getSnapshot()).toEqual({ address: 'dsh-resource://file/session/s-one/a.txt', params: undefined, revision: 0 }) + expect(pin.mock.calls.filter(([address]) => address === 'dsh-resource://file/session/s-one/a.txt')).toHaveLength(2) + }) + + it('leaves another session\'s occurrences alone when a session is synced', () => { + const { domain, controller, current } = harness() + const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' }) + domain.sync(SESSION, current()) + const held = domain.occurrence(SESSION, recordOf(current(), tabId)) + const other = layouts() + domain.sync(OTHER, other.current()) + expect(held.signal.aborted).toBe(false) + }) + + it('aborts every occurrence of every session on dispose', () => { + const { domain, controller, current } = harness() + const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' }) + domain.sync(SESSION, current()) + domain.sync(OTHER, layouts().current()) + const held = domain.occurrence(SESSION, recordOf(current(), tabId)) + domain.dispose() + expect(held.signal.aborted).toBe(true) + }) +}) + +describe('TabDomain — navigation', () => { + it('creates the occurrence for a tab the seat has not shown yet, at revision 1, and pins it on the next sync', () => { + const { domain, pin, controller, current } = harness() + const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' }) + domain.navigate(SESSION, tabId, { address: 'dsh-resource://file/session/s-one/a.txt', params: { line: 7 } }) + expect(pin).not.toHaveBeenCalled() + const occurrence = domain.occurrence(SESSION, recordOf(current(), tabId)) + expect(occurrence.navigation.getSnapshot()).toEqual({ address: 'dsh-resource://file/session/s-one/a.txt', params: { line: 7 }, revision: 1 }) + domain.sync(SESSION, current()) + expect(domain.occurrence(SESSION, recordOf(current(), tabId))).toBe(occurrence) + expect(pin).toHaveBeenCalledWith('dsh-resource://file/session/s-one/a.txt', occurrence.signal) + }) + + it('steps the revision on every navigation, params changed or not, and notifies', () => { + const { domain, controller, current } = harness() + const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' }) + domain.sync(SESSION, current()) + const occurrence = domain.occurrence(SESSION, recordOf(current(), tabId)) + const seen = vi.fn() + occurrence.navigation.subscribe(seen) + domain.navigate(SESSION, tabId, { address: 'dsh-resource://file/session/s-one/a.txt', params: { line: 3 } }) + domain.navigate(SESSION, tabId, { address: 'dsh-resource://file/session/s-one/a.txt', params: { line: 3 } }) + expect(occurrence.navigation.getSnapshot()).toEqual({ address: 'dsh-resource://file/session/s-one/a.txt', params: { line: 3 }, revision: 2 }) + expect(seen).toHaveBeenCalledTimes(2) + }) + + it('refuses an uncommitted occurrence without creating it during a read', () => { + const { domain, pin, controller, current } = harness() + const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' }) + expect(() => domain.occurrence(SESSION, { id: tabId })).toThrow('has no committed occurrence') + expect(pin).not.toHaveBeenCalled() + domain.sync(SESSION, current()) + const occurrence = domain.occurrence(SESSION, { id: tabId }) + expect(domain.occurrence(SESSION, { id: tabId })).toBe(occurrence) + expect(pin).toHaveBeenCalledWith('dsh-resource://file/session/s-one/a.txt', occurrence.signal) + controller.closeTab(tabId) + domain.sync(SESSION, current()) + expect(() => domain.occurrence(SESSION, { id: tabId })).toThrow('has no committed occurrence') + }) +}) + +describe('TabDomain — a tab\'s own actions', () => { + it('opens into the pane holding the tab at call time, including after it was moved', () => { + const { domain, navigator, controller, current } = harness() + controller.setExpanded(true) + const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' }) + controller.splitPane() + domain.sync(SESSION, current()) + const occurrence = domain.occurrence(SESSION, recordOf(current(), tabId)) + const home = current().nodes[current().rootId] + if (home?.kind !== 'split') throw new Error('expected a split root') + const [left, right] = home.children + if (left === undefined || right === undefined) throw new Error('expected two panes') + const leftPane = getPane(current(), left).id + const rightPane = getPane(current(), right).id + occurrence.tabActions.openResource('dsh-resource://file/session/s-one/b.txt') + expect(navigator.openResourceIn).toHaveBeenLastCalledWith(SESSION, 'dsh-resource://file/session/s-one/b.txt', { paneId: leftPane }) + controller.placeTab(tabId, rightPane, 0) + domain.sync(SESSION, current()) + occurrence.tabActions.openResource('dsh-resource://file/session/s-one/b.txt', { params: { line: 1 } }) + expect(navigator.openResourceIn).toHaveBeenLastCalledWith(SESSION, 'dsh-resource://file/session/s-one/b.txt', { paneId: rightPane, params: { line: 1 } }) + // The caller's pane wins over the default. + occurrence.tabActions.openResource('dsh-resource://file/session/s-one/b.txt', { paneId: leftPane }) + expect(navigator.openResourceIn).toHaveBeenLastCalledWith(SESSION, 'dsh-resource://file/session/s-one/b.txt', { paneId: leftPane }) + }) + + it('opens unplaced from a floating tab, since a floating pane holds one tab', () => { + const { domain, navigator, controller, current } = harness() + const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' }) + controller.floatTab(tabId) + domain.sync(SESSION, current()) + domain.occurrence(SESSION, recordOf(current(), tabId)).tabActions.openResource('dsh-resource://file/session/s-one/b.txt') + expect(navigator.openResourceIn).toHaveBeenLastCalledWith(SESSION, 'dsh-resource://file/session/s-one/b.txt', {}) + }) + + it('replaces itself and closes itself through the navigator', () => { + const { domain, navigator, controller, current } = harness() + const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' }) + domain.sync(SESSION, current()) + const { tabActions } = domain.occurrence(SESSION, recordOf(current(), tabId)) + tabActions.openTab('files', { replaceTab: true }) + expect(navigator.openTabIn).toHaveBeenLastCalledWith(SESSION, 'files', { replaceTab: tabId }) + tabActions.close() + expect(navigator.closeIn).toHaveBeenCalledWith(SESSION, tabId) + }) + + it('passes revealIfOpened through, so a tab may open a second copy beside itself', () => { + const { domain, navigator, controller, current } = harness() + const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' }) + domain.sync(SESSION, current()) + const { tabActions } = domain.occurrence(SESSION, recordOf(current(), tabId)) + tabActions.openResource('dsh-resource://file/session/s-one/a.txt', { revealIfOpened: false }) + expect(navigator.openResourceIn).toHaveBeenLastCalledWith( + SESSION, + 'dsh-resource://file/session/s-one/a.txt', + { paneId: current().rootId, revealIfOpened: false }, + ) + }) +}) diff --git a/packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx b/packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx new file mode 100644 index 0000000000..81c495f20b --- /dev/null +++ b/packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom +/** Tab information refuses readers whose committed record and navigation binding disagree. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, renderHook } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { keyedObservableHook } from '@deepseek-ai/dsh-client-ui-renderer/src/client/bindings.tsx' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { UseSidebarRightTabInfo } from '../src/client/contract/slots.ts' +import { createSidebarRightStore } from '../src/client/stores.ts' +import { TabDomain, type TabOccurrence } from '../src/client/tab-domain.ts' +import { tabInfoFactory, type TabHookContext } from '../src/client/tab-info.ts' + +const SESSION = 's-info' as SessionId +const ADDRESS = 'dsh-resource://file/session/s-info/a.txt' +const domains: TabDomain[] = [] + +afterEach(() => { + cleanup() + for (const domain of domains.splice(0)) domain.dispose() +}) + +function harness() { + const instance = createSidebarRightStore(() => 'Start').create() + const domain = new TabDomain({ openResourceIn: vi.fn(), openTabIn: vi.fn(), closeIn: vi.fn() }, vi.fn()) + domains.push(domain) + const navigationSources = new Map() + const useStore = bindSnapshotSelector(instance) + // The renderer erases the keyed snapshot type; this family contains only tab navigation sources. + const useTabNavigation = keyedObservableHook(key => navigationSources.get(key)) as TabHookContext['useTabNavigation'] + // Only sessionId is read from the standard share by this internal factory. + const standard = { sessionId: SESSION } as Parameters[0] + const layout = () => instance.getSnapshot().bySession[SESSION]?.layout + const sync = (): void => { + const committed = layout() + if (committed === undefined) throw new Error('expected the layout to commit') + domain.sync(SESSION, committed) + } + const bind = (tabId: TabId): UseSidebarRightTabInfo => { + const occurrence = domain.occurrence(SESSION, { id: tabId }) + navigationSources.set(tabId, occurrence.navigation) + return tabInfoFactory(standard, { + tabId, title: false, fullscreen: false, signal: occurrence.signal, actions: occurrence.tabActions, useStore, useTabNavigation, + }) + } + const open = (beforeCommit?: (tabId: TabId) => void): TabId => { + let opened: TabId | undefined + instance.actions.openContent(SESSION, { kind: 'text', contentId: ADDRESS, title: 'a' }, (tabId) => { + opened = tabId + domain.navigate(SESSION, tabId, { address: ADDRESS, params: undefined }) + beforeCommit?.(tabId) + }) + if (opened === undefined) throw new Error('expected the tab to open') + sync() + return opened + } + return { instance, domain, navigationSources, layout, sync, bind, open } +} + +function expectUncommitted(useTabInfo: UseSidebarRightTabInfo, tabId: TabId): void { + const message = `sidebarRight: tab "${tabId}" is not committed in session "${SESSION}"` + const suppressExpected = (event: ErrorEvent): void => { + if (event.error instanceof Error && event.error.message === message) event.preventDefault() + } + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + window.addEventListener('error', suppressExpected) + try { + expect(() => renderHook(useTabInfo)).toThrow(message) + } finally { + window.removeEventListener('error', suppressExpected) + report.mockRestore() + } +} + +describe('tabInfoFactory committed-record relation', () => { + it('rejects a navigation before the first layout commit and reads the same record after commit', () => { + const h = harness() + const tabId = h.open((opened) => { + expect(h.layout()).toBeUndefined() + expectUncommitted(h.bind(opened), opened) + }) + const view = renderHook(h.bind(tabId)) + expect(view.result.current.tab).toMatchObject({ id: tabId, contentId: ADDRESS }) + expect(view.result.current.tab.signal.aborted).toBe(false) + expect(view.result.current.tab.navigation.revision).toBe(1) + }) + + it('rejects a retained reader after its record closes, even while its navigation snapshot is held', () => { + const h = harness() + const tabId = h.open() + const useTabInfo = h.bind(tabId) + const view = renderHook(useTabInfo) + const { signal, navigation } = view.result.current.tab + view.unmount() + + h.instance.actions.closeTab(SESSION, tabId) + h.sync() + + expect(signal.aborted).toBe(true) + expect(h.layout()?.tabs[tabId]).toBeUndefined() + expect(h.navigationSources.get(tabId)?.getSnapshot()).toBe(navigation) + expectUncommitted(useTabInfo, tabId) + }) + + it('rejects a retained record after its keyed navigation binding is released', () => { + const h = harness() + const tabId = h.open() + const useTabInfo = h.bind(tabId) + const view = renderHook(useTabInfo) + expect(view.result.current.tab.id).toBe(tabId) + view.unmount() + const committed = h.layout() + + h.domain.dispose() + h.navigationSources.clear() + + expect(h.layout()).toBe(committed) + expect(h.layout()?.tabs[tabId]).toBeDefined() + expectUncommitted(useTabInfo, tabId) + }) +}) diff --git a/packages/client/ui-sidebar-right/tests/tab-registry.client.spec.ts b/packages/client/ui-sidebar-right/tests/tab-registry.client.spec.ts new file mode 100644 index 0000000000..2576115f33 --- /dev/null +++ b/packages/client/ui-sidebar-right/tests/tab-registry.client.spec.ts @@ -0,0 +1,286 @@ +/** + * Stage one of tab-type registration: how the registry decides who opens an + * address. + * + * The decision follows VS Code's editor resolver — declared globs narrow, an + * optional predicate vetoes, and survivors rank by band, matched-pattern length, + * then registration order — and every step is contract: a type shipped from + * another package relies on each one. So each is asserted, not assumed. + */ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { SidebarRightTabRegistry } from '../src/client/tab-registry.ts' +import type { SidebarRightTabDefinition } from '../src/client/tab-registry.ts' + +/** A type recognizing `patterns`, titled by its kind. */ +function typeFor( + kind: string, + patterns: readonly string[], + extra: Partial> = {}, +): SidebarRightTabDefinition { + return { id: `test/${kind}`, kind, patterns, title: address => `${kind}:${address}`, ...extra } +} + +/** Kinds of the ranked candidates, best first. */ +function ranked(registry: SidebarRightTabRegistry, address: string): string[] { + return registry.candidates(address).map(definition => definition.kind) +} + +describe('SidebarRightTabRegistry — recognition', () => { + it('matches a pattern containing ":" against the whole address', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('guide', ['sidebar://guide'])) + registry.register(typeFor('text', ['dsh-resource://file/**'])) + expect(registry.claim('sidebar://guide').kind).toBe('guide') + expect(registry.claim('dsh-resource://file/session/s/notes/a.txt').kind).toBe('text') + expect(ranked(registry, 'sidebar://files')).toEqual([]) + }) + + it('matches a pattern without ":" against the URI path at any depth, ignoring case', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('image', ['*.png'])) + expect(registry.claim('dsh-resource://file/session/s/deep/er/shot.PNG').kind).toBe('image') + expect(ranked(registry, 'dsh-resource://file/session/s/shot.png.txt')).toEqual([]) + }) + + it('matches no path pattern for an address that is not a URI', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('image', ['*.png'])) + registry.register(typeFor('raw', ['raw:*'])) + expect(ranked(registry, 'shot.png')).toEqual([]) + expect(ranked(registry, 'not a uri/shot.png')).toEqual([]) + // A whole-address pattern still reads the string as given. + expect(registry.claim('raw:thing').kind).toBe('raw') + }) + + it('does not hide dotfiles from a path pattern', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('env', ['.env*'])) + expect(registry.claim('dsh-resource://file/session/s/proj/.env.local').kind).toBe('env') + }) +}) + +describe('SidebarRightTabRegistry — ranking', () => { + it('ranks by band: extension over builtin over fallback, whatever the registration order', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('text', ['dsh-resource://file/**'], { priority: 'fallback' })) + registry.register(typeFor('markdown', ['*.md'], { priority: 'builtin' })) + registry.register(typeFor('third', ['*.md'])) + expect(ranked(registry, 'dsh-resource://file/session/s/a.md')).toEqual(['third', 'markdown', 'text']) + expect(registry.claim('dsh-resource://file/session/s/a.md').kind).toBe('third') + }) + + it('lets a more specific builtin beat the fallback viewer despite the viewer\'s longer pattern', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('text', ['dsh-resource://file/**'], { priority: 'fallback' })) + registry.register(typeFor('image', ['*.png'], { priority: 'builtin' })) + expect(registry.claim('dsh-resource://file/session/s/shot.png').kind).toBe('image') + expect(registry.claim('dsh-resource://file/session/s/notes.txt').kind).toBe('text') + }) + + it('lets an extension take over a builtin kind, and hands it back when the extension leaves', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('text', ['dsh-resource://file/**'], { priority: 'builtin', title: () => 'builtin text' })) + const release = registry.register(typeFor('text', ['*.txt'], { id: 'ext/text', title: () => 'extension text' })) + // The extension is the type in force: lookups, claims, and the listing. + expect(registry.get('text')?.title('x')).toBe('extension text') + expect(registry.claim('dsh-resource://file/session/s/a.txt').title).toBe('extension text') + expect(registry.entries().map(definition => definition.title('x'))).toEqual(['extension text']) + // The shadowed builtin's globs no longer count. + expect(ranked(registry, 'dsh-resource://file/session/s/a.bin')).toEqual([]) + release() + expect(registry.get('text')?.title('x')).toBe('builtin text') + expect(registry.claim('dsh-resource://file/session/s/a.bin').title).toBe('builtin text') + }) + + it('lets a builtin register under an extension already holding its kind, shadowed until the extension leaves', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'third-party/text' })) + const releaseBuiltin = registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'shipped/text', priority: 'builtin' })) + expect(registry.get('text')?.id).toBe('third-party/text') + expect(ranked(registry, 'dsh-resource://file/session/s/a.txt')).toEqual(['text']) + // The shadowed builtin leaving changes nothing in force, and its band is free again. + releaseBuiltin() + expect(registry.get('text')?.id).toBe('third-party/text') + expect(() => registry.register(typeFor('text', ['*.md'], { id: 'other/text', priority: 'builtin' }))).not.toThrow() + }) + + it('refuses a second registration in the same band, and any meeting a fallback of the kind', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'a/text', priority: 'builtin' })) + expect(() => registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'b/text', priority: 'builtin' }))).toThrow('already registered') + registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'c/text' })) + expect(() => registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'd/text' }))).toThrow('already registered') + expect(() => registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'e/text', priority: 'fallback' }))).toThrow('already registered') + registry.register(typeFor('hex', ['dsh-resource://file/**'], { id: 'a/hex', priority: 'fallback' })) + expect(() => registry.register(typeFor('hex', ['dsh-resource://file/**'], { id: 'b/hex', priority: 'builtin' }))).toThrow('already registered') + expect(() => registry.register(typeFor('hex', ['dsh-resource://file/**'], { id: 'c/hex' }))).toThrow('already registered') + }) + + it('refuses a second registration of an id, whatever its kind', () => { + const registry = new SidebarRightTabRegistry(new Context()) + const dispose = registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'pkg/viewer', priority: 'builtin' })) + expect(() => registry.register(typeFor('hex', ['*.bin'], { id: 'pkg/viewer' }))).toThrow('tab type id "pkg/viewer" is already registered') + dispose() + expect(() => registry.register(typeFor('hex', ['*.bin'], { id: 'pkg/viewer' }))).not.toThrow() + }) + + it('within a band, the longer matched pattern wins', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('markdown', ['*.md'], { priority: 'builtin' })) + registry.register(typeFor('readme', ['README.md'], { priority: 'builtin' })) + expect(ranked(registry, 'dsh-resource://file/session/s/proj/README.md')).toEqual(['readme', 'markdown']) + expect(ranked(registry, 'dsh-resource://file/session/s/proj/notes.md')).toEqual(['markdown']) + }) + + it('then registration order', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('first', ['dsh-resource://file/**'])) + registry.register(typeFor('second', ['dsh-resource://file/**'])) + expect(ranked(registry, 'dsh-resource://file/session/s/a.txt')).toEqual(['first', 'second']) + }) + + it('measures specificity by the longest pattern that matched, not the longest declared', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('wide', ['*.txt', 'some/very/long/**/never.matches'])) + registry.register(typeFor('narrow', ['notes.txt'])) + expect(ranked(registry, 'dsh-resource://file/session/s/notes.txt')).toEqual(['narrow', 'wide']) + }) +}) + +describe('SidebarRightTabRegistry — claiming', () => { + it('lets canOpen veto an address its globs matched', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('image', ['*.png'], { canOpen: address => address.startsWith('dsh-resource://file/') })) + expect(registry.claim('dsh-resource://file/session/s/shot.png').kind).toBe('image') + expect(ranked(registry, 'https://example.com/shot.png')).toEqual([]) + }) + + it('opens with a named type, skipping its globs but honouring its canOpen', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('text', ['dsh-resource://file/**'], { canOpen: address => !address.endsWith('.bin') })) + expect(registry.claim('sidebar://guide', 'text').kind).toBe('text') + expect(() => registry.claim('dsh-resource://file/session/s/a.bin', 'text')).toThrow('tab type "text" refuses') + expect(() => registry.claim('dsh-resource://file/session/s/a.txt', 'nope')).toThrow('no tab type is registered as "nope"') + }) + + it('answers with the address as contentId and the type\'s title', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('text', ['dsh-resource://file/**'])) + expect(registry.claim('dsh-resource://file/session/s/a.txt')).toEqual({ + kind: 'text', + contentId: 'dsh-resource://file/session/s/a.txt', + title: 'text:dsh-resource://file/session/s/a.txt', + }) + }) + + it('reads the title fresh, so a language change needs no re-registration', () => { + const registry = new SidebarRightTabRegistry(new Context()) + let language = 'zh' + registry.register({ id: 'shipped/guide', kind: 'guide', title: () => language === 'zh' ? '开始' : 'Start' }) + expect(registry.get('guide')?.title('sidebar://guide')).toBe('开始') + language = 'en' + expect(registry.get('guide')?.title('sidebar://guide')).toBe('Start') + }) +}) + +describe('SidebarRightTabRegistry — ids and page types', () => { + it('answers by kind with the definition in force, whose id is where its body lives', () => { + const registry = new SidebarRightTabRegistry(new Context()) + const disposeBuiltin = registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'shipped/text', priority: 'builtin' })) + expect(registry.get('text')?.id).toBe('shipped/text') + const disposeExtension = registry.register(typeFor('text', ['*.txt'], { id: 'third-party/text' })) + expect(registry.get('text')?.id).toBe('third-party/text') + disposeExtension() + expect(registry.get('text')?.id).toBe('shipped/text') + disposeBuiltin() + expect(registry.get('text')).toBeUndefined() + }) + + it('lets a page type omit patterns: it claims no address but is found by kind, and its guide entries carry its kind', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register({ + id: 'shipped/files', + kind: 'files', + priority: 'builtin', + title: () => 'Files', + guide: [{ order: 10, title: () => 'Files', description: () => 'Browse' }], + }) + expect(ranked(registry, 'dsh-resource://file/session/s/a.txt')).toEqual([]) + expect(registry.get('files')?.title('x')).toBe('Files') + expect(registry.guide().map(entry => [entry.kind, entry.order])).toEqual([['files', 10]]) + }) +}) + +describe('SidebarRightTabRegistry — lifetime', () => { + it('lists registered types in registration order', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('guide', ['sidebar://guide'])) + registry.register(typeFor('text', ['dsh-resource://file/**'])) + expect(registry.entries().map(entry => entry.kind)).toEqual(['guide', 'text']) + }) + + it('refuses a second type for the same kind in the same band', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('text', ['dsh-resource://file/**'])) + expect(() => registry.register(typeFor('text', ['other://**'], { id: 'other/text' }))) + .toThrow('tab kind "text" is already registered') + }) + + it('drops a type when its owner disposes, and frees the kind again', () => { + const registry = new SidebarRightTabRegistry(new Context()) + const dispose = registry.register(typeFor('text', ['dsh-resource://file/**'])) + dispose() + expect(registry.entries()).toEqual([]) + expect(registry.get('text')).toBeUndefined() + expect(() => registry.register(typeFor('text', ['other://**']))).not.toThrow() + }) + + it('drops a type registered inside another plugin\'s effect when that plugin is disposed', async () => { + const ctx = new Context() + const registry = new SidebarRightTabRegistry(ctx) + const fiber = ctx.plugin({ + apply(inner: Context) { + inner.effect(() => registry.register(typeFor('text', ['dsh-resource://file/**'])), 'test: text type') + }, + }) + await fiber.await() + expect(registry.get('text')).toBeDefined() + await fiber.dispose() + expect(registry.get('text')).toBeUndefined() + }) + + it('collects every type\'s guide entries in order, reference-stable between changes', () => { + const registry = new SidebarRightTabRegistry(new Context()) + const entry = (order: number) => ({ order, title: () => `#${order}`, description: () => '' }) + registry.register(typeFor('files', [], { guide: [entry(10)] })) + const first = registry.guide() + expect(registry.guide()).toBe(first) + registry.register(typeFor('artifacts', [], { guide: [entry(5)] })) + registry.register(typeFor('text', ['dsh-resource://file/**'])) + expect(registry.guide().map(item => item.kind)).toEqual(['artifacts', 'files']) + expect(registry.guide()).not.toBe(first) + }) + + it('notifies subscribers on registration and on disposal', () => { + const registry = new SidebarRightTabRegistry(new Context()) + const seen = vi.fn() + const unsubscribe = registry.subscribe(seen) + const dispose = registry.register(typeFor('text', ['dsh-resource://file/**'])) + expect(seen).toHaveBeenCalledTimes(1) + dispose() + expect(seen).toHaveBeenCalledTimes(2) + unsubscribe() + registry.register(typeFor('other', ['other://**'])) + expect(seen).toHaveBeenCalledTimes(2) + }) + + it('keeps entries reference-stable between changes', () => { + const registry = new SidebarRightTabRegistry(new Context()) + registry.register(typeFor('text', ['dsh-resource://file/**'])) + const first = registry.entries() + expect(registry.entries()).toBe(first) + registry.register(typeFor('guide', ['sidebar://guide'])) + expect(registry.entries()).not.toBe(first) + }) +}) diff --git a/packages/client/ui-sidebar-right/tsconfig.json b/packages/client/ui-sidebar-right/tsconfig.json new file mode 100644 index 0000000000..d1878df260 --- /dev/null +++ b/packages/client/ui-sidebar-right/tsconfig.json @@ -0,0 +1,48 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../../core/session" + }, + { + "path": "../resources" + }, + { + "path": "../store" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-dockkit" + }, + { + "path": "../ui-layout" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, + { + "path": "../ui-slots" + } + ] +} diff --git a/packages/client/ui-sidebar-right/tsdown.config.ts b/packages/client/ui-sidebar-right/tsdown.config.ts new file mode 100644 index 0000000000..559865cdcd --- /dev/null +++ b/packages/client/ui-sidebar-right/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-right', ['lib/types/index.js']) diff --git a/packages/client/ui-sidebar-textpreview/README.i18n.yaml b/packages/client/ui-sidebar-textpreview/README.i18n.yaml new file mode 100644 index 0000000000..3336e9ce03 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-textpreview/README.md +README.md: aa8364023637598ca84635321376fed07eef90cf +README.zh.md: ab157cb83045f414bf9cc9547c838d5c41cfd286 diff --git a/packages/client/ui-sidebar-textpreview/README.md b/packages/client/ui-sidebar-textpreview/README.md new file mode 100644 index 0000000000..aa83640236 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/README.md @@ -0,0 +1,81 @@ +--- +description: "The right Sidebar's plain-text viewer tab type for the dsh web client: paged reads of one workspace file, line navigation, wrap, reload, and the fallback claim on every file resource address." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-sidebar-textpreview + +English | [中文](README.zh.md) + +## Summary + +The right Sidebar's plain-text viewer: one workspace text file, read one page of lines at a time, with line navigation, wrap, and reload. It is the fallback type for every `file` resource address, and the template for a tab type shipped from outside `ui-sidebar-right`: every import from the Sidebar is a type, the file's metadata comes from the shared `file` resource, the text is the type's own business, and the type's controls live in its own body. + +## Table of Contents + +- [What it registers](#what-it-registers) +- [Addresses](#addresses) +- [How it reads](#how-it-reads) +- [Navigation](#navigation) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## What it registers + +- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-textpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; everything else lands here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title. +- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its header row shows the Host's absolute path and exposes its full value in a tooltip, with the type's two controls at its end: a wrap toggle (on by default; long lines wrap until the reader turns it off, per tab) and a reload button. The Sidebar's tab strip carries no controls of this type. The body takes the pane body's full height: the header row stays put and the file body below is the one scroller, so a short file leaves no unstyled space and a long file scrolls under a fixed path. Until metadata provides an absolute path, the header uses the requested path. +- **One store and one face**, session-scoped and bucketed by tab id. The store holds the pages read so far (keyed by the 1-based line each starts at, with the file version they belong to), the end-of-file flag, the read in flight or its failure, and the view: scroll offset, wrap (initially on), and the navigation revision the body last answered. The face (`loadPage`, `reloadPages`) performs the reads and writes through the store's actions. The bucket is forgotten when the owner's `signal` aborts, which is when the tab record is gone. + + +## Addresses + +A tab's address is `dsh-resource://file/session//` or `dsh-resource://file/absolute/` (a URI whose authority is the resource protocol, `file`, and whose path opens with the scope), built by `fileAddressFor` in `@deepseek-ai/dsh-util-workspace-path` and read back by `parseFileAddress`; every segment is component-encoded, and this package never splits the string itself. `hostFileOf` in `rpc.ts` turns the address into the session and path the endpoint takes: a `session` address reads under the session it names, with the relative path the Host resolves against that session's workspace root; an `absolute` address reads under the session the slot was mounted for, with the absolute path, and the Host's workspace confinement still applies. A malformed address throws, because the registry routes every `file` address to this type and a caller building one is expected to use the helper. + + +## How it reads + +The body reads its record, navigation and lifetime through `useTabInfo().tab`. Metadata and content come from different places: + +- `useResource<'file'>(tab.contentId)`, the standard hook from `@deepseek-ai/dsh-client-resources`, yields `{ absolutePath, version, bytes, changed }` from the `file` provider in `@deepseek-ai/dsh-api-workspace-files`. The body reads `changed` and the failed state: when the agent wrote the file after the last `stat`, a bar announces it with a reload button, and when the resource is `failed` — the file gone, or the Host refusing it — a failure bar takes that place with the failure's line and the same reload button, ahead of any pending `changed`. Either way the pages already read stay on screen: the text is never replaced under the reader. +- Pages come from `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`, bound in `rpc.ts` and called by the face with the session and path the address names. The first mount reads the first page; a **Load more** button at the end of the loaded text reads the next until `eof`. Each page carries its line count (`lines`), which is how one empty line and a page past the end read differently. A first page from a newer file version replaces the pages of the older one; a later page from a newer version is not adopted — the walk restarts from the first page, so the body never mixes two versions. A failed page shows one sentence per `workspace-file/*` code (`not-found`, `outside-workspace`, `too-large` for a page over the byte cap, `not-text`, `not-regular-file`) or the transport's own message, with a retry for the same page. +- **Reload** — the change bar's button and the header's reload control both call the resource's `reload()` (a fresh `stat`, which clears `changed`) and the face's `reloadPages` (drop the pages, read the first one again). A reload retires the reads still in flight — the face keeps a request generation per tab, and a page settling from an older generation writes nothing. The scroll offset is kept, so the reader stays where they were. + +Copy comes from the `sidebarTextpreview` locale namespace. + + +## Navigation + +`ctx.sidebarRight.openResource(address, { params: { line } })` — the `read` tool row passes its `offset` this way — arrives as `navigation.params`, which the body narrows to the `file` resource type's declared parameters (`SidebarRightResourceParamsMap['file']`, `{ line?: number }`, 1-based) without runtime validation: `params` is a typed same-process value. If the loaded pages do not reach that line, the body reads the next page, again, until they do or the file ends; then it scrolls the line to the top and marks it, once per `navigation.revision`. A body remounting for the same revision restores the reader's scroll offset instead. Opening the same file again without `revealIfOpened: false` focuses the existing tab and delivers the new parameters as a new revision. + + +## Model Experience + +None, as the preview is a browser-only viewer that registers no tool, prompt section, or session event. + +#### KV Cache effect + +No direct effect; what the user reads here never enters a model request. + +## Known Limitations and Deferred Work + + +- **Plain text only.** No syntax highlighting, images, rendered markdown, or search; a directory address fails with `not-regular-file`. +- **Sequential pages.** A line far into a large file loads every page before it; there is no seek to an arbitrary offset. +- **Package-local wrap glyph.** `IconWrapOutline16` lives in `src/client/icons.tsx` until the shared icon set carries one; the props contract already matches. +- **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back. + + +### Dev Note + +
    +Working context for maintainers — click to expand + +None. + +
    + +**Runtime invariant:** No companion is published. The type's only runtime state is one Slot store per tab, written by the body that owns it and forgotten on the tab's abort signal; there is no second observation of it to compare against. diff --git a/packages/client/ui-sidebar-textpreview/README.zh.md b/packages/client/ui-sidebar-textpreview/README.zh.md new file mode 100644 index 0000000000..ab157cb830 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/README.zh.md @@ -0,0 +1,81 @@ +--- +description: "dsh Web 客户端右侧 Sidebar 的纯文本查看器 tab 类型:对一个工作区文件分页读取,带行导航、换行、重新读取,并兜底认领每个 file 资源地址。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-sidebar-textpreview + +[English](README.md) | 中文 + +## 概述 + +右侧 Sidebar 的纯文本查看器:一个工作区文本文件,一次读一页行,带行号导航、换行与重新读取。它是每个 `file` 资源地址的兜底类型,也是 `ui-sidebar-right` 之外交付的 tab 类型的样板:来自 Sidebar 的每个 import 都是类型,文件的元数据来自共享的 `file` 资源,正文是类型自己的事,类型的控件住在自己的体里。 + +## 目录 + +- [注册了什么](#what-it-registers) +- [地址](#addresses) +- [怎么读](#how-it-reads) +- [导航](#navigation) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 注册了什么 + +- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-textpreview`(这个实现在 tab 系统里的唯一键,也是其体注册所用的 key),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其余一切落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题。 +- **体** —— keyed 坑位 `sidebar.right.pane.tab`,键为类型的 id。它的头部行显示 Host 的绝对路径,并在提示中保留完整值,末端是类型的两个控件:换行开关(默认开;长行折行直到读者关掉它,按 tab 记)与重新读取按钮。Sidebar 的 tab 条不承载这个类型的任何控件。体占满 pane 体的全部高度:头部行不动,其下的文件体是唯一的滚动者,于是短文件不留没有样式的空白,长文件在固定的路径下滚动。 元数据尚未提供绝对路径时,头部使用请求路径。 +- **一个 store 与一个 face**,会话作用域、按 tab id 分桶。store 持有已读的页(以每页起始的 1 起行号为键,连同它们所属的文件版本)、文件末尾标志、进行中的读取或其失败,以及视图:滚动位置、换行(初始为开)、体最近答过的导航 revision。face(`loadPage`、`reloadPages`)执行读取并经 store 的 action 写入。owner 的 `signal` abort 时——即 tab 记录消失时——桶被忘掉。 + + +## 地址 + +tab 的地址是 `dsh-resource://file/session//<相对该会话工作区根的路径>` 或 `dsh-resource://file/absolute/<去掉前导 / 的绝对路径>`(一个 URI,authority 是资源协议 `file`,路径以作用域开头),由 `@deepseek-ai/dsh-util-workspace-path` 的 `fileAddressFor` 构造、`parseFileAddress` 读回;每段都做 component 编码,本包从不自己拆这个串。`rpc.ts` 里的 `hostFileOf` 把地址变成端点所需的会话与路径:`session` 地址在它命名的会话下读取,相对路径由 Host 对该会话的工作区根解析;`absolute` 地址在坑位被挂载的会话下以绝对路径读取,Host 的工作区限制照样适用。畸形地址直接抛错,因为注册表把每个 `file` 地址都路由给这个类型,造地址的调用方本应使用助手。 + + +## 怎么读 + +正文通过 `useTabInfo().tab` 读取记录、导航和生命周期。元数据与内容来自不同的地方: + +- `useResource<'file'>(tab.contentId)`——来自 `@deepseek-ai/dsh-client-resources` 的标准 hook——从 `@deepseek-ai/dsh-api-workspace-files` 的 `file` 提供方得到 `{ absolutePath, version, bytes, changed }`。体读 `changed` 与失败态:agent 在上次 `stat` 之后写了文件时,一条提示带着重新载入按钮出现;资源为 `failed` 时——文件没了,或 Host 拒绝——一条失败条占据同一位置,显示失败句与同一个重新载入按钮,并优先于尚未处理的 `changed`。两种情况下已读的页都留在屏幕上:正文绝不在读者眼前被替换。 +- 页来自 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`,在 `rpc.ts` 绑定、由 face 以地址所命名的会话与路径调用。首次挂载读第一页;已加载文本末尾的 **加载更多** 按钮读下一页直到 `eof`。每页带着自己的行数(`lines`),单个空行与越过文件末尾的页由此区分。来自更新文件版本的第一页替换旧版本的页;更新版本的后续页不被采用——从第一页重新走一遍,于是体永不混合两个版本。失败的页按 `workspace-file/*` 错误码各显示一句(`not-found`、`outside-workspace`、超过字节上限的页 `too-large`、`not-text`、`not-regular-file`)或传输层自己的消息,并带一个重读同一页的重试。 +- **重新载入** —— 变更提示条的按钮与头部的重新读取控件都调用资源的 `reload()`(重新 `stat`,清掉 `changed`)与 face 的 `reloadPages`(丢掉所有页,重读第一页)。重载淘汰仍在飞的读取——face 按 tab 记请求代次,旧代次结算的页什么也不写。滚动位置保留,读者停在原处。 + +文案来自 `sidebarTextpreview` locale 命名空间。 + + +## 导航 + +`ctx.sidebarRight.openResource(address, { params: { line } })`——`read` 工具行以此传它的 `offset`——以 `navigation.params` 到达,体把它收窄为 `file` 资源类型声明的参数(`SidebarRightResourceParamsMap['file']`,`{ line?: number }`,1 起),不做运行时校验:`params` 是同进程的类型化值。已加载的页够不到该行时,体读下一页,再读,直到覆盖它或文件结束;然后把该行滚到顶部并标记,每个 `navigation.revision` 一次。同一 revision 下重新挂载的体恢复读者的滚动位置而不再跳。不带 `revealIfOpened: false` 再次打开同一文件时聚焦已有 tab,并把新参数作为新 revision 送达。 + + +## 模型体验 + +无,因为预览是纯浏览器侧的查看器,不注册工具、提示词段或会话事件。 + +#### KV Cache 影响 + +无直接影响;用户在这里读到的东西永不进入模型请求。 + +## 已知限制与延期工作 + + +- **只有纯文本。** 没有语法高亮、图片、Markdown 渲染或搜索;目录地址以 `not-regular-file` 失败。 +- **页按顺序加载。** 大文件深处的一行要先加载它之前的每一页;没有到任意偏移的 seek。 +- **换行图标为包内自绘。** `IconWrapOutline16` 住在 `src/client/icons.tsx`,直到共享图标集提供为止;props 契约已经一致。 +- **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。 + + +### 开发备注 + +
    +维护者工作上下文——点击展开 + +无。 + +
    + +**运行时不变量:** 不发布 companion。该类型唯一的运行时状态是每 tab 一份的 Slot store,由持有它的正文写入、随 tab 的中止信号忘掉;没有第二个观测源可与之比对。 diff --git a/packages/client/ui-sidebar-textpreview/package.json b/packages/client/ui-sidebar-textpreview/package.json new file mode 100644 index 0000000000..5df323e5a0 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/package.json @@ -0,0 +1,77 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-sidebar-textpreview", + "description": "Text preview tab type for the right Sidebar: the fallback viewer for file: addresses, drawn from the file resource with line navigation, wrap, and reload", + "version": "0.1.3-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-sidebar-textpreview" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-workspace-files", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-api-remotes" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-workspace-files": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-resources": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/TextPreview.module.css b/packages/client/ui-sidebar-textpreview/src/client/TextPreview.module.css new file mode 100644 index 0000000000..1bd16a6a23 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/TextPreview.module.css @@ -0,0 +1,169 @@ +/* The pane body is a block scroller with a definite height, not a flex + container, so the preview takes that height outright: the header row stays + put and the file body below is the one scroller, however short the file. */ +.preview { + display: flex; + flex: 1 1 auto; + flex-direction: column; + height: 100%; + min-height: 0; +} + +/* One row: the path, then the type's controls at its end. */ +.header { + display: flex; + flex: 0 0 auto; + gap: 2px; + align-items: center; + padding: 3px 6px 3px 10px; + border-bottom: 0.5px solid var(--dsw-alias-border-l1); +} + +.path { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* Announced, not applied: the reader keeps the text they are looking at. */ +.changed { + display: flex; + flex: 0 0 auto; + gap: 10px; + align-items: center; + margin: 0; + padding: 6px 10px; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + background: var(--dsw-alias-bg-layer-2); + border-bottom: 0.5px solid var(--dsw-alias-border-l1); +} + +.body { + /* Lines are positioned against the scroller, so a line's offset is its scroll target. */ + position: relative; + flex: 1 1 auto; + min-height: 0; + padding: 10px 0; + overflow: auto; + /* The notice and retry surfaces in this sheet are elevated, so the file body's + scroller rebinds the thumb indirection in a complete pair. */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size-secondary, 13px); + font-family: var(--dsw-font-mono, ui-monospace, monospace); + line-height: 1.6; + white-space: pre; +} + +.wrap { + white-space: pre-wrap; + word-break: break-word; +} + +/* One page of lines; pages abut so the file reads as one. */ +.page { + margin: 0; + font: inherit; + white-space: inherit; +} + +.line { + padding: 0 10px; +} + +.lineTarget { + background: var(--dsw-alias-interactive-bg-hover); +} + +.statusLine { + display: flex; + gap: 10px; + align-items: center; + margin: 0; + padding: 6px 10px; + color: var(--dsw-alias-label-secondary); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: 1.6; + white-space: normal; +} + +.status { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; + padding: 12px 10px; +} + +.action { + padding: 4px 10px; + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size-secondary, 13px); + font-family: var(--dsw-font, inherit); + white-space: normal; + background: var(--dsw-alias-bg-layer-2); + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 6px; + cursor: pointer; +} + +.action:hover { + background: var(--dsw-alias-bg-layer-3); +} + +/* The next page, asked for where the loaded text ends. */ +.more { + display: block; + margin: 8px 10px; + padding: 4px 10px; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + font-family: var(--dsw-font, inherit); + white-space: normal; + background: var(--dsw-alias-bg-layer-2); + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 6px; + cursor: pointer; +} + +.more:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-layer-3); +} + +.more:disabled { + color: var(--dsw-alias-label-tertiary); + cursor: default; +} + +/* Controls in the header row, sized like the docking kit's own pane controls. */ +.tool { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + color: var(--dsw-alias-label-secondary); + line-height: 1; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.tool:hover { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +.toolOn { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-textpreview/src/client/TextPreview.tsx new file mode 100644 index 0000000000..83ce3b5459 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/TextPreview.tsx @@ -0,0 +1,265 @@ +/** + * The text preview's body: a file's pages, or the reason the next one is not showing. + * + * Two sources meet here. The standard `useResource` hook gives the file's + * metadata — its version and whether the agent wrote it since — and this type's + * own store holds the pages it read through its face. A Host-reported change is + * announced, not applied: reloading under a reader would lose their place, so + * the bar waits for a click. A failed metadata frame — the file gone, its + * workspace unknown — takes the same bar's place over the pages already loaded, + * with the same reload. The type's controls, wrap and reload, sit at the end of + * the path row; the Sidebar's strip carries none of them. + */ +import { useEffect, useMemo, useRef } from 'react' +import type { ReactNode } from 'react' +import clsx from 'clsx' +import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { TextInjected } from './face.ts' +import { failureLine } from './failure-line.ts' +import { IconWrapOutline16 } from './icons.tsx' +import { hostFileOf } from './rpc.ts' +import type { TextPage, TextStore } from './store.ts' +import css from './TextPreview.module.css' + +/** The body's composed props: the tab, its navigation, the shared store and face, and copy. */ +export type TextPreviewProps = + & PropsRuntime<'sidebar.right.pane.tab'> + & PropsStore + & InjectFace + & PropsLocale<'sidebarTextpreview'> + +/** + * A page's lines. The Host joins a page's lines with `\n` without a terminator + * and counts them, so a page past the file's last line (`lines: 0`) has none + * and a page holding one empty line (`lines: 1`, `text: ''`) has one; a + * trailing `\n` ends an empty last line. + * @param page - the page's text and line count. + * @returns the lines in order. + */ +export function linesOf(page: TextPage): string[] { + return page.lines === 0 ? [] : page.text.split('\n') +} + +/** One loaded page: the 1-based line it starts at, its text, and its line count. */ +export interface LoadedPage extends TextPage { + readonly offset: number +} + +/** + * The loaded pages in file order. + * @param pages - the store's page table. + * @returns the pages, ascending by offset. + */ +export function loadedPages(pages: Record): LoadedPage[] { + return Object.entries(pages) + .map(([offset, page]) => ({ offset: Number(offset), ...page })) + .sort((left, right) => left.offset - right.offset) +} + +/** + * The last line the loaded pages reach, by the Host's line counts; 0 before the first page. + * @param pages - the loaded pages, ascending. + * @returns the 1-based last loaded line. + */ +export function lastLineLoaded(pages: readonly LoadedPage[]): number { + const last = pages.at(-1) + return last === undefined ? 0 : last.offset + last.lines - 1 +} + +/** + * Scroll the body so one line sits at its top. A line the pages do not hold + * leaves the body where it is. + * @param body - the scrolling container; lines are positioned against it. + * @param line - 1-based line. + */ +export function scrollToLine(body: HTMLElement, line: number): void { + const row = body.querySelector(`[data-textpreview-line="${line}"]`) + if (row instanceof HTMLElement) body.scrollTop = row.offsetTop +} + +/** + * The text type's body, registered under `sidebar.right.pane.tab` as `text`. + * @param props - composed slot props. + * @returns the pages read so far with their controls, or a progress line. + */ +export function TextPreview({ + useTabInfo, sessionId, useResource, useStore, actions, loadPage, reloadPages, t, +}: TextPreviewProps): ReactNode { + const { tab } = useTabInfo() + const { navigation, signal } = tab + const meta = useResource<'file'>(tab.contentId) + const file = useMemo(() => hostFileOf(tab.contentId, sessionId), [tab.contentId, sessionId]) + const state = useStore(s => s.byTab[tab.id]) + const bodyRef = useRef(null) + // Every tab of this type is a `file` resource address, so its params are the + // `file` type's; the union is narrowed on the one field read, not validated. + const line = navigation.params !== undefined && 'line' in navigation.params ? navigation.params.line : undefined + const pages = state?.pages + const loaded = useMemo(() => loadedPages(pages ?? {}), [pages]) + const loadedThrough = lastLineLoaded(loaded) + const hasPages = loaded.length > 0 + + // First mount reads the first page; a body coming back to a tab with pages + // reads nothing, because the store outlives the body. + const started = state !== undefined + useEffect(() => { + if (!started) loadPage(tab.id, file, 1, signal) + }, [started, tab.id, file, signal, loadPage]) + + // Come back where the reader was once there are pages to scroll: on a remount, + // and after a reload rebuilt the pages. Keyed on page presence only, so a + // scroll write never re-lands. + useEffect(() => { + const body = bodyRef.current + if (hasPages && body !== null && state !== undefined) body.scrollTop = state.scrollTop + }, [hasPages]) + + // Answer a navigation once: a line the pages do not reach yet loads the next + // page (again, until the pages cover it or the file ends); a line they hold + // is scrolled to and marked. The store remembers the answer, so a remount + // restores the reader's place instead. + useEffect(() => { + const body = bodyRef.current + if (state === undefined || body === null || state.revision === navigation.revision) return + if (line === undefined) { + actions.navigated(tab.id, navigation.revision) + return + } + if (line > loadedThrough && !state.eof) { + if (!state.loading && state.failure === undefined) loadPage(tab.id, file, loadedThrough + 1, signal) + return + } + scrollToLine(body, line) + actions.navigated(tab.id, navigation.revision) + // Recorded here as well as by the scroll event, so the store holds the + // landing before any later navigation reads it. + actions.scrolled(tab.id, body.scrollTop) + }, [navigation.revision, line, loadedThrough, state?.eof, state?.loading, state?.failure, started]) + + // One block per line inside one block per page, so a line has an offset to + // scroll to and a target can be marked. The trailing newline keeps an empty + // line one line tall. Memoized so a scroll write's re-render hands React the + // same elements back. + const rows = useMemo(() => loaded.map(page => ( +
    +      {linesOf(page).map((content, index) => {
    +        const number = page.offset + index
    +        const target = number === line
    +        return (
    +          
    + {content}{'\n'} +
    + ) + })} +
    + )), [loaded, line]) + + if (state === undefined) { + return ( +
    +

    {t('loading')}

    +
    + ) + } + const next = loadedThrough + 1 + const displayPath = meta.value?.absolutePath ?? file.path + // Reload does two things at once: stat again through the resource (which + // clears `changed`, or a failed frame) and read the pages again through the face. + const reload = (): void => { meta.reload(); reloadPages(tab.id, file, signal) } + return ( +
    + {meta.failure !== undefined + ? ( + // The file's metadata failed — gone, or its workspace unknown — which + // outranks a pending change; the pages already read stay under it. +

    + {failureLine(t, meta.failure)} + +

    + ) + : meta.value?.changed === true && ( +

    + {t('changed')} + +

    + )} +
    +
    {displayPath}
    + + +
    +
    { actions.scrolled(tab.id, event.currentTarget.scrollTop) }} + > + {rows} + {state.failure !== undefined && ( +

    + {failureLine(t, state.failure)} + +

    + )} + {!state.eof && state.failure === undefined && ( + + )} +
    +
    + ) +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/definition.ts b/packages/client/ui-sidebar-textpreview/src/client/definition.ts new file mode 100644 index 0000000000..9a6cf946b8 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/definition.ts @@ -0,0 +1,54 @@ +/** + * Stage one of this package's registration: what the `text` tab type IS. + * + * The type claims every `dsh-resource://file/` address in either scope — + * `session//` or `absolute/` — at the `fallback` band: it + * is the plain viewer that any more specific type for the same address should + * beat, the position VS Code's text editor holds among its editors. `canOpen` + * refuses an address `parseFileAddress` rejects at claim time, where an + * unclaimed address is the documented wiring error. + */ +import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path' + +/** The tab kind this package owns. */ +export const TEXTPREVIEW_KIND = 'text' + +/** This implementation's identity in the tab system: the key its body registers under. */ +export const TEXTPREVIEW_ID = '@deepseek-ai/dsh-client-ui-sidebar-textpreview' + +/** + * The tab title for one `file:` address: its decoded basename. + * + * The whole address stays the content identity, so two files with one name in + * different directories are two tabs; only the chip text is shortened. Decoding + * is per segment, matching how the address was built, so a name carrying `#`, + * `?`, or a space reads as itself. + * @param address - a `file:`-shaped address. + * @returns the decoded last path segment, or the address itself when it has none. + */ +export function basenameOf(address: string): string { + const name = address.slice(address.lastIndexOf('/') + 1) + if (name === '') return address + try { + return decodeURIComponent(name) + } catch { + // A malformed percent sequence is still a name; showing it raw beats refusing the address. + return name + } +} + +/** + * The text type's registry definition. + * @returns the definition to register. + */ +export function textDefinition(): SidebarRightTabDefinition { + return { + id: TEXTPREVIEW_ID, + kind: TEXTPREVIEW_KIND, + patterns: ['dsh-resource://file/**'], + priority: 'fallback', + canOpen: address => parseFileAddress(address) !== undefined, + title: basenameOf, + } +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/face.ts b/packages/client/ui-sidebar-textpreview/src/client/face.ts new file mode 100644 index 0000000000..fdf5f1d282 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/face.ts @@ -0,0 +1,111 @@ +/** + * The preview's asynchronous half: reading pages into the store. + * + * The component never awaits anything. It asks for a page and this face performs + * the read and writes the outcome through the store's own actions — the + * Slot-standard `inject` form, so the write set stays the store's. The session + * the read runs under comes from the file's address, not from the slot's + * session: the address is the read's whole authority. + * + * A tab's pages are one file version walked from the first line. Dropping them + * — a reload, or a page of a newer version arriving past the first line, which + * restarts the walk — retires every read still in flight for the tab: a + * settlement from before the drop writes nothing. Cleanup rides the owner's + * `signal`, armed once per tab by its first read: the abort forgets the tab's + * bucket and this bookkeeping, a request is not made for a record that already + * ended, and a settlement arriving after the record is gone has nothing left to + * write to. A tab that never read has no bucket to forget. + */ +import type { BoundActions } from '@deepseek-ai/dsh-client-store' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { ReadWorkspaceFilePage, SessionFile } from './rpc.ts' +import type { TextStore } from './store.ts' + +/** The preview's injected business face, as the body receives it. */ +export interface TextInjected { + /** + * Read one page into the store. A page of a newer file version than the pages + * held, arriving past the first line, is not kept: the tab's pages are dropped + * and the first page read again. The tab's first read arms the abort listener + * that forgets its bucket when the record ends. + * @param tabId - the tab being drawn. + * @param file - the session and workspace path the tab's address names. + * @param offset - 1-based line the page starts at. + * @param signal - the tab record's lifetime. + */ + readonly loadPage: (tabId: TabId, file: SessionFile, offset: number, signal: AbortSignal) => void + /** + * Drop every page and read the first one again, for a file the Host reports + * changed. The view is kept, so the reader stays where they were; a page read + * still in flight writes nothing when it settles. + * @param tabId - the tab being drawn. + * @param file - the session and workspace path the tab's address names. + * @param signal - the tab record's lifetime. + */ + readonly reloadPages: (tabId: TabId, file: SessionFile, signal: AbortSignal) => void +} + +/** + * What the face remembers of one tab: the read generation a settlement must + * match, and the version of the pages held. Created by the tab's first read, + * which also arms the one abort listener that forgets the tab. + */ +interface TabReads { + generation: number + version: string | undefined +} + +/** + * Bind the preview's face to one paged read. + * @param read - the bound `workspaceFiles.read` call. + * @returns the Slot `inject` factory: bound actions in, face out. The slot's session id is unused because the address carries its own. + */ +export function textFace(read: ReadWorkspaceFilePage): (sessionId: SessionId, actions: BoundActions) => TextInjected { + return (_sessionId: SessionId, actions: BoundActions): TextInjected => { + const tabs = new Map() + // Reached with a live signal only: the record's end forgets the tab's + // bucket and this bookkeeping in one listener, however often its body mounts. + const readsOf = (tabId: TabId, signal: AbortSignal): TabReads => { + const held = tabs.get(tabId) + if (held !== undefined) return held + const created: TabReads = { generation: 0, version: undefined } + tabs.set(tabId, created) + signal.addEventListener('abort', () => { + tabs.delete(tabId) + actions.forget(tabId) + }, { once: true }) + return created + } + const loadPage = (tabId: TabId, file: SessionFile, offset: number, signal: AbortSignal): void => { + if (signal.aborted) return + const reads = readsOf(tabId, signal) + const { generation } = reads + actions.loading(tabId) + void read(file.sessionId, file.path, offset, signal).then((result) => { + if (signal.aborted || reads.generation !== generation) return + if (!result.ok) { + actions.failed(tabId, result.error) + return + } + // Pages of two versions never meet: a newer file past the first line + // restarts the walk from line 1, where the store adopts the new version. + if (offset !== 1 && reads.version !== undefined && result.value.version !== reads.version) { + restart(tabId, file, signal) + return + } + reads.version = result.value.version + actions.page(tabId, result.value) + }) + } + const restart = (tabId: TabId, file: SessionFile, signal: AbortSignal): void => { + if (signal.aborted) return + const reads = readsOf(tabId, signal) + reads.generation += 1 + reads.version = undefined + actions.reset(tabId) + loadPage(tabId, file, 1, signal) + } + return { loadPage, reloadPages: restart } + } +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/failure-line.ts b/packages/client/ui-sidebar-textpreview/src/client/failure-line.ts new file mode 100644 index 0000000000..a8caee1e1c --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/failure-line.ts @@ -0,0 +1,36 @@ +/** + * The failure line one Remote code deserves. + * + * Kept apart from the component so the mapping is testable on its own. Codes + * this reader does not name fall to the generic line carrying the carrier's + * message. + */ +import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' +import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' + +/** Render a byte count the way a person reads one. */ +function humanBytes(bytes: number): string { + if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB` + if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB` + return `${bytes} B` +} + +/** + * Say what went wrong, in terms of the file rather than of the transport. + * @param t - namespace-bound translate. + * @param failure - the settled Remote failure. + * @returns the line to show in place of the file. + */ +export function failureLine(t: TranslateNS<'sidebarTextpreview'>, failure: RemoteFailure): string { + switch (failure.code) { + case 'workspace-file/not-found': return t('error.notFound') + case 'workspace-file/outside-workspace': return t('error.outsideWorkspace') + case 'workspace-file/too-large': + return t('error.tooLarge', { limit: humanBytes(failure.details.limit) }) + case 'workspace-file/not-text': return t('error.notText') + case 'workspace-file/not-regular-file': return t('error.notRegularFile') + // Carrier and unclassified host failures reach the reader as themselves: + // this panel knows nothing useful to add to a transport-level message. + default: return t('error.unavailable', { message: failure.message }) + } +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/icons.tsx b/packages/client/ui-sidebar-textpreview/src/client/icons.tsx new file mode 100644 index 0000000000..10428efb52 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/icons.tsx @@ -0,0 +1,27 @@ +/** + * Glyphs this package draws that the shared icon set does not carry yet. + * Same props contract as `@deepseek-ai/dsh-client-ui-primitives` icons, so a + * shared replacement is a one-line import change. + */ +import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' + +/** Three text lines, the middle one turning back under itself. */ +export const IconWrapOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + +) diff --git a/packages/client/ui-sidebar-textpreview/src/client/index.ts b/packages/client/ui-sidebar-textpreview/src/client/index.ts new file mode 100644 index 0000000000..ee3fd06ac2 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/index.ts @@ -0,0 +1,73 @@ +/** + * Browser half: register `text` as a right-Sidebar tab type. + * + * The type reaches the Sidebar through its public path only: the definition into + * `ctx.sidebarRightTabs` and the body into the keyed `sidebar.right.pane.tab` + * seat under the definition's `id`. Nothing here reaches into the Sidebar's store, its + * panes, or its sequence. The file's metadata comes from the standard + * `useResource`, served by the `file` provider; the text is this type's own + * business, read one page at a time through its face. Every import from another + * client plugin is a type. + */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-resources/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { WorkspaceFileParams } from '@deepseek-ai/dsh-api-workspace-files/client' +import { TextPreview } from './TextPreview.tsx' +import { TEXTPREVIEW_ID, textDefinition } from './definition.ts' +import { textFace } from './face.ts' +import { createReadPage } from './rpc.ts' +import { createTextStore } from './store.ts' +import { en, zh } from './locales.ts' + +// Values stay package-private unless another package needs them; the plugin +// surface is `apply`, `inject`, and the store factory another registration may +// share, plus the types a consumer of the seat or the store names. +export type { SidebarTextpreviewKey } from './locales.ts' +export type { TextPreviewProps } from './TextPreview.tsx' +export type { TextInjected } from './face.ts' +export type { ReadWorkspaceFilePage, SessionFile, WorkspaceFilesReadRemote } from './rpc.ts' +export type { TextPage, TextState, TextStore, TextTabState } from './store.ts' + +/** This package's copy namespace. */ +const NS = 'sidebarTextpreview' + +declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' { + interface SidebarRightResourceParamsMap { + /** File line navigation supported by the text preview. */ + file: WorkspaceFileParams + } +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Text-preview progress, paging, change, control, and failure lines. */ + sidebarTextpreview: import('./locales.ts').SidebarTextpreviewKey + } +} + +/** + * Required browser services: the tab registry, the slot registry, copy, and the + * Remote carrier with its `workspaceFiles` namespace. + */ +export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles'] + +/** + * Client plugin body: register the type, its dictionaries, and its body. + * @param ctx - client root context carrying the registry, the slots, copy, and the Remote face. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.sidebarRightTabs.register(textDefinition()), 'ui-sidebar-textpreview: text type') + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar-textpreview: dictionaries') + + const store = createTextStore() + const face = textFace(createReadPage(ctx.remote)) + ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register( + { name: 'sidebar.right.pane.tab', key: TEXTPREVIEW_ID, locale: NS, store, inject: face }, + TextPreview, + )), 'ui-sidebar-textpreview: text body') +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/locales.ts b/packages/client/ui-sidebar-textpreview/src/client/locales.ts new file mode 100644 index 0000000000..f0456c952e --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/locales.ts @@ -0,0 +1,44 @@ +/** + * `sidebarTextpreview` namespace dictionaries. + * + * The failure lines are the point of this file: a preview that cannot show a + * page has to say which of several different things went wrong, and each one + * suggests a different next step for the reader. + */ + +/** Simplified Chinese dictionary and key-set source of truth. */ +export const zh = { + loading: '正在读取…', + loadMore: '加载更多', + changed: '文件已被修改,显示的还是旧内容。', + reloadNow: '重新载入', + reload: '重新读取文件', + wrap: '自动换行', + 'error.notFound': '这个文件不在了。可能已被移动或删除。', + 'error.outsideWorkspace': '这个文件在工作区之外,侧栏不会读取它。', + 'error.tooLarge': '这一页太大,侧栏不读取超过 {limit} 的页。', + 'error.notText': '这不是文本文件,没法在这里查看。', + 'error.notRegularFile': '这不是一个普通文件,没有可显示的文本。', + 'error.unavailable': '读取失败:{message}', + retry: '重试', +} satisfies Record + +/** Text-preview dictionary key union. */ +export type SidebarTextpreviewKey = keyof typeof zh + +/** English dictionary, checked against the Chinese key set. */ +export const en = { + loading: 'Reading…', + loadMore: 'Load more', + changed: 'The file has changed; this is the older text.', + reloadNow: 'Reload', + reload: 'Read the file again', + wrap: 'Wrap lines', + 'error.notFound': 'That file is gone. It may have been moved or deleted.', + 'error.outsideWorkspace': 'That file is outside the workspace, so the sidebar will not read it.', + 'error.tooLarge': 'That page is too large; the sidebar does not read pages above {limit}.', + 'error.notText': 'That is not a text file, so it cannot be shown here.', + 'error.notRegularFile': 'That is not a regular file, so it has no text to show.', + 'error.unavailable': 'Read failed: {message}', + retry: 'Retry', +} satisfies Record diff --git a/packages/client/ui-sidebar-textpreview/src/client/rpc.ts b/packages/client/ui-sidebar-textpreview/src/client/rpc.ts new file mode 100644 index 0000000000..1f5547b5bf --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/rpc.ts @@ -0,0 +1,86 @@ +/** + * The paged read this type performs, bound to the Client Remote. + * + * Content is the consumer's business: the `file` resource carries metadata only, + * and the text arrives here one page of lines at a time. The endpoint takes a + * session and a workspace path while a tab carries a `dsh-resource://file/` + * address in one of two scopes, so this module also owns that translation. + */ +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceFileRange, WorkspaceFileText } from '@deepseek-ai/dsh-api-workspace-files/types' +import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path' + +/** The slice of the Client Remote this package calls. */ +export interface WorkspaceFilesReadRemote { + readonly workspaceFiles: { + /** + * Read one page of lines. + * @param sessionId - the session whose workspace resolves `path`. + * @param path - workspace path, absolute or relative to the workspace root. + * @param range - 1-based start line; the Host's page cap applies when `limit` is absent. + * @param signal - cancels the call. + * @returns the page, or the failure the Host declares. + */ + read( + sessionId: SessionId, + path: string, + range: WorkspaceFileRange, + signal?: AbortSignal, + ): Promise> + } +} + +/** + * The read one page performs, injected so the face stays host-free. + * + * The session travels with the call because the endpoint resolves the workspace + * root from it: the same path means different files in different sessions. A + * Remote call does not reject: the result carries the failure. + */ +export type ReadWorkspaceFilePage = ( + sessionId: SessionId, + path: string, + offset: number, + signal: AbortSignal, +) => Promise> + +/** The file one tab reads: the session the read runs under and the path handed to the Host. */ +export interface SessionFile { + /** The session whose workspace confines the read. */ + readonly sessionId: SessionId + /** The path the Host receives: workspace-relative for a `session` address, absolute for an `absolute` one. */ + readonly path: string +} + +/** + * The session and path one `dsh-resource://file/…` address names. + * + * A `session` address names its own session and a workspace-relative path, so + * a tab addressed into another session reads from that session. An `absolute` + * address carries no session and is read through the seat's own, which the + * Host confines to that session's workspace. The registry routes every + * parseable `file` address to this type, so an address `parseFileAddress` + * rejects is a programming error and throws. + * @param address - a tab's `dsh-resource://file/…` address. + * @param sessionId - the seat's session, which an `absolute` address is read through. + * @returns the session and the path to hand the endpoint. + */ +export function hostFileOf(address: string, sessionId: SessionId): SessionFile { + const parsed = parseFileAddress(address) + if (parsed === undefined) throw new Error(`ui-sidebar-textpreview: not a file address "${address}"`) + // The address is a string boundary: its id segment is the Session id it names. + return parsed.scope === 'session' + ? { sessionId: parsed.sessionId as SessionId, path: parsed.path } + : { sessionId, path: parsed.path } +} + +/** + * Bind the paged read to one Remote face. The page length is the Host's + * configured cap, so no `limit` travels. + * @param remote - the Client Remote carrying the `workspaceFiles` namespace. + * @returns the read the face performs. + */ +export function createReadPage(remote: WorkspaceFilesReadRemote): ReadWorkspaceFilePage { + return (sessionId, path, offset, signal) => remote.workspaceFiles.read(sessionId, path, { offset }, signal) +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/store.ts b/packages/client/ui-sidebar-textpreview/src/client/store.ts new file mode 100644 index 0000000000..e3f9705314 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/store.ts @@ -0,0 +1,193 @@ +/** + * The preview's own state: the pages it has read, and how the reader views them. + * + * The `file` resource carries metadata only, so the text is this type's to fetch + * and keep — page by page, keyed by the 1-based line each page starts at. The + * view state (scroll offset, wrap, the navigation already answered) must outlive + * the body: a tab switched away from unmounts its body and must come back where + * it was rather than re-read or jump to its opening line again. Bucketed by tab + * id because two tabs of one file scroll independently. + * + * A bucket lives as long as its tab record: the face's first read of a tab arms + * one listener on the owner's `signal` that forgets the bucket when the record + * ends, and a tab that never read has no bucket to forget. + */ +import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { WorkspaceFileText } from '@deepseek-ai/dsh-api-workspace-files/types' + +/** + * One page as the store keeps it: its text and the Host's line count, which + * tells a page past the file's last line (`lines: 0`) from a page holding one + * empty line (`lines: 1`, `text: ''`). + */ +export interface TextPage { + readonly text: string + readonly lines: number +} + +/** One tab's pages and view. */ +export interface TextTabState { + /** The file version the loaded pages belong to; absent before the first page. */ + version: string | undefined + /** Pages by the 1-based line each starts at. */ + pages: Record + /** Whether the last loaded page reached the end of the file. */ + eof: boolean + /** A page read is in flight. */ + loading: boolean + /** Why the last page read failed; cleared by the next page. */ + failure: RemoteFailure | undefined + /** Scroll offset of the body, in px. */ + scrollTop: number + /** Whether long lines wrap instead of scrolling horizontally; on until the reader turns it off. */ + wrap: boolean + /** The `navigation.revision` the body already answered; absent before the first. */ + revision: number | undefined +} + +/** Every tab's state, keyed by tab id. */ +export interface TextState { + byTab: Record +} + +/** + * A tab's state before it reads, scrolls, toggles, or answers anything. + * @returns the empty bucket. + */ +export function fresh(): TextTabState { + return { + version: undefined, + pages: {}, + eof: false, + loading: false, + failure: undefined, + scrollTop: 0, + wrap: true, + revision: undefined, + } +} + +/** The bucket for one tab, created on first write. */ +function bucket(state: TextState, tabId: TabId): TextTabState { + return state.byTab[tabId] ??= fresh() +} + +/** The preview store's write set; every action names the tab it writes. */ +type TextActions = { + loading: (draft: TextState, tabId: TabId) => void + page: (draft: TextState, tabId: TabId, page: WorkspaceFileText) => void + failed: (draft: TextState, tabId: TabId, failure: RemoteFailure) => void + reset: (draft: TextState, tabId: TabId) => void + scrolled: (draft: TextState, tabId: TabId, scrollTop: number) => void + toggledWrap: (draft: TextState, tabId: TabId) => void + navigated: (draft: TextState, tabId: TabId, revision: number) => void + forget: (draft: TextState, tabId: TabId) => void +} + +/** + * Declare the preview's store. + * + * Constructed once in apply and shared by the body and the tools registrations, + * which the slot runtime allows because both are session-scoped. + * @returns the store handle to declare on both registrations. + */ +export function createTextStore(): EngineStoreHandle { + return defineStore({ + init: (): TextState => ({ byTab: {} }), + actions: { + /** + * Mark a page read as in flight. + * @param d - draft state. + * @param tabId - the tab being drawn. + */ + loading: (d, tabId: TabId) => { + bucket(d, tabId).loading = true + }, + /** + * Keep one page. A page from a newer file version invalidates the pages + * of the older one, so the body never shows two versions at once. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param page - the page the Host returned. + */ + page: (d, tabId: TabId, page: WorkspaceFileText) => { + const state = bucket(d, tabId) + if (state.version !== undefined && state.version !== page.version) state.pages = {} + state.version = page.version + state.pages[page.offset] = { text: page.text, lines: page.lines } + state.eof = page.eof + state.loading = false + state.failure = undefined + }, + /** + * Record why a page read failed; the pages already held stay. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param failure - the settled Remote failure. + */ + failed: (d, tabId: TabId, failure: RemoteFailure) => { + const state = bucket(d, tabId) + state.loading = false + state.failure = failure + }, + /** + * Drop every page, keeping the view, for a re-read from the first line. + * @param d - draft state. + * @param tabId - the tab being drawn. + */ + reset: (d, tabId: TabId) => { + const state = bucket(d, tabId) + state.pages = {} + state.eof = false + state.version = undefined + state.failure = undefined + }, + /** + * Record where one tab's body is scrolled to. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param scrollTop - the body's scroll offset, in px. + */ + scrolled: (d, tabId: TabId, scrollTop: number) => { + bucket(d, tabId).scrollTop = scrollTop + }, + /** + * Switch one tab between wrapped and unwrapped lines. + * @param d - draft state. + * @param tabId - the tab being drawn. + */ + toggledWrap: (d, tabId: TabId) => { + const state = bucket(d, tabId) + state.wrap = !state.wrap + }, + /** + * Record that the body answered one navigation, so a remount restores the + * reader's position instead of jumping again. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param revision - the `navigation.revision` answered. + */ + navigated: (d, tabId: TabId, revision: number) => { + bucket(d, tabId).revision = revision + }, + /** + * Drop one tab's state, for a tab record that is gone. + * @param d - draft state. + * @param tabId - the tab that went away. + */ + forget: (d, tabId: TabId) => { + const byTab: TextState['byTab'] = {} + // Keys were written from tab ids; reading them back as ids is exact. + for (const [id, state] of Object.entries(d.byTab) as [TabId, TextTabState][]) { + if (id !== tabId) byTab[id] = state + } + d.byTab = byTab + }, + }, + }) +} + +/** The store handle type both registrations declare. */ +export type TextStore = ReturnType diff --git a/packages/client/ui-sidebar-textpreview/src/css-modules.d.ts b/packages/client/ui-sidebar-textpreview/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-sidebar-textpreview/src/index.ts b/packages/client/ui-sidebar-textpreview/src/index.ts new file mode 100644 index 0000000000..a8b3f761b9 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/index.ts @@ -0,0 +1,4 @@ +/** Pure host half; the whole preview lives in the browser export. */ + +/** Host plugin body: the preview contributes nothing to the host tree. */ +export function apply(): void {} diff --git a/packages/client/ui-sidebar-textpreview/tests/apply.client.spec.ts b/packages/client/ui-sidebar-textpreview/tests/apply.client.spec.ts new file mode 100644 index 0000000000..a8687153da --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/tests/apply.client.spec.ts @@ -0,0 +1,84 @@ +/** + * The plugin's registrations, and their removal when the plugin goes. + * + * The registry is real, because "registered" means what it says a type is; the + * slot, locale, and Remote faces are recorders, because what matters here is + * what was handed to them — one body seat under the type's kind with its store + * and face — and that every registration is gone after dispose, which is what + * makes a reload safe. + */ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts' +import { TEXTPREVIEW_ID, TEXTPREVIEW_KIND } from '../src/client/definition.ts' +import { apply, inject } from '../src/client/index.ts' +import { apply as hostApply } from '../src/index.ts' +import { TextPreview } from '../src/client/TextPreview.tsx' +import { en, zh } from '../src/client/locales.ts' + +interface Recorded { + name: string + key: string + locale: string + store: unknown + inject: unknown + component: unknown +} + +async function boot() { + const ctx = new Context() + const tabs = new SidebarRightTabRegistry(ctx) + const registered: Recorded[] = [] + const slots = { + inject: vi.fn((_name: string, register: () => () => void) => register()), + register: vi.fn((options: Omit, component: unknown) => { + const entry: Recorded = { ...options, component } + registered.push(entry) + return () => { registered.splice(registered.indexOf(entry), 1) } + }), + } + const dictionaries = new Map() + const locale = { + register: vi.fn((ns: string, dicts: unknown) => { + dictionaries.set(ns, dicts) + return () => { dictionaries.delete(ns) } + }), + } + const workspaceFiles = { read: vi.fn() } + ctx.provide('sidebarRightTabs', tabs as never) + ctx.provide('slots', slots as never) + ctx.provide('locale', locale as never) + ctx.provide('remote', { workspaceFiles } as never) + ctx.provide('remote.workspaceFiles', workspaceFiles as never) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + return { tabs, registered, dictionaries, fiber } +} + +describe('ui-sidebar-textpreview apply', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + + it('registers the type, its dictionaries, and the body seat under the type\'s id with a store and a face', async () => { + const { tabs, registered, dictionaries } = await boot() + expect(tabs.get(TEXTPREVIEW_KIND)?.priority).toBe('fallback') + expect(tabs.get(TEXTPREVIEW_KIND)?.id).toBe(TEXTPREVIEW_ID) + expect(dictionaries.get('sidebarTextpreview')).toEqual({ zh, en }) + // The seat key is the implementation's id, not the kind: an extension may + // take the kind over, and the seat must still find this body. + expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([ + ['sidebar.right.pane.tab', TEXTPREVIEW_ID, 'sidebarTextpreview', TextPreview], + ]) + expect(registered[0]?.store).toBeDefined() + expect(typeof registered[0]?.inject).toBe('function') + }) + + it('takes every registration back when the plugin is disposed', async () => { + const { tabs, registered, dictionaries, fiber } = await boot() + await fiber.dispose() + expect(tabs.get(TEXTPREVIEW_KIND)).toBeUndefined() + expect(registered).toEqual([]) + expect(dictionaries.size).toBe(0) + }) +}) diff --git a/packages/client/ui-sidebar-textpreview/tests/definition.client.spec.ts b/packages/client/ui-sidebar-textpreview/tests/definition.client.spec.ts new file mode 100644 index 0000000000..97fb367e10 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/tests/definition.client.spec.ts @@ -0,0 +1,88 @@ +/** + * What the `text` type claims, and how it yields. + * + * The type is the fallback viewer for every `file` resource address, so the contract + * worth asserting is the yielding: a narrower type registered at a higher + * band takes its addresses, and everything else still lands here. Routing is + * exercised through the real registry, because "fallback" means whatever the + * registry's ranking means by it. + */ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts' +import { TEXTPREVIEW_ID, TEXTPREVIEW_KIND, basenameOf, textDefinition } from '../src/client/definition.ts' + +describe('basenameOf', () => { + it('decodes the last segment, so an escaped name reads as itself', () => { + expect(basenameOf('dsh-resource://file/session/s-1/work/notes/a%20b%23c.md')).toBe('a b#c.md') + }) + + it('falls back to the whole address when there is no last segment', () => { + expect(basenameOf('dsh-resource://file/session/s-1/')).toBe('dsh-resource://file/session/s-1/') + }) + + it('keeps a malformed percent escape as it is rather than refusing the address', () => { + expect(basenameOf('dsh-resource://file/session/s-1/work/%E0%A4%A')).toBe('%E0%A4%A') + }) +}) + +describe('textDefinition', () => { + it('is the fallback claimant of every file: address, titled by basename', () => { + const definition = textDefinition() + expect(definition.id).toBe(TEXTPREVIEW_ID) + expect(definition.kind).toBe(TEXTPREVIEW_KIND) + expect(definition.patterns).toEqual(['dsh-resource://file/**']) + expect(definition.priority).toBe('fallback') + expect(definition.title('dsh-resource://file/session/s-1/work/README.md')).toBe('README.md') + // Both scopes have a read endpoint here; anything the grammar rejects is refused. + expect(definition.canOpen?.('dsh-resource://file/session/s-1/work/README.md')).toBe(true) + expect(definition.canOpen?.('dsh-resource://file/absolute/home/me/README.md')).toBe(true) + expect(definition.canOpen?.('dsh-resource://file/shared/team/README.md')).toBe(false) + expect(definition.canOpen?.('dsh-resource://file/session')).toBe(false) + }) +}) + +describe('text type in the registry', () => { + function registry() { + const tabs = new SidebarRightTabRegistry(new Context()) + tabs.register(textDefinition()) + return tabs + } + + it('claims files of any extension, depth, and dot-prefix in either scope', () => { + const tabs = registry() + for (const address of [ + 'dsh-resource://file/session/s-1/a.md', + 'dsh-resource://file/session/s-1/deep/er/path/x.py', + 'dsh-resource://file/session/s-1/w/.env', + 'dsh-resource://file/session/s-1/w/Makefile', + 'dsh-resource://file/absolute/home/me/notes.md', + 'dsh-resource://file/absolute/C:/w/x.ts', + ]) { + expect(tabs.claim(address)).toEqual({ kind: TEXTPREVIEW_KIND, contentId: address, title: basenameOf(address) }) + } + }) + + it('refuses a file address in no known scope at claim time, named or ranked', () => { + const tabs = registry() + const shared = 'dsh-resource://file/shared/team/notes.md' + expect(tabs.candidates(shared)).toEqual([]) + expect(() => tabs.claim(shared)).toThrow('no registered tab type claims') + expect(() => tabs.claim(shared, TEXTPREVIEW_KIND)).toThrow(`tab type "${TEXTPREVIEW_KIND}" refuses`) + }) + + it('yields an address to a narrower type at the extension band, and keeps the rest', () => { + const tabs = registry() + tabs.register({ id: 'test/image', kind: 'image', patterns: ['*.png'], priority: 'extension', title: () => 'image' }) + expect(tabs.claim('dsh-resource://file/session/s-1/w/logo.png').kind).toBe('image') + expect(tabs.claim('dsh-resource://file/session/s-1/w/logo.md').kind).toBe(TEXTPREVIEW_KIND) + // Still listed for the picture: a caller naming the kind may open it as text. + expect(tabs.candidates('dsh-resource://file/session/s-1/w/logo.png').map(type => type.kind)).toEqual(['image', TEXTPREVIEW_KIND]) + }) + + it('does not claim addresses of other schemes', () => { + const tabs = registry() + expect(() => tabs.claim('sidebar://guide')).toThrow('no registered tab type claims') + expect(() => tabs.claim('https://example.com/a.md')).toThrow('no registered tab type claims') + }) +}) diff --git a/packages/client/ui-sidebar-textpreview/tests/face.client.spec.ts b/packages/client/ui-sidebar-textpreview/tests/face.client.spec.ts new file mode 100644 index 0000000000..289109a507 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/tests/face.client.spec.ts @@ -0,0 +1,157 @@ +/** + * The face's contract with the store: a read in flight is visible, its outcome + * lands as a page or a failure, a read outlived by its tab writes nothing, a + * reload starts over from the first line and retires the reads still out, and + * a newer file version arriving past the first line restarts the walk. The read + * runs under the session the file names, not the one the face was injected for. + */ +import { describe, expect, it, vi } from 'vitest' +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceFileText } from '@deepseek-ai/dsh-api-workspace-files/types' +import { textFace } from '../src/client/face.ts' +import type { ReadWorkspaceFilePage } from '../src/client/rpc.ts' +import { createTextStore } from '../src/client/store.ts' +import { FILE, PATH, SESSION, failure, page } from './fixtures.client.ts' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' + +const TAB_1 = 'tab-1' as TabId + +/** One read awaiting the spec's answer. */ +interface PendingRead { + readonly offset: number + resolve(result: RemoteResult): void +} + +function bench() { + const instance = createTextStore().create() + const pending: PendingRead[] = [] + const read = vi.fn((_session, _path, offset) => + new Promise((resolve) => { pending.push({ offset, resolve }) })) + // The store's own `forget`, counted: the record's end must forget a tab exactly once. + const forget = vi.fn(instance.actions.forget) + // Injected for another session on purpose: the address's session must win. + const face = textFace(read)('other-session' as SessionId, { ...instance.actions, forget }) + /** Settle the oldest outstanding read, or the oldest one for `offset`. */ + const settle = (result: RemoteResult, offset?: number): void => { + const at = offset === undefined ? 0 : pending.findIndex(call => call.offset === offset) + const [call] = pending.splice(at, 1) + if (call === undefined) throw new Error('no outstanding read to settle') + call.resolve(result) + } + return { + instance, read, face, forget, settle, + outstanding: () => pending.map(call => call.offset), + tab: () => instance.getSnapshot().byTab[TAB_1], + } +} + +const flush = (): Promise => new Promise((resolve) => { setTimeout(resolve, 0) }) + +describe('textFace', () => { + it('marks the read in flight, then keeps the page', async () => { + const { read, face, settle, tab } = bench() + const controller = new AbortController() + face.loadPage(TAB_1, FILE, 1, controller.signal) + expect(read).toHaveBeenCalledWith(SESSION, PATH, 1, controller.signal) + expect(tab()?.loading).toBe(true) + settle(page(1, ['a', 'b'], false)) + await flush() + expect(tab()).toMatchObject({ loading: false, pages: { 1: { text: 'a\nb', lines: 2 } }, eof: false }) + }) + + it('records a failed read', async () => { + const { face, settle, tab } = bench() + face.loadPage(TAB_1, FILE, 1, new AbortController().signal) + settle(failure('workspace-file/outside-workspace', { path: PATH })) + await flush() + expect(tab()?.failure?.code).toBe('workspace-file/outside-workspace') + expect(tab()?.loading).toBe(false) + }) + + it('forgets the tab when its record ends, once, however many reads armed it, and writes nothing afterwards', async () => { + const { read, face, forget, settle, tab } = bench() + const controller = new AbortController() + const armed = vi.spyOn(controller.signal, 'addEventListener') + face.loadPage(TAB_1, FILE, 1, controller.signal) + settle(page(1, ['a'], false)) + await flush() + face.loadPage(TAB_1, FILE, 2, controller.signal) + face.reloadPages(TAB_1, FILE, controller.signal) + expect(armed.mock.calls.filter(([type]) => type === 'abort')).toHaveLength(1) + expect(tab()).toBeDefined() + controller.abort() + expect(forget).toHaveBeenCalledExactlyOnceWith(TAB_1) + expect(tab()).toBeUndefined() + // The reads still out settle into nothing, and no request is made for the ended record. + settle(page(1, ['A'], true)) + await flush() + expect(tab()).toBeUndefined() + face.loadPage(TAB_1, FILE, 1, controller.signal) + face.reloadPages(TAB_1, FILE, controller.signal) + expect(read).toHaveBeenCalledTimes(3) + expect(tab()).toBeUndefined() + }) + + it('reloads from the first line, dropping the pages and keeping the view', async () => { + const { instance, read, face, settle, tab } = bench() + const controller = new AbortController() + face.loadPage(TAB_1, FILE, 1, controller.signal) + settle(page(1, ['a'], false)) + await flush() + instance.actions.scrolled(TAB_1, 77) + face.reloadPages(TAB_1, FILE, controller.signal) + expect(tab()).toMatchObject({ pages: {}, eof: false, version: undefined, loading: true, scrollTop: 77 }) + expect(read).toHaveBeenLastCalledWith(SESSION, PATH, 1, controller.signal) + }) + + it('drops a page that settles after a reload retired it, whichever lands first', async () => { + const { face, settle, outstanding, tab } = bench() + const signal = new AbortController().signal + face.loadPage(TAB_1, FILE, 1, signal) + settle(page(1, ['a', 'b', 'c'], false)) + await flush() + // Load-more is out when the reader reloads: the new first page lands first. + face.loadPage(TAB_1, FILE, 4, signal) + face.reloadPages(TAB_1, FILE, signal) + expect(outstanding()).toEqual([4, 1]) + settle(page(1, ['A'], false, 'v2'), 1) + await flush() + expect(tab()).toMatchObject({ pages: { 1: { text: 'A', lines: 1 } }, version: 'v2', eof: false, loading: false }) + // The retired page lands afterwards and changes nothing, not even the end flag. + settle(page(4, ['d'], true), 4) + await flush() + expect(tab()).toMatchObject({ pages: { 1: { text: 'A', lines: 1 } }, version: 'v2', eof: false, loading: false }) + }) + + it('starts the walk over when a page of a newer version arrives past the first line', async () => { + const { read, face, settle, outstanding, tab } = bench() + const signal = new AbortController().signal + face.loadPage(TAB_1, FILE, 1, signal) + settle(page(1, ['a', 'b', 'c'], false)) + await flush() + face.loadPage(TAB_1, FILE, 4, signal) + // The file changed between the two reads: the page is not kept beside the older ones. + settle(page(4, ['D'], true, 'v2')) + await flush() + expect(tab()).toMatchObject({ pages: {}, version: undefined, eof: false, loading: true }) + expect(read).toHaveBeenCalledTimes(3) + expect(outstanding()).toEqual([1]) + settle(page(1, ['A', 'B'], true, 'v2')) + await flush() + expect(tab()).toMatchObject({ pages: { 1: { text: 'A\nB', lines: 2 } }, version: 'v2', eof: true, loading: false }) + }) + + it('keeps a first page of a newer version, since the store drops the older pages for it', async () => { + const { face, settle, tab } = bench() + const signal = new AbortController().signal + face.loadPage(TAB_1, FILE, 1, signal) + settle(page(1, ['a'], false)) + await flush() + // A retry of the first page after the file changed lands as the new version. + face.loadPage(TAB_1, FILE, 1, signal) + settle(page(1, ['A'], true, 'v2')) + await flush() + expect(tab()).toMatchObject({ pages: { 1: { text: 'A', lines: 1 } }, version: 'v2', eof: true }) + }) +}) diff --git a/packages/client/ui-sidebar-textpreview/tests/failure-line.client.spec.ts b/packages/client/ui-sidebar-textpreview/tests/failure-line.client.spec.ts new file mode 100644 index 0000000000..3e8a9935ee --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/tests/failure-line.client.spec.ts @@ -0,0 +1,36 @@ +/** + * One sentence per endpoint code, and the transport's own words for anything else. + */ +import { describe, expect, it } from 'vitest' +import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +// The namespace declaration `TranslateNS<'sidebarTextpreview'>` resolves against. +import type {} from '../src/client/index.ts' +import { failureLine } from '../src/client/failure-line.ts' + +/** Key-echoing translate that also shows its parameters, so a formatted value is visible. */ +const t: TranslateNS<'sidebarTextpreview'> = (key, params) => + params === undefined ? key : `${key}(${Object.entries(params).map(([k, v]) => `${k}=${String(v)}`).join(',')})` + +function failure(code: string, details: Record = {}, message = 'boom'): RemoteFailure { + return { code, message, details } as unknown as RemoteFailure +} + +describe('failureLine', () => { + it('names each workspace-file code', () => { + expect(failureLine(t, failure('workspace-file/not-found'))).toBe('error.notFound') + expect(failureLine(t, failure('workspace-file/outside-workspace'))).toBe('error.outsideWorkspace') + expect(failureLine(t, failure('workspace-file/not-text'))).toBe('error.notText') + expect(failureLine(t, failure('workspace-file/not-regular-file'))).toBe('error.notRegularFile') + }) + + it('states the byte cap the way a person reads one', () => { + expect(failureLine(t, failure('workspace-file/too-large', { limit: 512 }))).toBe('error.tooLarge(limit=512 B)') + expect(failureLine(t, failure('workspace-file/too-large', { limit: 4096 }))).toBe('error.tooLarge(limit=4 KB)') + expect(failureLine(t, failure('workspace-file/too-large', { limit: 3 * 1024 * 1024 }))).toBe('error.tooLarge(limit=3 MB)') + }) + + it('passes any other failure through in its own words', () => { + expect(failureLine(t, failure('gateway/internal', {}, 'socket closed'))).toBe('error.unavailable(message=socket closed)') + }) +}) diff --git a/packages/client/ui-sidebar-textpreview/tests/fixtures.client.ts b/packages/client/ui-sidebar-textpreview/tests/fixtures.client.ts new file mode 100644 index 0000000000..72c480ae00 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/tests/fixtures.client.ts @@ -0,0 +1,147 @@ +/** + * Shared harness for the body specs: a real store instance, a real face over a + * scripted paged read, a scripted `useResource`, and the owner props a tab + * record carries. + * + * The framework's standard kit is replaced by the few members these components + * read, behind one documented cast, so the specs exercise the components and + * not the slot runtime. + */ +import { vi } from 'vitest' +import type { Mock } from 'vitest' +import { act } from '@testing-library/react' +import { useSyncExternalStore } from 'react' +import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' +import type { ResourceSnapshot } from '@deepseek-ai/dsh-client-resources/client' +import type { WorkspaceFileResource } from '@deepseek-ai/dsh-api-workspace-files/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceFileText } from '@deepseek-ai/dsh-api-workspace-files/types' +import type { TextPreviewProps } from '../src/client/TextPreview.tsx' +import { textFace } from '../src/client/face.ts' +import type { TextInjected } from '../src/client/face.ts' +import type { ReadWorkspaceFilePage, SessionFile } from '../src/client/rpc.ts' +import { createTextStore } from '../src/client/store.ts' +import type { TextStore } from '../src/client/store.ts' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' + +export const TAB_ID = 'tab-1' as TabId +export const SESSION = 's-1' as SessionId +/** The path relative to the session's workspace root, as the Host receives it. */ +export const PATH = 'work/notes.md' +export const ABSOLUTE_PATH = '/host/project/work/notes.md' +/** The tab's address: the file under this session's scope. */ +export const ADDRESS = 'dsh-resource://file/session/s-1/work/notes.md' +/** What the address names, as the face receives it. */ +export const FILE: SessionFile = { sessionId: SESSION, path: PATH } + +/** One page the Host would return: the lines joined without a terminator, and their count. */ +export function page(offset: number, lines: readonly string[], eof: boolean, version = 'v1'): RemoteResult { + return { ok: true, value: { absolutePath: ABSOLUTE_PATH, version, offset, text: lines.join('\n'), lines: lines.length, eof, bytes: 100 } } +} + +/** One failed page read. */ +export function failure(code: string, details: Record = {}): RemoteResult { + return { ok: false, error: { code, message: 'boom', details } as unknown as RemoteFailure } +} + +/** The `file` resource's metadata: live, or failed beside the last live value. */ +function meta(changed: boolean, failure: RemoteFailure | undefined, reload: () => void): ResourceSnapshot { + const value: WorkspaceFileResource = { absolutePath: ABSOLUTE_PATH, version: 'v1', bytes: 100, changed } + return failure === undefined + ? { status: 'live', value, failure: undefined, reload } + : { status: 'failed', value, failure, reload } +} + +/** Test-local selector hook over a framework-neutral store instance. */ +function hookOf(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) { + return function useSelector(sel: (s: T) => S): S { + return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) + } +} + +/** Key-echoing translate that also shows its parameters. */ +export function t(key: string, params?: Record): string { + return params === undefined ? key : `${key}(${Object.entries(params).map(([k, v]) => `${k}=${String(v)}`).join(',')})` +} + +/** Flush page reads that resolved since the last render, then React's work. */ +export async function settle(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +/** What one tab record's harness hands a spec. Named so the helper's declaration stays portable. */ +export interface Harness { + /** The live store instance both components read. */ + instance: ReturnType + /** The face bound to the scripted read. */ + face: TextInjected + /** The scripted paged read. */ + read: Mock + /** The resource's `reload`. */ + reload: Mock<() => void> + /** The tab record's lifetime. */ + controller: AbortController + /** The scripted `useResource`. */ + useResource: Mock<() => ResourceSnapshot> + /** Composed props for one navigation state. */ + props: (navigation?: { params?: unknown; revision: number }) => TextPreviewProps + /** Script what one offset resolves to from now on. */ + script(offset: number, result: RemoteResult): void + /** Script whether the next render's `useResource` reports a pending change. */ + setChanged(changed: boolean): void + /** Script the next render's `useResource` as failed with `failure`, or live again with `undefined`. */ + setFailure(failure: RemoteFailure | undefined): void +} + +/** + * One tab record's harness. + * @param script - the page each offset resolves to; an unscripted offset fails `not-found`. + * @returns the store, the scripted faces, and a props builder. + */ +export function harness(script: Record> = {}): Harness { + const instance = createTextStore().create() + const pages: Record> = { ...script } + const read = vi.fn((_session, _path, offset) => + Promise.resolve(pages[offset] ?? failure('workspace-file/not-found', { path: PATH }))) + const face = textFace(read)(SESSION, instance.actions) + const reload = vi.fn<() => void>() + const current = { changed: false, failure: undefined as RemoteFailure | undefined, snapshot: meta(false, undefined, reload) } + const refresh = (): void => { current.snapshot = meta(current.changed, current.failure, reload) } + const useResource = vi.fn<() => ResourceSnapshot>(() => current.snapshot) + const controller = new AbortController() + const tabActions = { openResource: vi.fn(), openTab: vi.fn(), close: vi.fn(), replace: vi.fn() } + const props = (navigation: { params?: unknown; revision: number } = { revision: 1 }) => ({ + useTabInfo: () => ({ + sidebar: { expanded: true, fullscreen: false }, + panel: { id: 'pane-1' }, + tab: { + id: TAB_ID, kind: 'text', contentId: ADDRESS, title: 'notes.md', visible: true, + navigation: { address: ADDRESS, params: navigation.params, revision: navigation.revision }, + signal: controller.signal, + actions: tabActions, + }, + }), + sessionId: SESSION, + useResource, + useStore: hookOf(instance), + actions: instance.actions, + loadPage: face.loadPage, + reloadPages: face.reloadPages, + t, + }) as unknown as TextPreviewProps + return { + instance, + face, + read, + reload, + controller, + useResource, + props, + script(offset, result) { pages[offset] = result }, + setChanged(changed) { current.changed = changed; refresh() }, + setFailure(failure) { current.failure = failure; refresh() }, + } +} diff --git a/packages/client/ui-sidebar-textpreview/tests/lines.client.spec.ts b/packages/client/ui-sidebar-textpreview/tests/lines.client.spec.ts new file mode 100644 index 0000000000..40b3822395 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/tests/lines.client.spec.ts @@ -0,0 +1,43 @@ +/** + * The body's page arithmetic: how a page's text and line count become lines, + * how the store's page table becomes the pages in file order, and how far they + * reach. + */ +import { describe, expect, it } from 'vitest' +import { lastLineLoaded, linesOf, loadedPages } from '../src/client/TextPreview.tsx' +import type { TextPage } from '../src/client/store.ts' + +const held = (text: string, lines: number): TextPage => ({ text, lines }) + +describe('linesOf', () => { + it('splits on newlines, so a trailing one ends an empty last line as the Host counted it', () => { + expect(linesOf(held('a\nb', 2))).toEqual(['a', 'b']) + expect(linesOf(held('a\n', 2))).toEqual(['a', '']) + expect(linesOf(held('a\nb\n', 3))).toEqual(['a', 'b', '']) + expect(linesOf(held('a\n\nb', 3))).toEqual(['a', '', 'b']) + }) + + it('tells a page holding one empty line from a page past the file\'s last line by the count', () => { + expect(linesOf(held('', 1))).toEqual(['']) + expect(linesOf(held('', 0))).toEqual([]) + }) +}) + +describe('loadedPages', () => { + it('orders the store\'s page table by the line each page starts at', () => { + expect(loadedPages({})).toEqual([]) + expect(loadedPages({ 4: held('d\ne', 2), 1: held('a\nb\nc', 3) })).toEqual([ + { offset: 1, text: 'a\nb\nc', lines: 3 }, + { offset: 4, text: 'd\ne', lines: 2 }, + ]) + }) +}) + +describe('lastLineLoaded', () => { + it('is 0 before the first page and the last line of the last page after, by the Host\'s count', () => { + expect(lastLineLoaded([])).toBe(0) + expect(lastLineLoaded(loadedPages({ 4: held('d\ne', 2), 1: held('a\nb\nc', 3) }))).toBe(5) + expect(lastLineLoaded(loadedPages({ 1: held('', 1) }))).toBe(1) + expect(lastLineLoaded(loadedPages({ 1: held('a', 1), 2: held('', 0) }))).toBe(1) + }) +}) diff --git a/packages/client/ui-sidebar-textpreview/tests/rpc.client.spec.ts b/packages/client/ui-sidebar-textpreview/tests/rpc.client.spec.ts new file mode 100644 index 0000000000..5a26f699e7 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/tests/rpc.client.spec.ts @@ -0,0 +1,40 @@ +/** + * The address-to-read translation: a `dsh-resource://file/session//` + * address names the session the read runs under and the workspace-relative path + * it hands the Host; a `dsh-resource://file/absolute/` address is read + * through the seat's own session; anything else fails loud. + */ +import { describe, expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { createReadPage, hostFileOf } from '../src/client/rpc.ts' +import type { WorkspaceFilesReadRemote } from '../src/client/rpc.ts' +import { ADDRESS, FILE, PATH, SESSION, page } from './fixtures.client.ts' + +const SEAT = 's-seat' as SessionId + +describe('hostFileOf', () => { + it('reads the session and the decoded relative path out of a session file address, whatever the seat\'s session', () => { + expect(hostFileOf(ADDRESS, SEAT)).toEqual(FILE) + expect(hostFileOf('dsh-resource://file/session/s%2F1/work/a%20b%23c.md', SEAT)).toEqual({ sessionId: 's/1', path: 'work/a b#c.md' }) + }) + + it('reads an absolute address through the seat\'s session with the decoded absolute path', () => { + expect(hostFileOf('dsh-resource://file/absolute/etc/hosts', SEAT)).toEqual({ sessionId: SEAT, path: '/etc/hosts' }) + expect(hostFileOf('dsh-resource://file/absolute/C:/w/a%20b.md', SEAT)).toEqual({ sessionId: SEAT, path: 'C:/w/a b.md' }) + }) + + it('throws for an address that is not a file address', () => { + for (const address of ['dsh-resource://file/shared/team/notes.md', 'dsh-resource://file/session', 'file:///work/notes.md', 'sidebar://guide']) { + expect(() => hostFileOf(address, SEAT)).toThrow('not a file address') + } + }) +}) + +describe('createReadPage', () => { + it('binds the paged read to the Remote with the offset as the only range', async () => { + const read = vi.fn(() => Promise.resolve(page(4, ['d'], true))) + const signal = new AbortController().signal + await expect(createReadPage({ workspaceFiles: { read } })(SESSION, PATH, 4, signal)).resolves.toEqual(page(4, ['d'], true)) + expect(read).toHaveBeenCalledWith(SESSION, PATH, { offset: 4 }, signal) + }) +}) diff --git a/packages/client/ui-sidebar-textpreview/tests/store.client.spec.ts b/packages/client/ui-sidebar-textpreview/tests/store.client.spec.ts new file mode 100644 index 0000000000..a5d7b2b054 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/tests/store.client.spec.ts @@ -0,0 +1,84 @@ +/** + * The store's write set: pages keyed by their first line, invalidated by a newer + * file version; a view that survives a reset; one bucket per tab, dropped on + * `forget` so a closed tab leaves nothing behind. + */ +import { describe, expect, it } from 'vitest' +import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' +import { createTextStore, fresh } from '../src/client/store.ts' +import { page } from './fixtures.client.ts' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' + +const TAB_1 = 'tab-1' as TabId +const TAB_2 = 'tab-2' as TabId +const TAB_9 = 'tab-9' as TabId + +function pageValue(offset: number, lines: readonly string[], eof: boolean, version = 'v1') { + const result = page(offset, lines, eof, version) + if (!result.ok) throw new Error('fixture') + return result.value +} + +describe('text store', () => { + it('starts empty and mints a bucket on the first write', () => { + const instance = createTextStore().create() + expect(instance.getSnapshot().byTab).toEqual({}) + instance.actions.loading(TAB_1) + expect(instance.getSnapshot().byTab[TAB_1]).toEqual({ ...fresh(), loading: true }) + }) + + it('keeps pages by their first line and reports the end of the file', () => { + const instance = createTextStore().create() + instance.actions.loading(TAB_1) + instance.actions.page(TAB_1, pageValue(1, ['a', 'b'], false)) + instance.actions.page(TAB_1, pageValue(3, ['c'], true)) + const state = instance.getSnapshot().byTab[TAB_1] + expect(state?.pages).toEqual({ 1: { text: 'a\nb', lines: 2 }, 3: { text: 'c', lines: 1 } }) + expect(state?.version).toBe('v1') + expect(state?.eof).toBe(true) + expect(state?.loading).toBe(false) + }) + + it('drops the pages of an older version when a newer page arrives', () => { + const instance = createTextStore().create() + instance.actions.page(TAB_1, pageValue(1, ['a'], false)) + instance.actions.page(TAB_1, pageValue(2, ['B'], true, 'v2')) + expect(instance.getSnapshot().byTab[TAB_1]?.pages).toEqual({ 2: { text: 'B', lines: 1 } }) + expect(instance.getSnapshot().byTab[TAB_1]?.version).toBe('v2') + }) + + it('records a failure beside the pages already held, and the next page clears it', () => { + const instance = createTextStore().create() + instance.actions.page(TAB_1, pageValue(1, ['a'], false)) + const failure = { code: 'workspace-file/too-large', message: 'x', details: {} } as unknown as RemoteFailure + instance.actions.failed(TAB_1, failure) + expect(instance.getSnapshot().byTab[TAB_1]?.failure).toBe(failure) + expect(instance.getSnapshot().byTab[TAB_1]?.pages).toEqual({ 1: { text: 'a', lines: 1 } }) + instance.actions.page(TAB_1, pageValue(2, ['b'], true)) + expect(instance.getSnapshot().byTab[TAB_1]?.failure).toBeUndefined() + }) + + it('resets the pages but keeps the view', () => { + const instance = createTextStore().create() + instance.actions.page(TAB_1, pageValue(1, ['a'], true)) + instance.actions.scrolled(TAB_1, 120) + instance.actions.toggledWrap(TAB_1) + instance.actions.navigated(TAB_1, 3) + instance.actions.reset(TAB_1) + expect(instance.getSnapshot().byTab[TAB_1]).toEqual({ + ...fresh(), scrollTop: 120, wrap: false, revision: 3, + }) + }) + + it('forgets one tab and keeps the rest', () => { + const instance = createTextStore().create() + instance.actions.toggledWrap(TAB_1) + instance.actions.toggledWrap(TAB_2) + instance.actions.forget(TAB_1) + expect(Object.keys(instance.getSnapshot().byTab)).toEqual([TAB_2]) + // Forgetting an unknown tab is a no-op, not a fault: the abort listener may + // fire for a tab that never wrote anything. + instance.actions.forget(TAB_9) + expect(Object.keys(instance.getSnapshot().byTab)).toEqual([TAB_2]) + }) +}) diff --git a/packages/client/ui-sidebar-textpreview/tests/text-preview.client.spec.tsx b/packages/client/ui-sidebar-textpreview/tests/text-preview.client.spec.tsx new file mode 100644 index 0000000000..464fe67303 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/tests/text-preview.client.spec.tsx @@ -0,0 +1,327 @@ +// @vitest-environment jsdom +/** + * What the body draws from its pages and the file's metadata, and what it does + * with a navigation: load until the asked line is held, jump to it once, then + * keep the reader's place. + * + * jsdom lays nothing out, so two geometry facts are supplied here: a line's + * offset is its number times one line height, and `scrollTop` holds what it is + * set to. Both are the browser's job; the specs assert the body's arithmetic + * over them. + */ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { TextPreview } from '../src/client/TextPreview.tsx' +import { ABSOLUTE_PATH, ADDRESS, PATH, SESSION, TAB_ID, failure, harness, page, settle } from './fixtures.client.ts' + +const LINE_HEIGHT = 20 + +const originals = { + offsetTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetTop'), + scrollTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTop'), +} + +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'offsetTop', { + configurable: true, + get(this: HTMLElement) { + const line = this.getAttribute('data-textpreview-line') + return line === null ? 0 : (Number(line) - 1) * LINE_HEIGHT + }, + }) + Object.defineProperty(HTMLElement.prototype, 'scrollTop', { + configurable: true, + get(this: HTMLElement & { __scrollTop?: number }) { return this.__scrollTop ?? 0 }, + set(this: HTMLElement & { __scrollTop?: number }, value: number) { this.__scrollTop = value }, + }) +}) + +afterAll(() => { + for (const [name, descriptor] of Object.entries(originals)) { + if (descriptor === undefined) Reflect.deleteProperty(HTMLElement.prototype, name) + else Object.defineProperty(HTMLElement.prototype, name, descriptor) + } +}) + +afterEach(cleanup) + +function body(container: HTMLElement): HTMLElement { + const element = container.querySelector('[data-textpreview-body]') + if (element === null) throw new Error('expected the file body') + return element +} + +function lines(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('[data-textpreview-line]'), row => row.textContent ?? '') +} + +function target(container: HTMLElement): string | null { + return container.querySelector('[data-textpreview-target]')?.getAttribute('data-textpreview-target') ?? null +} + +function click(container: HTMLElement, selector: string): void { + const button = container.querySelector(selector) + if (button === null) throw new Error(`expected ${selector}`) + fireEvent.click(button) +} + +describe('TextPreview — pages', () => { + it.each([ABSOLUTE_PATH, 'C:\\work\\project\\notes.md', '\\\\host\\share\\notes.md'])( + 'shows the Host path %s in the header and tooltip even when text cannot be read', + async (absolutePath) => { + const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) }) + h.useResource.mockReturnValue({ + status: 'live', value: { absolutePath, version: 'v1', changed: false }, failure: undefined, reload: h.reload, + }) + const view = render() + await settle() + const path = view.container.querySelector('[data-textpreview-path]') + expect(path?.textContent).toBe(absolutePath) + expect(path?.getAttribute('title')).toBe(absolutePath) + expect(h.read).toHaveBeenCalledWith(SESSION, PATH, 1, h.controller.signal) + }, + ) + + it('shows the requested path until Host metadata supplies its absolute path', async () => { + const h = harness({ 1: page(1, ['one'], true) }) + const metadata = h.useResource() + h.useResource.mockReturnValue({ status: 'loading', value: undefined, failure: undefined, reload: h.reload }) + const view = render() + await settle() + expect(view.container.querySelector('[data-textpreview-path]')?.textContent).toBe(PATH) + h.useResource.mockReturnValue(metadata) + view.rerender() + expect(view.container.querySelector('[data-textpreview-path]')?.textContent).toBe(ABSOLUTE_PATH) + expect(view.container.querySelector('[data-textpreview-path]')?.getAttribute('title')).toBe(ABSOLUTE_PATH) + }) + + it('reads the first page on first mount and draws its lines, offering the next', async () => { + const h = harness({ 1: page(1, ['one', 'two', 'three'], false) }) + const view = render() + // The read is in flight from the mount effect on: the body is up with no + // lines yet, and its next-page control reports the progress. + const pending = view.container.querySelector('[data-textpreview-more]') + expect(pending?.disabled).toBe(true) + expect(pending?.textContent).toBe('loading') + expect(lines(view.container)).toEqual([]) + await settle() + expect(h.read).toHaveBeenCalledTimes(1) + expect(h.read).toHaveBeenCalledWith(SESSION, PATH, 1, h.controller.signal) + expect(lines(view.container)).toEqual(['one\n', 'two\n', 'three\n']) + expect(view.container.querySelector('[data-textpreview-url]')?.getAttribute('data-textpreview-url')).toBe(ADDRESS) + expect(view.container.textContent).toContain(PATH) + expect(view.container.querySelector('[data-textpreview-more]')).not.toBeNull() + expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull() + }) + + it('reads nothing on a remount while the store holds the pages', async () => { + const h = harness({ 1: page(1, ['one'], true) }) + const first = render() + await settle() + first.unmount() + const second = render() + await settle() + expect(h.read).toHaveBeenCalledTimes(1) + expect(lines(second.container)).toEqual(['one\n']) + }) + + it('loads the next page where the loaded text ends, until the file ends', async () => { + const h = harness({ 1: page(1, ['a', 'b', 'c'], false), 4: page(4, ['d', 'e'], true) }) + const view = render() + await settle() + click(view.container, '[data-textpreview-more]') + await settle() + expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 4, h.controller.signal) + expect(lines(view.container)).toEqual(['a\n', 'b\n', 'c\n', 'd\n', 'e\n']) + expect(view.container.querySelectorAll('[data-textpreview-page]').length).toBe(2) + expect(view.container.querySelector('[data-textpreview-more]')).toBeNull() + }) + + it('says why a page failed and retries the same page', async () => { + const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) }) + const view = render() + await settle() + expect(view.container.querySelector('[data-textpreview-failed]')?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-text') + expect(view.container.textContent).toContain('error.notText') + expect(view.container.querySelector('[data-textpreview-more]')).toBeNull() + h.script(1, page(1, ['one'], true)) + click(view.container, '[data-textpreview-retry]') + await settle() + expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 1, h.controller.signal) + expect(lines(view.container)).toEqual(['one\n']) + expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull() + }) + + it('announces a change and, on request, re-reads the pages keeping the reader\'s place', async () => { + const h = harness({ 1: page(1, ['a', 'b'], true) }) + const view = render() + await settle() + fireEvent.scroll(body(view.container), { target: { scrollTop: 50 } }) + h.setChanged(true) + view.rerender() + expect(view.container.querySelector('[data-textpreview-changed]')?.textContent).toContain('changed') + expect(lines(view.container)).toEqual(['a\n', 'b\n']) + h.script(1, page(1, ['A', 'B', 'C'], true, 'v2')) + click(view.container, '[data-textpreview-reload-now]') + expect(h.reload).toHaveBeenCalledTimes(1) + await settle() + expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 1, h.controller.signal) + expect(lines(view.container)).toEqual(['A\n', 'B\n', 'C\n']) + expect(body(view.container).scrollTop).toBe(50) + }) +}) + +describe('TextPreview — the file\'s metadata', () => { + it('announces a failed metadata frame over the pages already loaded, ahead of a pending change, until a reload stats it live again', async () => { + const h = harness({ 1: page(1, ['a', 'b'], true) }) + const view = render() + await settle() + h.setChanged(true) + h.setFailure(new RemoteError('workspace-file/not-found', 'gone', { path: PATH })) + view.rerender() + const bar = view.container.querySelector('[data-textpreview-meta-failed]') + expect(bar?.getAttribute('data-textpreview-meta-failed')).toBe('workspace-file/not-found') + expect(bar?.textContent).toContain('error.notFound') + expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull() + expect(lines(view.container)).toEqual(['a\n', 'b\n']) + // The bar's reload is the same gesture: stat again through the resource and re-read the pages. + h.script(1, page(1, ['A'], true, 'v2')) + click(view.container, '[data-textpreview-reload-now]') + expect(h.reload).toHaveBeenCalledTimes(1) + await settle() + expect(lines(view.container)).toEqual(['A\n']) + // The stat succeeds: the next frame is live and the bar is gone. + h.setChanged(false) + h.setFailure(undefined) + view.rerender() + expect(view.container.querySelector('[data-textpreview-meta-failed]')).toBeNull() + expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull() + }) + + it('draws a page holding one empty line as one line, and nothing for a page past the end', async () => { + const h = harness({ 1: page(1, [''], false), 2: page(2, [], true) }) + const view = render() + await settle() + expect(lines(view.container)).toEqual(['\n']) + click(view.container, '[data-textpreview-more]') + await settle() + expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 2, h.controller.signal) + expect(lines(view.container)).toEqual(['\n']) + expect(view.container.querySelector('[data-textpreview-more]')).toBeNull() + }) +}) + +describe('TextPreview — navigation and view', () => { + it('loads until the navigated line is held, then jumps to it once and marks it', async () => { + const h = harness({ 1: page(1, ['a', 'b', 'c'], false), 4: page(4, ['d', 'e', 'f'], true) }) + const view = render() + await settle() + // The first page does not reach line 5, so the body asks for the next on its own. + await settle() + expect(h.read).toHaveBeenCalledTimes(2) + expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 4, h.controller.signal) + expect(body(view.container).scrollTop).toBe(4 * LINE_HEIGHT) + expect(target(view.container)).toBe('5') + expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1) + expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(4 * LINE_HEIGHT) + }) + + it('comes back where the reader was on a remount, instead of jumping again', async () => { + const h = harness({ 1: page(1, ['a', 'b', 'c'], true) }) + const first = render() + await settle() + expect(body(first.container).scrollTop).toBe(2 * LINE_HEIGHT) + fireEvent.scroll(body(first.container), { target: { scrollTop: 300 } }) + first.unmount() + const second = render() + await settle() + expect(body(second.container).scrollTop).toBe(300) + }) + + it('jumps again for a new navigation to the same tab', async () => { + const h = harness({ 1: page(1, ['a', 'b', 'c'], true) }) + const view = render() + await settle() + fireEvent.scroll(body(view.container), { target: { scrollTop: 300 } }) + view.rerender() + expect(body(view.container).scrollTop).toBe(1 * LINE_HEIGHT) + expect(target(view.container)).toBe('2') + expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(2) + }) + + it('stops at the end of the file for a line past it, and answers a navigation without a line', async () => { + const h = harness({ 1: page(1, ['a', 'b'], true) }) + const view = render() + await settle() + expect(h.read).toHaveBeenCalledTimes(1) + expect(target(view.container)).toBeNull() + expect(body(view.container).scrollTop).toBe(0) + expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1) + view.rerender() + expect(target(view.container)).toBeNull() + expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(2) + }) + + it('wraps by default and stops when the shared store says so', async () => { + const h = harness({ 1: page(1, ['a'], true) }) + const view = render() + await settle() + expect(body(view.container).hasAttribute('data-textpreview-wrap')).toBe(true) + act(() => { h.instance.actions.toggledWrap(TAB_ID) }) + expect(body(view.container).hasAttribute('data-textpreview-wrap')).toBe(false) + }) +}) + +describe('TextPreview — header controls', () => { + it('toggles wrap off from the header, reporting the pressed state', async () => { + const h = harness({ 1: page(1, ['a'], true) }) + const view = render() + await settle() + const wrap = view.container.querySelector('[data-textpreview-tool="wrap"]') + if (wrap === null) throw new Error('expected the wrap control') + expect(wrap.getAttribute('aria-pressed')).toBe('true') + fireEvent.click(wrap) + expect(h.instance.getSnapshot().byTab[TAB_ID]?.wrap).toBe(false) + expect(wrap.getAttribute('aria-pressed')).toBe('false') + expect(body(view.container).hasAttribute('data-textpreview-wrap')).toBe(false) + }) + + it('reloads from the header through the resource and the face, without a change announced', async () => { + const h = harness({ 1: page(1, ['one'], true) }) + const view = render() + await settle() + expect(h.useResource).toHaveBeenCalledWith(ADDRESS) + expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull() + h.script(1, page(1, ['uno'], true, 'v2')) + click(view.container, '[data-textpreview-tool="reload"]') + expect(h.reload).toHaveBeenCalledTimes(1) + await settle() + expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 1, h.controller.signal) + expect(lines(view.container)).toEqual(['uno\n']) + }) + + it('forgets at once when mounted for a record that has already ended', async () => { + const h = harness({ 1: page(1, ['a'], true) }) + h.controller.abort() + render() + await settle() + expect(h.instance.getSnapshot().byTab[TAB_ID]).toBeUndefined() + }) + + it('forgets its state when the record ends, even with the body unmounted, through one listener however often it mounted', async () => { + const h = harness({ 1: page(1, ['a'], true) }) + const armed = vi.spyOn(h.controller.signal, 'addEventListener') + const first = render() + await settle() + expect(h.instance.getSnapshot().byTab[TAB_ID]).toBeDefined() + // Switched away and back: the store outlives the body, so nothing re-arms. + first.unmount() + const second = render() + await settle() + second.unmount() + expect(armed.mock.calls.filter(([type]) => type === 'abort')).toHaveLength(1) + h.controller.abort() + expect(h.instance.getSnapshot().byTab[TAB_ID]).toBeUndefined() + }) +}) diff --git a/packages/client/ui-sidebar-textpreview/tsconfig.json b/packages/client/ui-sidebar-textpreview/tsconfig.json new file mode 100644 index 0000000000..c576ab9943 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/tsconfig.json @@ -0,0 +1,54 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../core/session" + }, + { + "path": "../locale" + }, + { + "path": "../resources" + }, + { + "path": "../store" + }, + { + "path": "../ui-dockkit" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, + { + "path": "../ui-sidebar-right" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../api/workspace-files/tsconfig.client.json" + }, + { + "path": "../../util/workspace-path" + } + ] +} diff --git a/packages/client/ui-sidebar-textpreview/tsdown.config.ts b/packages/client/ui-sidebar-textpreview/tsdown.config.ts new file mode 100644 index 0000000000..01c1b9a440 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-textpreview', ['lib/types/index.js']) diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 2e07515091..0b08ade73b 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar", "description": "Sidebar plugin: session multi-level tree, search, grouping, state dots", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx index 9adc7b51df..ed5b687e73 100644 --- a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx @@ -5,12 +5,16 @@ * asserted in scrollbar-quiet-styles.spec.ts (node environment — a jsdom spec * has no file: module URL to read the sheet through). */ +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src/client/contract/slots.ts' import { SidebarRoot } from '../src/client/SidebarRoot.tsx' import { en } from '../src/client/locales.ts' +// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + /** Pinned column box; the shell compares pointer coordinates against it. */ const COLUMN_WIDTH = 280 const COLUMN_HEIGHT = 600 @@ -35,7 +39,8 @@ function mountColumn(): { column: HTMLElement; quiet: () => boolean } { const view = render(
    ) as SidebarRootComponentProps['renderSlot']} diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index 0f5edce0de..9973a1958b 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { ReactNode } from 'react' @@ -10,6 +11,9 @@ import { SidebarRoot } from '../src/client/SidebarRoot.tsx' import { en } from '../src/client/locales.ts' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' +// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + // English-dictionary translate stub: the shell renders the same copy the // assertions below query by accessible name. const t: SidebarRootComponentProps['t'] = key => @@ -40,7 +44,8 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w const root = () => ( { vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3-rc.4') const { container } = render( options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} @@ -122,7 +128,8 @@ describe('SidebarRoot shell', () => { for (const [name, value] of Object.entries(environment)) vi.stubEnv(name, value) render( options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} @@ -135,7 +142,8 @@ describe('SidebarRoot shell', () => { it('retains the local-build fallback without complete build metadata', () => { render( options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 28bfd18750..90bff42e84 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", "description": "Web skill references and the dedicated skill tool row", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 8b30874f9c..269570d599 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-slots", "description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 56a8234ce1..834b2a2b85 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -35,6 +35,14 @@ export interface SlotMap {} */ export interface LocaleNamespaceMap {} +/** + * Resource protocol (URL scheme) → the value its provider streams. Declared + * empty here, the zero-dependency merge point; each protocol owner merges its + * own member (`file`, later `chat`), and `useResource

    (address)` narrows its + * value by `P`. The resource service itself lives in `dsh-client-resources`. + */ +export interface ResourceProtocolMap {} + /** * Translate a dictionary key with optional `{name}` template params. * `K` narrows the accepted keys to the owning namespace's dictionary union diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 51c0a4ea2b..bc5c74483e 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-subagent", "description": "Subagent conversation catalog, continuation routing UI, and '@' reference source", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index c97f73260a..6820f62afc 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", "description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx index c42d6aa3b6..4e6b61616e 100644 --- a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' @@ -10,6 +11,9 @@ import type { AppearanceRowComponentProps } from '../src/client/AppearanceRow.ts import { createAppearanceRowStore } from '../src/client/settings-store.ts' import type { ThemePreference } from '../src/client/index.ts' +// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + afterEach(cleanup) const COPY: Record = { @@ -43,6 +47,7 @@ function mount(preference: ThemePreference = 'system') { const props: AppearanceRowComponentProps = { useSessions: emptySessions(), useSessionPendingInteraction, + useResource, useWorkspaces: emptyWorkspaces(), useStore: bindSnapshotSelector(store), actions: store.actions, diff --git a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx index 314576d572..3c4601e1f8 100644 --- a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom /** FontSizeRow behavior: value display, arrow clicks drive setFontSize, * bound-value arrows disable, display follows the store mirror. */ +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' @@ -11,6 +12,9 @@ import { FontSizeRow } from '../src/client/FontSizeRow.tsx' import type { FontSizeRowComponentProps } from '../src/client/FontSizeRow.tsx' import { createFontSizeRowStore } from '../src/client/settings-store.ts' +// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] + afterEach(cleanup) const COPY: Record = { @@ -45,6 +49,7 @@ function mount(fontSize = 14) { const props: FontSizeRowComponentProps = { useSessions: emptySessions(), useSessionPendingInteraction, + useResource, useWorkspaces: emptyWorkspaces(), useStore: bindSnapshotSelector(store), actions: store.actions, diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 09a763f5b8..4bbf0f2d15 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -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-tool/README.md -README.md: e0b49a3b582f5f1e6e9aa19e0b026dedb9ba2a18 -README.zh.md: 032f048f2501923c4c9f010b76afe27d98fe7889 +README.md: b8dd2a5e3c6743e496d585a2de238714f00e9f6b +README.zh.md: 49e82c9fa70953d73171d466f36995ff2870c6b2 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index e0b49a3b58..b8dd2a5e3c 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -57,12 +57,12 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool ### Rendering contract -`ToolCallTree` receives one root `ToolCallBlock` that already contains recursive `subCalls`, selection state, the session `cwd`, and Host callbacks for opening files and inspecting calls. It recursively walks the standard call blocks and sends the root and children at every depth through the same atomic dispatch path, without subscribing to a separate parent-to-children map. Each root and child wrapper preserves the `data-chat-anchor-key="call:"` and `data-chat-call-id` DOM contract used for paging and selection. +`ToolCallTree` receives one root `ToolCallBlock` that already contains recursive `subCalls`, the session `cwd`, and the owner's callbacks for opening files and inspecting calls. It recursively walks the standard call blocks and sends the root and children at every depth through the same atomic dispatch path, without subscribing to a separate parent-to-children map. Each root and child wrapper preserves the `data-chat-anchor-key="call:"` and `data-chat-call-id` DOM contract used for paging and selection. -### Details and cards +### Cards -The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card; the image card is row-only because its gallery renders through the tool-owned `tool.call.images` slot the details panel does not declare. These models validate raw call arguments, result content, failure state, persisted metadata, and Session path facts. Diff, read, search, and web models also reject Code Dispatch children; terminal eligibility does not depend on `parentCallId`. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules. +Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules. The terminal model uses `hasSpillNotice` from the browser-safe `@deepseek-ai/dsh-spill-policy/notice` entry, not an independent UI pattern. The [spill-policy README](../../spill/spill-policy/README.md#shared-notice-ownership) owns notice formatting and recognition. This check conservatively selects generic output; matching text cannot authenticate its source, and replay leaves recorded result bytes untouched. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 032f048f25..49e82c9fa7 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -57,12 +57,12 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 渲染约定 -`ToolCallTree` 接收一个已经包含递归 `subCalls` 的 root `ToolCallBlock`、selection 状态、会话 `cwd`,以及用于打开文件和检查调用的 Host 回调。它递归遍历标准调用块,让 root 与任意深度的 child 经过同一条原子分发路径,不订阅独立的 parent-to-children map。每个 root 和 child 包装层都保留 `data-chat-anchor-key="call:"` 与 `data-chat-call-id` DOM 约定,供分页和 selection 使用。 +`ToolCallTree` 接收一个已经包含递归 `subCalls` 的 root `ToolCallBlock`、会话 `cwd`,以及属主用于打开文件和检查调用的回调。它递归遍历标准调用块,让 root 与任意深度的 child 经过同一条原子分发路径,不订阅独立的 parent-to-children map。每个 root 和 child 包装层都保留 `data-chat-anchor-key="call:"` 与 `data-chat-call-id` DOM 约定,供分页和 selection 使用。 -### 详情与卡片 +### 卡片 -本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model;image 卡片仅属于行,因为其图库经由工具自有 `tool.call.images` 槽位渲染,而 details 面板不声明该槽位。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata 与 Session 路径事实。diff、read、search 和 web model 还会拒绝 Code Dispatch 子调用;terminal 适用性不依赖 `parentCallId`。不受支持或格式错误的输入使用压平的工具结果文本。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。 +每张卡片都在调用树里就地阅读;不存在选中调用的第二个全高展示面。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` 槽位渲染。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch 的 `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。 terminal model 使用浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice` 的 `hasSpillNotice`,而非独立的 UI 匹配规则。[spill-policy README](../../spill/spill-policy/README.zh.md#shared-notice-ownership) 负责通知的格式化与识别。该检查保守地选择通用输出;匹配文本不能认证其来源,回放也不改变已记录的结果字节。 diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 0a22df9204..b16b29235d 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-tool", "description": "Client Tool call-tree renderer and keyed per-tool presentation slot", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index 7c153dc515..28bc6e51fe 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -7,7 +7,6 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { ToolCallTree } from './tool/ToolCallTree.tsx' -import { ToolDetails } from './tool/ToolDetails.tsx' import { CONVERSATION_NS as NS } from './locale.ts' import { askQuestionToolview } from './tool/toolviews/ask-question-row.tsx' import { bashToolviewSample } from './tool/toolviews/bash-sample.tsx' @@ -41,12 +40,6 @@ export function apply(ctx: ClientContext): void { inject: toolInject, }, ToolCallTree)) - ctx.slots.inject('conversation.details.tool', () => ctx.slots.register({ - name: 'conversation.details.tool', - locale: NS, - inject: toolInject, - }, ToolDetails)) - ctx.plugin(bashToolviewSample) ctx.plugin(readToolview) ctx.plugin(readImageToolview) diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index 7e6aaa675c..512c6880bb 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -3,7 +3,7 @@ import type { HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, } from '@deepseek-ai/dsh-client-ui-slots' import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' -import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { OpenFileOptions, ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' import type { MessageImageLoader, MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -63,8 +63,11 @@ export interface ToolCallOwnerProps { cwd?: string | undefined /** Host account home; POSIX home-rooted summaries display as `~`. */ home?: string | undefined - /** Open a Tool argument path through the Host. */ - openFile: (path: string) => void + /** + * Open a Tool argument path. A view that knows which line the call was about + * passes it, and the opened surface lands there. + */ + openFile: (path: string, options?: OpenFileOptions) => void /** * Session-authorized image loader for the `tool.call.images` slot, supplied * by the chat node that owns this call. A composed chat node always @@ -98,8 +101,3 @@ export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'> & PropsRenderSlots<'tool.call.toolview'> & PropsLocale<'conversation'> & InjectFace - -/** Full props of the selected Tool output renderer in the details panel. */ -export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'> - & PropsLocale<'conversation'> - & InjectFace diff --git a/packages/client/ui-tool/src/client/index.ts b/packages/client/ui-tool/src/client/index.ts index fc65940d0f..c2b2f61fa3 100644 --- a/packages/client/ui-tool/src/client/index.ts +++ b/packages/client/ui-tool/src/client/index.ts @@ -1,5 +1,5 @@ /** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */ export { apply, inject } from './apply.ts' export type { - ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolHostInfoInjected, ToolTreeProps, + ToolCallOwnerProps, ToolCallViewProps, ToolHostInfoInjected, ToolTreeProps, } from './contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index d8288fa71a..2212278bd5 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -12,12 +12,11 @@ function callName(node: ToolCallBlock): string { /** One atomic call dispatched through the Tool-owned keyed slot. */ const ToolCall = memo(function ToolCall({ - renderSlot, callId, toolName, block, openFile, selected, cwd, home, inspectCall, loadImage, t, children, + renderSlot, callId, toolName, block, openFile, cwd, home, inspectCall, loadImage, t, children, }: Pick & { callId: string toolName: string block: ToolCallBlock - selected: boolean home?: string | undefined children?: ReactNode }) { @@ -36,7 +35,6 @@ const ToolCall = memo(function ToolCall({ className={css.callRow} data-chat-anchor-key={`call:${callId}`} data-chat-call-id={callId} - data-selected={selected || undefined} > {renderSlot('tool.call.toolview', owner, { entryKey: toolName, @@ -48,8 +46,8 @@ const ToolCall = memo(function ToolCall({ }) const ToolCallBranch = memo(function ToolCallBranch({ - renderSlot, block, selectedCallId, cwd, home, openFile, inspectCall, loadImage, t, -}: Pick & { + renderSlot, block, cwd, home, openFile, inspectCall, loadImage, t, +}: Pick & { block: ToolCallBlock home?: string | undefined }) { @@ -60,7 +58,6 @@ const ToolCallBranch = memo(function ToolCallBranch({ toolName={callName(block)} block={block} openFile={openFile} - selected={block.callId === selectedCallId} cwd={cwd} home={home} inspectCall={inspectCall} @@ -74,7 +71,6 @@ const ToolCallBranch = memo(function ToolCallBranch({ key={child.callId} renderSlot={renderSlot} block={child} - selectedCallId={selectedCallId} cwd={cwd} home={home} openFile={openFile} @@ -96,7 +92,7 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, selectedCallId, cwd, openFile, inspectCall, loadImage, useHostInfo, t, + renderSlot, node, cwd, openFile, inspectCall, loadImage, useHostInfo, t, }: ToolTreeProps) { const home = useHostInfo(info => info.home) const block = node.data.root @@ -104,7 +100,6 @@ export function ToolCallTree({ ) { - const home = useHostInfo(info => info.home) - const terminalModel = terminalCardModel(block, cwd) - if (terminalModel !== null) { - const terminal = localizeTerminalCardModel(terminalModel, t) - return ( - <> - {terminal.description !== undefined ? ( -

    {terminal.description}
    - ) : null} - - - ) - } - const read = readCardModel(block, cwd, home) - if (read !== null) return - const diff = diffCardModel(block) - if (diff !== null) return - const search = searchCardModel(block) - if (search !== null) { - return ( - <> - - {search.recovery !== undefined ?
    {search.recovery}
    : null} - - ) - } - const web = webCardModel(block) - if (web !== null) { - const body = 'kind' in block ? resultText(block) : '' - return ( - <> - - {body !== '' ?
    {body}
    : null} - - ) - } - if (!('kind' in block)) return
    {t('details.running')}
    - return ( -
    -      {resultText(block)}
    -    
    - ) -} diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 44bbafb6e9..ca5becec3b 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -5,6 +5,7 @@ import { diffTotals, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsRenderSlots, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { OpenFileOptions } from '@deepseek-ai/dsh-client-ui-chat/client' import type { MessageImageLoader } from '@deepseek-ai/dsh-client-ui-conversation/client' import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../models/diff-card-model.ts' import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../models/read-card-model.ts' @@ -75,8 +76,10 @@ export interface ToolRowProps { * renders as a hover-underline link that opens the host default app. */ filePath?: string | undefined - /** Open the path with the host OS default application (already cwd-resolved). */ - onOpenFile?: ((path: string) => void) | undefined + /** 1-based line the call was about; absent = open the file at its beginning. */ + filePathLine?: number | undefined + /** Open the path (already cwd-resolved), landing on `filePathLine` when given. */ + onOpenFile?: ((path: string, options?: OpenFileOptions) => void) | undefined /** * Jump to this call in the trajectory view: a hover-revealed Inspect pill * over the expanded body. Absent = no affordance. @@ -127,6 +130,7 @@ export function ToolRow({ web, state, filePath, + filePathLine, onOpenFile, inspect, }: ToolRowProps) { @@ -174,7 +178,9 @@ export function ToolRow({ } const openFile = (event: MouseEvent) => { event.stopPropagation() - if (filePath !== undefined) onOpenFile?.(filePath) + if (filePath === undefined || onOpenFile === undefined) return + if (filePathLine === undefined) onOpenFile(filePath) + else onOpenFile(filePath, { line: filePathLine }) } // Keep Enter/Space on the focused path link from bubbling to the row's // keydown handler, which would preventDefault() the key and toggle expand diff --git a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts index a5a8fffdfe..7afcd40263 100644 --- a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts @@ -31,13 +31,18 @@ interface ReadMeta { lang?: string } +/** Whether a model-supplied argument is a 1-based line position or count: an integer of at least 1. */ +function positiveInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 1 +} + function validReadCall(block: ToolCallBlock): boolean { const call = parsedToolCall(block) if (call?.name !== 'read') return false const { file_path: path, offset, limit } = call.args if (typeof path !== 'string' || path.trim() === '') return false - if (offset !== undefined && (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 1)) return false - if (limit !== undefined && (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1)) return false + if (offset !== undefined && !positiveInteger(offset)) return false + if (limit !== undefined && !positiveInteger(limit)) return false return true } @@ -66,6 +71,23 @@ function readMeta(meta: unknown): ReadMeta | null { } } +/** + * The line one `read` call was about, from its arguments. + * + * `offset` is the read tool's own 1-based start line, so opening the path can + * land where the model looked. Available while the call is still running, + * unlike the persisted metadata, because the arguments carry it. The arguments + * are model-produced JSON: only an integer of at least 1 is a line, and a call + * whose `offset` is anything else names none. + * @param block - running or settled Tool block. + * @returns the 1-based line, or undefined when the call named none. + */ +export function readCallLine(block: ToolCallBlock): number | undefined { + if (!validReadCall(block)) return undefined + const { offset } = parsedToolCall(block)?.args ?? {} + return positiveInteger(offset) ? offset : undefined +} + /** * Derive a settled root read card after validating its persisted metadata and * model-facing read envelope. diff --git a/packages/client/ui-tool/src/client/tool/toolviews/read-family-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/read-family-row.tsx index 006ba643f1..40553313d2 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/read-family-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/read-family-row.tsx @@ -21,10 +21,11 @@ export type ReadImageRowProps = ReadFamilyRowProps & PropsRenderSlots<'tool.call /** * The card material one read-family row contributes: exactly the ToolRow card - * props that row owns. `read` supplies `read`; `read_image` supplies `image` - * together with the slot dispatcher and loader that draw it. + * props that row owns. `read` supplies `read` and the line its call named; + * `read_image` supplies `image` together with the slot dispatcher and loader + * that draw it. */ -export type ReadFamilyCard = Pick +export type ReadFamilyCard = Pick /** * Compose a read-family row: the shared chrome and model-derived fields, plus the diff --git a/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx index 06c0d12ba3..a6a8a64b28 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx @@ -1,18 +1,22 @@ import type { Context } from '@deepseek-ai/cordis' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' -import { readCardModel } from '../models/read-card-model.ts' +import { readCallLine, readCardModel } from '../models/read-card-model.ts' import { readFamilyRow } from './read-family-row.tsx' import { CONVERSATION_NS as NS } from '../../locale.ts' type ReadRowProps = ToolCallViewProps & PropsLocale<'conversation'> /** - * Lets users expand a completed read result and open its reported path. + * Lets users expand a completed read result and open its reported path at the + * line the call started from. */ export function ReadRow(props: ReadRowProps) { const { block, cwd, home } = props - return readFamilyRow(props, { read: readCardModel(block, cwd, home) }) + return readFamilyRow(props, { + read: readCardModel(block, cwd, home), + filePathLine: readCallLine(block), + }) } /** Registers the read tool's conversation row. */ diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index be428ebf19..636124555e 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -1,3 +1,4 @@ +import { toolSessionEvents } from './tool-fixtures.client.ts' // @vitest-environment jsdom /** Tool assembly acceptance through the real ui-conversation host. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -13,7 +14,6 @@ import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { SlotTestRuntime, TestRemote, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' -import { toolSessionEvents } from './tool-details-render.client.tsx' // The service reads its initial locale from the browser; these specs assert // the shipped Chinese copy, so they state the browser they assume. @@ -59,14 +59,13 @@ const bashResult = (seq: number, callId: string, over?: Partial) }) /** Test-owned AppFrame role: declares and renders the resident conversation area. */ -type AppRootProps = PropsRenderSlots<'conversation' | 'details'> +type AppRootProps = PropsRenderSlots<'conversation'> function AppRoot({ renderSlot }: AppRootProps) { return <>{renderSlot('conversation', {})} } const LAYOUT_CHILDREN = { 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, } as const async function bench(nodes: ToolResultNode[]) { @@ -78,6 +77,7 @@ async function bench(nodes: ToolResultNode[]) { }) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never) runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID), } as never) diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index 36b9886b89..94db65688d 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -16,7 +16,7 @@ import { import { en as conversationEn, NS as CONVERSATION_NS, zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { apply as applyChat, inject as injectChat } from '@deepseek-ai/dsh-client-ui-chat/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' -import { toolChatSnapshot } from './tool-details-render.client.tsx' +import { toolChatSnapshot } from './tool-fixtures.client.ts' const SID = 's1' as SessionId @@ -112,12 +112,14 @@ async function bench(snapshot: ChatSnapshot) { await runtime.sessions.add({ id: SID, - summary: { title: 'S', displayTitle: 'S' }, + summary: { title: 'S', displayTitle: 'S', cwd: '/w' }, snapshot: { running: snapshot.legacy.runningCalls.length > 0 }, }) const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } const openWorkspacePath = vi.fn(async () => ({ ok: true, value: { opened: true } })) ctx.provide('layout', layout as never) + const sidebarRight = { openResource: vi.fn<(address: string) => void>() } + ctx.provide('sidebarRight', sidebarRight as never) ctx.provide('uiWorkspace', {} as never) new TestRemote(ctx, { session: { openWorkspacePath } }) const locale = new LocaleRuntime(ctx) @@ -128,7 +130,7 @@ async function bench(snapshot: ChatSnapshot) { await runtime.root.declare(ROOT_CHILDREN, AppRoot) await runtime.mount({ inject: [...injectChat], apply: applyChat }) await runtime.mount({ inject: [...injectTool], apply: applyTool }) - return { runtime, layout, openWorkspacePath } + return { runtime, layout, openWorkspacePath, sidebarRight } } function mountApp(runtime: SlotTestRuntime) { @@ -214,7 +216,7 @@ describe('run_code sub-calls through the real chat machinery', () => { expect(nested).not.toBeNull() }) - it('a file sub-row click opens the host path; bash sub-rows do not open details', async () => { + it('a file sub-row click opens the file in the Sidebar; bash sub-rows open nothing', async () => { const parent = 'call-64' const subCalls = [ subCall(11, parent, 1, 'read', { path: 'notes/demo.txt' }, 'ok'), @@ -223,12 +225,12 @@ describe('run_code sub-calls through the real chat machinery', () => { const b = await bench(snapshotWith([codeResult(10, parent)], subCalls)) const view = mountApp(b.runtime) view.getByText('notes/demo.txt').click() - expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(b.openWorkspacePath).toHaveBeenCalledWith({ path: 'notes/demo.txt' }) + expect(b.sidebarRight.openResource).toHaveBeenCalledWith('dsh-resource://file/session/s1/notes/demo.txt') }) + expect(b.openWorkspacePath).not.toHaveBeenCalled() view.getByText('List notes').click() - expect(b.layout.openDetails).not.toHaveBeenCalled() + expect(b.sidebarRight.openResource).toHaveBeenCalledTimes(1) }) it('a RUNNING run_code call nests its so-far dispatches under the spinner row', async () => { diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index be039beb46..1e8b2fdef6 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -2,25 +2,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { - bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot, -} from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { - ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode, -} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/tool/models/diff-card-model.ts' -import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx' import { FileMutationRow, fileMutationToolview } from '../src/client/tool/toolviews/file-mutation-row.tsx' -import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' -import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts' afterEach(cleanup) @@ -29,7 +21,6 @@ type FileMutationRowProps = Parameters[0] const SID = 's1' as SessionId const t = makeTranslate(zh, commonZh) -const chatT = makeTranslate(chatZh, commonZh) const ARGS = '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}' @@ -356,87 +347,3 @@ describe('fileMutationToolview registration', () => { expect(registered.every(r => r.disposed)).toBe(true) }) }) - -describe('DetailsPanel diff Output section', () => { - function mount(snapshot: ChatSnapshot, selection: SelectionTarget | null, cwd?: string) { - localStorage.clear() - const chat = createChatStore().create() - if (selection !== null) chat.actions.select(selection) - const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined } - : { - ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, - current: SID, - phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, - currentAddress: undefined, - }) - const session = createSnapshotStore(sessionSnapshot(SID)) - const conversation = createSnapshotStore(conversationSnapshot()) - const workspaces = createSnapshotStore(workspaceSnapshot()) - const attention = createSnapshotStore(new Map()) - return render( - children} - sessionId={SID} - useSession={bindSnapshotSelector(session)} - useSessions={bindSnapshotSelector(sessions)} - useSessionPendingInteraction={bindSnapshotSelector(attention)} - useWorkspaces={bindSnapshotSelector(workspaces)} - useConversation={bindSnapshotSelector(conversation)} - useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} - useTrajectory={useEmptyTrajectory} - useInput={(() => { throw new Error('unused') })} - inputActions={{ - setDraft: () => {}, - addAttachments: () => true, - removeAttachment: () => {}, - pruneAttachments: () => {}, - submit: () => {}, - }} - useProjection={(() => undefined)} - useStore={bindSnapshotSelector(chat)} - actions={chat.actions} - closeDetails={vi.fn()} - t={chatT} - />, - ) - } - - function snapshot(over: { - nodes?: readonly ConversationNode[] - runningCalls?: readonly RunningToolCall[] - } = {}): ChatSnapshot { - const nodes = over.nodes ?? [] - const runningCalls = over.runningCalls ?? [] - return toolChatSnapshot(nodes, runningCalls) - } - - const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'edit' } - - it('renders the applied diff at full height, keeping the JSON Input section', () => { - const view = mount(snapshot({ nodes: [settled()] }), target) - expect(view.getByText(/"file_path"/)).toBeTruthy() - expect(view.container.querySelector('[data-diff]')).not.toBeNull() - expect(view.getByText('hello fixture')).toBeTruthy() - }) - - it('a running diff call renders its intended change, not the 运行中… placeholder', () => { - const view = mount(snapshot({ runningCalls: [running()] }), target) - expect(view.container.querySelector('[data-diff]')).not.toBeNull() - expect(view.queryByText('运行中…')).toBeNull() - }) - - it('a non-diff result keeps the flattened pre', () => { - const view = mount(snapshot({ - nodes: [settled({ - meta: undefined, - content: [{ type: 'text', text: 'permission denied' }], - })], - }), target) - expect(view.container.querySelector('[data-diff]')).toBeNull() - expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('permission denied') - }) -}) diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index 5f172891c0..21ace19335 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -3,24 +3,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { Context } from '@deepseek-ai/cordis' -import { - bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, workspaceSnapshot, -} from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import type { - ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode, -} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/tool/models/read-card-model.ts' -import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts' +import { CHAT_READ_MAX_LINES, readCallLine, readCardModel } from '../src/client/tool/models/read-card-model.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' -import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts' -import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx' import { ReadRow, readToolview } from '../src/client/tool/toolviews/read-row.tsx' -import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx' afterEach(cleanup) @@ -28,7 +20,6 @@ const SID = 's1' as SessionId /** The chat-view locale seat: this package's namespace over the common fallback. */ const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh) -const chatT = makeTranslate(chatZh, commonZh) // The read tool's real schema key is `file_path`; the top-level read samples // use it so the row exercises a production-shaped call. `web_fetch` (below) has @@ -149,6 +140,28 @@ describe('readCardModel', () => { }) }) +describe('readCallLine', () => { + it('reads the 1-based offset a well-formed read call started from, running or settled', () => { + expect(readCallLine(running())).toBe(41) + expect(readCallLine(settled())).toBe(41) + }) + + it.each([ + ['no offset', '{"file_path":"src/a.ts"}'], + ['a string offset', '{"file_path":"src/a.ts","offset":"41"}'], + ['zero', '{"file_path":"src/a.ts","offset":0}'], + ['a negative offset', '{"file_path":"src/a.ts","offset":-3}'], + ['a fraction', '{"file_path":"src/a.ts","offset":2.5}'], + ['a read without a path', '{"offset":3}'], + ])('names no line for %s', (_label, argsRaw) => { + expect(readCallLine(running({ argsRaw }))).toBeUndefined() + }) + + it('names no line for a call that is not read', () => { + expect(readCallLine(running({ name: 'echo', argsRaw: '{"offset":3}' }))).toBeUndefined() + }) +}) + describe('GenericToolCard read body', () => { const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({ loadImage: vi.fn(() => Promise.reject(new Error('not used'))), @@ -228,13 +241,14 @@ describe('ReadRow keyed toolview', () => { expect(view.getAllByText('src/a.ts').length).toBe(1) }) - it('the path summary opens the file through the host', () => { + it('the path summary opens the file at the line the call started from', () => { const openFile = vi.fn() const view = render() fireEvent.click(view.getByRole('button', { name: 'src/a.ts' })) - // The row derives the file path from args; the chat view resolves it against - // the cwd before this callback opens it, so the arg path is what arrives. - expect(openFile).toHaveBeenCalledWith('src/a.ts') + // The row derives the file path and the `offset` line from args; the chat + // view resolves the path against the cwd before this callback opens it, so + // the arg path is what arrives. + expect(openFile).toHaveBeenCalledWith('src/a.ts', { line: 41 }) }) it('a running read renders the summary row alone, and its state', () => { @@ -271,104 +285,3 @@ describe('ReadRow keyed toolview', () => { expect(readToolview.inject).toEqual(['slots']) }) }) - -describe('DetailsPanel Output section (read)', () => { - function mount( - snapshot: ChatSnapshot, - selection: SelectionTarget | null, - cwd?: string, - description?: Parameters[1], - ) { - localStorage.clear() - const chat = createChatStore().create() - if (selection !== null) chat.actions.select(selection) - const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined } - : { - ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, - current: SID, - phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, - currentAddress: undefined, - }) - const session = createSnapshotStore(sessionSnapshot(SID)) - const conversation = createSnapshotStore(conversationSnapshot()) - const workspaces = createSnapshotStore(workspaceSnapshot()) - const attention = createSnapshotStore(new Map()) - return render( - children} - sessionId={SID} - t={chatT} - useSession={bindSnapshotSelector(session)} - useSessions={bindSnapshotSelector(sessions)} - useSessionPendingInteraction={bindSnapshotSelector(attention)} - useWorkspaces={bindSnapshotSelector(workspaces)} - useConversation={bindSnapshotSelector(conversation)} - useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} - useTrajectory={useEmptyTrajectory} - useInput={(() => { throw new Error('unused') })} - inputActions={{ - setDraft: () => {}, - addAttachments: () => true, - removeAttachment: () => {}, - pruneAttachments: () => {}, - submit: () => {}, - }} - useProjection={(() => undefined)} - useStore={bindSnapshotSelector(chat)} - actions={chat.actions} - closeDetails={vi.fn()} - />, - ) - } - - function snapshot(over: { - nodes?: readonly ConversationNode[] - runningCalls?: readonly RunningToolCall[] - } = {}): ChatSnapshot { - const nodes = over.nodes ?? [] - const runningCalls = over.runningCalls ?? [] - return toolChatSnapshot(nodes, runningCalls) - } - - const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'read' } - - it('renders the read card at full height, keeping the JSON Input section', () => { - const long = Array.from({ length: 20 }, (_, i) => ({ number: i + 1, text: `row-${i}` })) - const view = mount(snapshot({ - nodes: [settled({ meta: readMeta({ offset: 1, lines: long, totalLines: 20 }) })], - }), target) - expect(view.getByText(/"file_path"/)).toBeTruthy() - expect(view.container.querySelector('[data-read]')).not.toBeNull() - // The panel takes the primitive's own default cap (16), not the row's. - expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy() - expect(contentTexts(view.container)).toContain('row-0') - }) - - it('a non-read result keeps the flattened pre form', () => { - const view = mount(snapshot({ - nodes: [settled({ - meta: undefined, - content: [{ type: 'text', text: 'plain result' }], - })], - }), target) - expect(view.container.querySelector('[data-read]')).toBeNull() - expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('plain result') - }) - - it('abbreviates a leftover POSIX home path on the read card label', () => { - const view = mount(snapshot({ - nodes: [settled({ meta: readMeta({ path: '/Users/u/notes.md' }) })], - }), target, '/tmp/ws', '/Users/u') - expect(view.getByText('~/notes.md')).toBeTruthy() - }) - - it('a running read keeps the 运行中… placeholder (no result metadata)', () => { - const view = mount(snapshot({ runningCalls: [running()] }), target) - expect(view.getByText('运行中…')).toBeTruthy() - expect(view.container.querySelector('[data-read]')).toBeNull() - }) -}) diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx index 963bf18e49..b189b6f6a8 100644 --- a/packages/client/ui-tool/tests/search-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -2,32 +2,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { - bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot, -} from '@deepseek-ai/dsh-client-test-runtime' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { - ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode, -} from '@deepseek-ai/dsh-client-ui-chat/client' -import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/tool/models/search-card-model.ts' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' -import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts' -import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx' import { SearchRow, searchToolview } from '../src/client/tool/toolviews/search-row.tsx' -import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx' type SearchRowProps = Parameters[0] afterEach(cleanup) const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh) -const chatT = makeTranslate(chatZh, commonZh) /** The rendered search card's kind attribute, so a render site cannot silently drop it. */ function searchKindOf(container: HTMLElement): string | null { @@ -367,89 +355,3 @@ describe('SearchRow keyed card', () => { expect(searchToolview.inject).toEqual(['slots']) }) }) - -describe('DetailsPanel Output section (search)', () => { - function mount(snapshot: ChatSnapshot, selection: SelectionTarget | null) { - localStorage.clear() - const chat = createChatStore().create() - if (selection !== null) chat.actions.select(selection) - const sessions = createSnapshotStore({ - ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, - }) - const session = createSnapshotStore(sessionSnapshot(SID)) - const conversation = createSnapshotStore(conversationSnapshot()) - const workspaces = createSnapshotStore(workspaceSnapshot()) - const attention = createSnapshotStore(new Map()) - return render( - children} - sessionId={SID} - useSession={bindSnapshotSelector(session)} - useSessions={bindSnapshotSelector(sessions)} - useSessionPendingInteraction={bindSnapshotSelector(attention)} - useWorkspaces={bindSnapshotSelector(workspaces)} - useConversation={bindSnapshotSelector(conversation)} - useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} - useTrajectory={useEmptyTrajectory} - useInput={(() => { throw new Error('unused') })} - inputActions={{ - setDraft: () => {}, - addAttachments: () => true, - removeAttachment: () => {}, - pruneAttachments: () => {}, - submit: () => {}, - }} - useProjection={(() => undefined)} - useStore={bindSnapshotSelector(chat)} - actions={chat.actions} - closeDetails={vi.fn()} - t={chatT} - />, - ) - } - - function snapshot(over: { - nodes?: readonly ConversationNode[] - runningCalls?: readonly RunningToolCall[] - } = {}): ChatSnapshot { - const nodes = over.nodes ?? [] - const runningCalls = over.runningCalls ?? [] - return toolChatSnapshot(nodes, runningCalls) - } - - const grepTarget: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'grep' } - const globTarget: SelectionTarget = { turnSeq: 11, callId: 'c2', toolName: 'glob' } - - it('renders the grep matches card at full height, keeping the JSON Input section', () => { - const view = mount(snapshot({ nodes: [settledGrep()] }), grepTarget) - expect(view.getByText(/"pattern"/)).toBeTruthy() - expect(searchRows(view.container)).toContain('12: const foo = 1') - expect(searchKindOf(view.container)).toBe('matches') - }) - - it('renders the glob path card', () => { - const view = mount(snapshot({ nodes: [settledGlob()] }), globTarget) - expect(view.getByText('src/a.ts')).toBeTruthy() - expect(searchKindOf(view.container)).toBe('paths') - }) - - it('renders the recovery footer below the card for a capped search', () => { - const recovery = 'src/a.ts\nsrc/b.ts\n\n(Showing 2 of 23 paths. Full sorted result stored at: spill://glob-7.)' - const view = mount(snapshot({ - nodes: [settledGlob({ content: [{ type: 'text', text: recovery }], meta: pathsMeta({ truncated: true, total: 23 }) })], - }), globTarget) - expect(searchKindOf(view.container)).toBe('paths') - expect(view.getByText(/Full sorted result stored at: spill:\/\/glob-7/)).toBeTruthy() - }) - - it('a non-search result keeps the flattened pre form', () => { - const view = mount(snapshot({ - nodes: [settledGrep({ meta: undefined })], - }), grepTarget) - expect(searchKindOf(view.container)).toBeNull() - const output = view.getByText('输出').closest('section') - expect(output?.querySelector('pre')?.textContent).toContain('const foo = 1') - }) -}) diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index 9476de0ce5..1cfe3b869c 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -2,13 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { - bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot, -} from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { - ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode, -} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -17,19 +13,14 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import { localizeTerminalCardModel, terminalCardModel, terminalFailed, } from '../src/client/tool/models/terminal-card-model.ts' -import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx' import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx' -import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx' import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' -import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts' type BashRowProps = Parameters[0] const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh) const enT: GenericToolCardProps['t'] = makeTranslate(en, commonEn) -const chatT = makeTranslate(chatZh, commonZh) afterEach(cleanup) @@ -531,261 +522,3 @@ describe('BashRow terminal card', () => { expect(view.container.querySelector('[data-error]')?.textContent).toBe('Error: command aborted') }) }) - -describe('DetailsPanel Output section', () => { - function mount(snapshot: ChatSnapshot, selection: SelectionTarget | null, cwd?: string) { - localStorage.clear() - const chat = createChatStore().create() - if (selection !== null) chat.actions.select(selection) - const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined } - : { - ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, - current: SID, - phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, - currentAddress: undefined, - }) - const session = createSnapshotStore(sessionSnapshot(SID)) - const conversation = createSnapshotStore(conversationSnapshot()) - const workspaces = createSnapshotStore(workspaceSnapshot()) - const attention = createSnapshotStore(new Map()) - return render( - children} - sessionId={SID} - useSession={bindSnapshotSelector(session)} - useSessions={bindSnapshotSelector(sessions)} - useSessionPendingInteraction={bindSnapshotSelector(attention)} - useWorkspaces={bindSnapshotSelector(workspaces)} - useConversation={bindSnapshotSelector(conversation)} - useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} - useTrajectory={useEmptyTrajectory} - useInput={(() => { throw new Error('unused') })} - inputActions={{ - setDraft: () => {}, addAttachments: () => true, removeAttachment: () => {}, - pruneAttachments: () => {}, submit: () => {}, - }} - useProjection={(() => undefined)} - useStore={bindSnapshotSelector(chat)} - actions={chat.actions} - closeDetails={vi.fn()} - t={chatT} - />, - ) - } - - function snapshot(over: { - nodes?: readonly ConversationNode[] - runningCalls?: readonly RunningToolCall[] - } = {}): ChatSnapshot { - const nodes = over.nodes ?? [] - const runningCalls = over.runningCalls ?? [] - return toolChatSnapshot(nodes, runningCalls) - } - - const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'bash' } - - // The panel never unmounts between selections, so per-call view state has to - // be keyed off the selected call or it leaks into the next one. - it('resets the card\'s expand state when the selected call changes', () => { - const long = Array.from({ length: 20 }, (_, i) => `row-${i}`) - const view = mount(snapshot({ - nodes: [settled({ content: [{ type: 'text', text: `${long.join('\n')}\n` }] })], - }), target) - fireEvent.click(view.getByRole('button', { name: '展开其余 4 行输出' })) - expect(view.getByRole('button', { name: '收起输出' })).toBeTruthy() - // A second call, selected without unmounting the panel, starts collapsed. - cleanup() - const second = mount(snapshot({ - nodes: [settled({ - callId: 'c2', content: [{ type: 'text', text: `${long.join('\n')}\n` }], - })], - }), { turnSeq: 10, callId: 'c2', toolName: 'bash' }) - expect(second.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy() - }) - - it('renders the raw call description above the card', () => { - const view = mount(snapshot({ - nodes: [settled({ call: { name: 'bash', argsRaw: shellArgs({ description: 'Terminal 3' }) } })], - }), target) - const description = view.getByText('Terminal 3') - const card = view.container.querySelector('[data-terminal]') - expect(card).not.toBeNull() - // Above, not below: document order is what places it as the card's heading. - expect(description.compareDocumentPosition(card!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() - }) - - it('localizes terminal_send copy in Details', () => { - const argsRaw = JSON.stringify({ sessionId: 'pty-3', text: '' }) - const view = mount(snapshot({ - nodes: [settled({ - call: { name: 'terminal_send', argsRaw }, - content: [{ type: 'text', text: 'ok' }], - })], - }), { ...target, toolName: 'terminal_send' }) - expect(view.getByText('终端 pty-3')).toBeTruthy() - expect(view.getByText('(发送输入)')).toBeTruthy() - }) - - it('resolves the prompt cwd against the session workspace', () => { - const view = mount(snapshot({ nodes: [settled()] }), target, '/w/app') - // No workdir in the call args: the prompt label is the workspace basename. - expect(view.getByText('app')).toBeTruthy() - }) - - it('renders the terminal card at full height, keeping the JSON Input section', () => { - const long = Array.from({ length: 20 }, (_, i) => `row-${i}`) - const view = mount(snapshot({ - nodes: [settled({ content: [{ type: 'text', text: `${long.join('\n')}\n` }] })], - }), target) - expect(view.getByText(/"command"/)).toBeTruthy() - expect(view.getByText('ls -la')).toBeTruthy() - // The panel takes the primitive's own default cap (16), not the row's. - expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy() - expect(view.getByText('row-0')).toBeTruthy() - }) - - it('a running terminal call shows the prompt line, not the 运行中… placeholder', () => { - const view = mount(snapshot({ runningCalls: [running()] }), target) - expect(view.getByText('ls -la')).toBeTruthy() - expect(view.queryByText('运行中…')).toBeNull() - expect(runStateOf(view.container)).toBe('ongoing') - }) - - it('a running non-terminal call keeps the 运行中… placeholder', () => { - const view = mount(snapshot({ runningCalls: [running({ argsRaw: shellArgs({ run_in_background: true }) })] }), target) - expect(view.getByText('运行中…')).toBeTruthy() - }) - - it('a non-terminal result keeps the flattened pre with its error styling', () => { - const view = mount(snapshot({ - nodes: [settled({ - isError: true, - content: [{ type: 'text', text: 'permission denied' }], - })], - }), target) - const pre = view.container.querySelector('pre[data-error]') - expect(pre?.textContent).toBe('permission denied') - }) - - it('a Code Dispatch child renders its terminal output in Details', () => { - const child = settled({ callId: 'c1', parentCallId: 'p1' }) - const view = mount(snapshot({ - runningCalls: [running({ callId: 'p1', subCalls: [child] })], - }), target) - expect(view.container.querySelector('[data-terminal]')?.textContent).toContain('a.ts b.ts') - expect(view.getByText('ls -la')).toBeTruthy() - expect(runStateOf(view.container)).toBe('done') - }) - - it('a running Code Dispatch child renders its terminal prompt in Details', () => { - const view = mount(snapshot({ - // The leading non-matching sub-call exercises the scan's skip. - runningCalls: [running({ - callId: 'p1', - subCalls: [ - running({ callId: 'other', parentCallId: 'p1' }), - running({ parentCallId: 'p1' }), - ], - })], - }), target) - expect(view.queryByText('运行中…')).toBeNull() - expect(view.getByText('ls -la')).toBeTruthy() - expect(runStateOf(view.container)).toBe('ongoing') - }) - - it('a window-truncated call head titles the panel by callId and drops the Input section', () => { - const view = mount(snapshot({ - nodes: [settled({ call: null })], - }), target) - expect(view.getByText('c1')).toBeTruthy() - expect(view.queryByText('输入')).toBeNull() - expect(view.getByText('输出')).toBeTruthy() - }) - - it('scans past other nodes and other calls before reporting the call out of window', () => { - const view = mount(snapshot({ - nodes: [ - { kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [] }, - settled({ callId: 'elsewhere' }), - ], - runningCalls: [running({ callId: 'also-elsewhere' })], - }), target) - expect(view.getByText('该调用不在当前窗口内')).toBeTruthy() - }) - - it('no selection at all renders the guidance line and the default title', () => { - const view = mount(snapshot(), null) - expect(view.getByText('详情')).toBeTruthy() - expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy() - }) - - it('a step selection without a callId renders the guidance line too', () => { - const view = mount(snapshot(), { turnSeq: 3, stepSeq: 1 }) - expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy() - }) - - it('the close button reaches closeDetails', () => { - localStorage.clear() - const chat = createChatStore().create() - const closeDetails = vi.fn() - const snap = snapshot() - const session = createSnapshotStore(sessionSnapshot(SID)) - const conversation = createSnapshotStore(conversationSnapshot()) - const workspaces = createSnapshotStore(workspaceSnapshot()) - const attention = createSnapshotStore(new Map()) - const view = render( - children} - sessionId={SID} - useSession={bindSnapshotSelector(session)} - useSessions={bindSnapshotSelector(createSnapshotStore( - { - ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, - }))} - useSessionPendingInteraction={bindSnapshotSelector(attention)} - useWorkspaces={bindSnapshotSelector(workspaces)} - useConversation={bindSnapshotSelector(conversation)} - useChat={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })} - useTrajectory={useEmptyTrajectory} - useInput={(() => { throw new Error('unused') })} - inputActions={{ - setDraft: () => {}, addAttachments: () => true, removeAttachment: () => {}, - pruneAttachments: () => {}, submit: () => {}, - }} - useProjection={(() => undefined)} - useStore={bindSnapshotSelector(chat)} - actions={chat.actions} - closeDetails={closeDetails} - t={chatT} - />, - ) - fireEvent.click(view.getByRole('button', { name: '关闭详情' })) - expect(closeDetails).toHaveBeenCalledTimes(1) - }) - - it('a non-text result block renders as JSON, and an empty result falls back to its error', () => { - const nonText = mount(snapshot({ - nodes: [settled({ - content: [{ type: 'reasoning', text: 'why' }], - })], - }), target) - // Scope to the Output section: the Input section's CodeBlock renders a - //
     of its own, and it comes first in document order.
    -    expect(nonText.getByText('输出').closest('section')?.querySelector('pre')?.textContent)
    -      .toBe('{\n  "type": "reasoning",\n  "text": "why"\n}')
    -    cleanup()
    -    const empty = mount(snapshot({
    -      nodes: [settled({
    -        content: [], isError: true,
    -        error: { name: 'ToolError', code: 'interrupted' },
    -      })],
    -    }), target)
    -    expect(empty.getByText('ToolError: interrupted')).toBeTruthy()
    -  })
    -})
    diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx
    index 4cbd33e0d1..db048abc4b 100644
    --- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx
    +++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx
    @@ -56,46 +56,15 @@ function props(
     }
     
     describe('ToolCallTree', () => {
    -  it('owns the root marker, generic fallback, and selected state for a window-truncated call', () => {
    +  it('owns the root marker and the generic fallback for a window-truncated call', () => {
         const block = root('w1', null)
         const view = render()
         const row = view.container.querySelector('[data-chat-call-id="w1"]')
         expect(row?.getAttribute('data-chat-anchor-key')).toBe('call:w1')
    -    expect(row?.getAttribute('data-selected')).toBe('true')
         expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
         expect(view.getByText('w1')).toBeTruthy()
       })
     
    -  it('recursively renders a selected leaf without selecting its ancestors', () => {
    -    const owners: ToolCallOwnerProps[] = []
    -    const leaf = {
    -      ...root('parent:code:1:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' }),
    -      parentCallId: 'parent:code:1',
    -    }
    -    const child = {
    -      ...root('parent:code:1', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
    -      parentCallId: 'parent',
    -      subCalls: [leaf],
    -    }
    -    const block = {
    -      ...root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
    -      subCalls: [child],
    -    }
    -    const view = render()
    -    const nests = view.container.querySelectorAll('[data-subcalls]')
    -    expect(nests[0]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent"]'))
    -    expect(nests[1]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent:code:1"]'))
    -    expect(view.container.querySelector('[data-chat-call-id="parent"]')?.hasAttribute('data-selected')).toBe(false)
    -    expect(view.container.querySelector('[data-chat-call-id="parent:code:1"]')?.hasAttribute('data-selected')).toBe(false)
    -    expect(view.container.querySelector('[data-chat-call-id="parent:code:1:code:1"]')?.getAttribute('data-selected')).toBe('true')
    -    expect(nests).toHaveLength(2)
    -    expect(owners.map(owner => [owner.callId, owner.block.parentCallId ?? null])).toEqual([
    -      ['parent', null],
    -      ['parent:code:1', 'parent'],
    -      ['parent:code:1:code:1', 'parent:code:1'],
    -    ])
    -  })
    -
       it('abbreviates a POSIX home path in the generic tool summary', () => {
         const block = root('w1', { name: 'read', argsRaw: '{"path":"/h/docs/a.ts"}' })
         const view = render()
    diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-fixtures.client.ts
    similarity index 71%
    rename from packages/client/ui-tool/tests/tool-details-render.client.tsx
    rename to packages/client/ui-tool/tests/tool-fixtures.client.ts
    index 29f8ef954f..dfb73e25a5 100644
    --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx
    +++ b/packages/client/ui-tool/tests/tool-fixtures.client.ts
    @@ -1,27 +1,10 @@
    -/** Test adapter for the production conversation.details.tool registration. */
    +/** Shared Chat-slice and Session-event fixtures for Tool row tests. */
     import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
     import { SessionSeq } from '@deepseek-ai/dsh-session/types'
     import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
     import type {
    -  ChatConversationViewNode, ChatSnapshot, ConversationNode, DetailsSlotProps,
    -  DetailsToolOwnerProps, RunningToolCall, ToolResultNode,
    +  ChatConversationViewNode, ChatSnapshot, ConversationNode, RunningToolCall, ToolResultNode,
     } from '@deepseek-ai/dsh-client-ui-chat/client'
    -import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
    -import { ToolDetails } from '../src/client/tool/ToolDetails.tsx'
    -
    -type TrajectorySnapshot = Parameters[0]>[0]
    -
    -const emptyTrajectory: TrajectorySnapshot = {
    -  eventNodes: [],
    -  eventLocations: new Map(),
    -  requests: [],
    -  callSchemas: new Map(),
    -  partial: null,
    -  runningCalls: [],
    -}
    -
    -/** Stable empty Trajectory source for DetailsPanel fixtures. */
    -export const useEmptyTrajectory: DetailsSlotProps['useTrajectory'] = selector => selector(emptyTrajectory)
     
     function jsonFixture(value: unknown): JsonValue {
       if (!isJsonValue(value)) throw new Error('tool event fixture must be lossless JSON')
    @@ -142,26 +125,3 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se
       }
       return entries
     }
    -
    -/**
    - * Bind ui-tool's details renderer to the conversation slot callback shape.
    - * @param t - conversation locale seat used by Tool cards.
    - * @param home - optional Host account home for POSIX `~` summaries.
    - * @returns a direct-test renderSlot implementation.
    - */
    -export function renderToolDetails(
    -  t: TranslateNS<'conversation'>,
    -  home?: string,
    -): DetailsSlotProps['renderSlot'] {
    -  return (_key, owner) => {
    -    // PropsRenderSlots keeps its key generic even for this one-key share;
    -    // recover the concrete owner selected by the adapter's fixed slot.
    -    const details = owner as unknown as DetailsToolOwnerProps
    -    return  selector({ home, isLoopback: true })}
    -      t={t}
    -    />
    -  }
    -}
    diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx
    index 2409fd0b9c..bb471caff9 100644
    --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx
    +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx
    @@ -13,7 +13,7 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
     import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
     import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client'
     import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
    -import { toolSessionEvents } from './tool-details-render.client.tsx'
    +import { toolSessionEvents } from './tool-fixtures.client.ts'
     
     const SID = 's1' as SessionId
     
    @@ -42,14 +42,13 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
     })
     
     /** Test-owned AppFrame role: declares and renders the resident conversation area. */
    -type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
    +type AppRootProps = PropsRenderSlots<'conversation'>
     function AppRoot({ renderSlot }: AppRootProps) {
       return <>{renderSlot('conversation', {})}
     }
     
     const LAYOUT_CHILDREN = {
       'conversation': { kind: 'single', scope: 'session-maybe' },
    -  'details': { kind: 'single', scope: 'session' },
     } as const
     
     /**
    @@ -64,6 +63,8 @@ async function bench(nodes: ToolResultNode[]) {
       runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
       const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
       runtime.ctx.provide('layout', layout)
    +  const sidebarRight = { openResource: vi.fn<(address: string) => void>() }
    +  runtime.ctx.provide('sidebarRight', sidebarRight as never)
       runtime.ctx.provide('uiWorkspace', {
         connectWorkspace: vi.fn(async () => SID),
       } as never)
    @@ -72,7 +73,7 @@ async function bench(nodes: ToolResultNode[]) {
       runtime.slots.installLocale(locale)
       await runtime.sessions.add({
         id: SID,
    -    summary: { title: 'S', displayTitle: 'S' },
    +    summary: { title: 'S', displayTitle: 'S', cwd: '/w' },
         events: toolSessionEvents(nodes),
         session: {
           loadOlder: vi.fn(),
    @@ -83,7 +84,7 @@ async function bench(nodes: ToolResultNode[]) {
       await runtime.mount({ inject: [...injectConversation], apply: applyConversation })
       await runtime.mount({ inject: [...injectChat], apply: applyChat })
       await runtime.mount({ inject: [...injectTool], apply: applyTool })
    -  return { runtime, slots: runtime.slots, layout, openWorkspacePath }
    +  return { runtime, slots: runtime.slots, layout, openWorkspacePath, sidebarRight }
     }
     
     describe('keyed toolview hole through the real machinery', () => {
    @@ -126,22 +127,23 @@ describe('keyed toolview hole through the real machinery', () => {
         await b.runtime.dispose()
       })
     
    -  it('file-path clicks travel owner openFile → chat inject → session.openWorkspacePath', async () => {
    +  it('file-path clicks travel owner openFile → chat inject → the right Sidebar', async () => {
         const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
         const view = b.runtime.renderRoot()
         view.getByText('src/a.ts').click()
    -    expect(b.layout.openDetails).not.toHaveBeenCalled()
         await vi.waitFor(() => {
    -      expect(b.openWorkspacePath).toHaveBeenCalledWith({ path: 'src/a.ts' })
    +      expect(b.sidebarRight.openResource).toHaveBeenCalledWith('dsh-resource://file/session/s1/src/a.ts')
         })
    +    // Nothing on this path reaches the local machine any more.
    +    expect(b.openWorkspacePath).not.toHaveBeenCalled()
         await b.runtime.dispose()
       })
     
    -  it('bash summary clicks do not open details or host paths', async () => {
    +  it('bash summary clicks open nothing at all', async () => {
         const b = await bench([toolResult(3, 'c1', 'bash')])
         const view = b.runtime.renderRoot()
         view.getByText('Build').click()
    -    expect(b.layout.openDetails).not.toHaveBeenCalled()
    +    expect(b.sidebarRight.openResource).not.toHaveBeenCalled()
         expect(b.openWorkspacePath).not.toHaveBeenCalled()
         await b.runtime.dispose()
       })
    @@ -208,6 +210,7 @@ describe('registrant declaration injection', () => {
         })
         runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
         runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
    +    runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never)
         runtime.ctx.provide('uiWorkspace', {
           connectWorkspace: vi.fn(async () => SID),
         } as never)
    diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx
    index 473ac1c7a4..28f5c8c2d3 100644
    --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx
    +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx
    @@ -2,34 +2,20 @@
     
     import { afterEach, describe, expect, it, vi } from 'vitest'
     import { cleanup, fireEvent, render } from '@testing-library/react'
    -import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
    -import type {
    -  ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode,
    -} from '@deepseek-ai/dsh-client-ui-chat/client'
    -import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
    -import type { SessionId } from '@deepseek-ai/dsh-session/types'
    -import {
    -  bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot,
    -} from '@deepseek-ai/dsh-client-test-runtime'
    +import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
     import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client'
     import { IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
     import { webCardModel } from '../src/client/tool/models/web-card-model.ts'
    -import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts'
     import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx'
    -import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx'
     import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx'
    -import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx'
     import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
     import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
     import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
    -import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts'
     
     afterEach(cleanup)
     
    -const SID = 's1' as SessionId
     
     const t = makeTranslate(zh, commonZh)
    -const chatT = makeTranslate(chatZh, commonZh)
     
     const SEARCH_ARGS = '{"queries":["deepseek harness"]}'
     const FETCH_ARGS = '{"url":"https://example.com/page"}'
    @@ -215,86 +201,6 @@ describe('chat row web body', () => {
       })
     })
     
    -describe('DetailsPanel web Output section', () => {
    -  function mount(snapshot: ChatSnapshot, selection: SelectionTarget | null) {
    -    localStorage.clear()
    -    const chat = createChatStore().create()
    -    if (selection !== null) chat.actions.select(selection)
    -    const sessions = createSnapshotStore({
    -      ids: [], byId: {}, current: undefined, phase: 'ready',
    -      subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
    -    })
    -    const session = createSnapshotStore(sessionSnapshot(SID))
    -    const conversation = createSnapshotStore(conversationSnapshot())
    -    const workspaces = createSnapshotStore(workspaceSnapshot())
    -    const attention = createSnapshotStore(new Map())
    -    return render(
    -       children}
    -        sessionId={SID}
    -        useSession={bindSnapshotSelector(session)}
    -        useSessions={bindSnapshotSelector(sessions)}
    -        useSessionPendingInteraction={bindSnapshotSelector(attention)}
    -        useWorkspaces={bindSnapshotSelector(workspaces)}
    -        useConversation={bindSnapshotSelector(conversation)}
    -        useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
    -        useTrajectory={useEmptyTrajectory}
    -        useInput={(() => { throw new Error('unused') })}
    -        inputActions={{
    -          setDraft: () => {},
    -          addAttachments: () => true,
    -          removeAttachment: () => {},
    -          pruneAttachments: () => {},
    -          submit: () => {},
    -        }}
    -        useProjection={(() => undefined)}
    -        useStore={bindSnapshotSelector(chat)}
    -        actions={chat.actions}
    -        closeDetails={vi.fn()}
    -        t={chatT}
    -      />,
    -    )
    -  }
    -
    -  function snapshot(over: {
    -    nodes?: readonly ConversationNode[]
    -    runningCalls?: readonly RunningToolCall[]
    -  } = {}): ChatSnapshot {
    -    const nodes = over.nodes ?? []
    -    const runningCalls = over.runningCalls ?? []
    -    return toolChatSnapshot(nodes, runningCalls)
    -  }
    -
    -  it('renders the search card at full source allowance', () => {
    -    const view = mount(snapshot({ nodes: [settledSearch()] }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' })
    -    expect(view.getByText('Titled')).toBeTruthy()
    -    expect(view.getByText('excerpt')).toBeTruthy()
    -    // The Input JSON section survives beside it.
    -    expect(view.getByText(/"queries"/)).toBeTruthy()
    -  })
    -
    -  it('renders the fetch card and keeps the fetched body below it', () => {
    -    const view = mount(snapshot({ nodes: [settledFetch()] }), { turnSeq: 11, callId: 'c2', toolName: 'web_fetch' })
    -    const card = view.container.querySelector('[data-web="fetch"]')
    -    expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page')
    -    expect(view.getByText('HTTP 200')).toBeTruthy()
    -    // The card is a summary (URL + status only); the panel is the single-call
    -    // reading surface, so the fetched body still renders below the card.
    -    const output = view.getByText('输出').closest('section')
    -    expect(output?.querySelector('pre')?.textContent).toContain('fetch body')
    -  })
    -
    -  it('a non-web result keeps the flattened pre form', () => {
    -    const view = mount(snapshot({
    -      nodes: [settledSearch({ meta: undefined })],
    -    }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' })
    -    expect(view.container.querySelector('[data-web]')).toBeNull()
    -    const output = view.getByText('输出').closest('section')
    -    expect(output?.querySelector('pre')?.textContent).toContain('search text')
    -  })
    -})
    -
     describe('web toolview registration', () => {
       it('registers one WebRow under both web_search and web_fetch', () => {
         const registered: { key: string; locale: unknown; component: unknown }[] = []
    diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json
    index 82e2b2d081..59d0496f89 100644
    --- a/packages/client/ui-trajectory/package.json
    +++ b/packages/client/ui-trajectory/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-client-ui-trajectory",
       "description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx
    index e8288cb0fa..91ba0468f1 100644
    --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx
    +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx
    @@ -7,6 +7,7 @@
      * event ledger with its timing overview, and fiber disposal removes the tab.
      * Timeline projection and inclusive focus edge cases ride along.
      */
    +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
     import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
     import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
     import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
    @@ -52,12 +53,16 @@ import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
     import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
     import { t as tTrajectory, tZh } from './locale.client.ts'
     
    +// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
    +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
    +
     function TrajectoryTimeline(
       props: Omit, 't'>,
     ) {
       return 
     }
     
    +
     const SID = 's1' as SessionId
     const tConversation: ConversationSessionHeaderProps['t'] =
       key => (conversationZh as Record)[key] ?? key
    @@ -219,6 +224,7 @@ function standaloneProps(
         sessionId: SID,
         useChat: bindSnapshotSelector(createSnapshotStore(EMPTY_CHAT_SNAPSHOT)),
         useSessions: emptySessions(),
    +    useResource,
         useSessionPendingInteraction: bindSnapshotSelector(
           createSnapshotStore(new Map()),
         ),
    @@ -345,6 +351,7 @@ function mount(fixture: Awaited>) {
         useConversation,
         useConversationViews,
         useSessions,
    +    useResource,
         useSessionPendingInteraction,
         useWorkspaces,
         useProjection,
    diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json
    index 66db0afecf..c847f274d2 100644
    --- a/packages/client/ui-user-questions/package.json
    +++ b/packages/client/ui-user-questions/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-client-ui-user-questions",
       "description": "Web ask_user_question composer takeover and plan-review presentation UI",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx
    index e303ae5aa8..f3cc685d4d 100644
    --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx
    +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx
    @@ -1,4 +1,5 @@
     // @vitest-environment jsdom
    +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
     import { afterEach, describe, expect, it, vi } from 'vitest'
     import { cleanup, fireEvent, render, screen } from '@testing-library/react'
     import type { SessionId } from '@deepseek-ai/dsh-session/types'
    @@ -11,8 +12,12 @@ import { en, zh } from '../src/client/locales.ts'
     import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
     import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
     
    +// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
    +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
    +
     afterEach(cleanup)
     
    +
     const SID = 's1' as SessionId
     
     const seatOver = (dict: Record, common: Record): QuestionComposerProps['t'] =>
    @@ -110,6 +115,7 @@ const kit: Omit = {
       pendingInteraction: undefined,
       useSession: selector => selector(sessionState),
       useSessions: selector => selector(sessionList),
    +  useResource,
       useSessionPendingInteraction: selector => selector(attentionState),
       useWorkspaces: selector => selector(workspaceState),
       useConversation: selector => selector(conversationState),
    diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
    index 42313fc802..80d7d9307c 100644
    --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
    +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
    @@ -1,4 +1,5 @@
     // @vitest-environment jsdom
    +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
     import { useSyncExternalStore } from 'react'
     import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
     import { cleanup, fireEvent, render, screen } from '@testing-library/react'
    @@ -10,8 +11,12 @@ import { en, zh } from '../src/client/locales.ts'
     import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
     import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
     
    +// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
    +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
    +
     afterEach(cleanup)
     
    +
     const SID = 's1' as SessionId
     
     const seatOver = (dict: Record, common: Record): QuestionComposerProps['t'] =>
    @@ -109,6 +114,7 @@ const kitBase: Omit =
       pendingInteraction: undefined,
       useSession: selector => selector(sessionState),
       useSessions: selector => selector(sessionList),
    +  useResource,
       useSessionPendingInteraction: selector => selector(attentionState),
       useWorkspaces: selector => selector(workspaceState),
       useConversation: selector => selector(conversationState),
    diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json
    index 2bb8d5fdb5..b77d23403c 100644
    --- a/packages/client/ui-workflow-run/package.json
    +++ b/packages/client/ui-workflow-run/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-client-ui-workflow-run",
       "description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx
    index 123d08ef84..e6b38074a5 100644
    --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx
    +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx
    @@ -1,4 +1,5 @@
     // @vitest-environment jsdom
    +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
     import { Context, Service } from '@deepseek-ai/cordis'
     import { cleanup, fireEvent, render, screen } from '@testing-library/react'
     import { afterEach, describe, expect, it, vi } from 'vitest'
    @@ -31,8 +32,12 @@ import {
     import { apply as applyNode } from '../src/index.ts'
     import type {} from '../src/client/index.ts'
     
    +// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
    +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
    +
     afterEach(cleanup)
     
    +
     const PARENT_ID = 'parent' as SessionId
     const CHILD_ID = 'child-1' as SessionId
     const SECOND_ID = 'child-2' as SessionId
    @@ -309,6 +314,7 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi
         node: node(data),
         sessionId: PARENT_ID,
         useSessions: selector => selector(sessions),
    +    useResource,
         useSessionPendingInteraction: selector => selector(panelAttention),
         useSession: selector => selector(panelSession),
         useProjection: () => undefined,
    diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json
    index 91cc118845..d499dc8106 100644
    --- a/packages/client/ui-workspace/package.json
    +++ b/packages/client/ui-workspace/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-client-ui-workspace",
       "description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
    index 99437341f3..6958423e5f 100644
    --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
    +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
    @@ -1,4 +1,5 @@
     // @vitest-environment jsdom
    +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
     import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
     import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
     import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
    @@ -15,6 +16,9 @@ import { UNGROUPED_KEY } from '../src/client/tree.ts'
     import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx'
     import { zh } from '../src/client/locales.ts'
     
    +// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
    +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
    +
     afterEach(cleanup)
     const scrollIntoView = vi.fn()
     beforeEach(() => {
    @@ -74,6 +78,7 @@ function mount(overrides: Partial = {}) {
         expandSidebar: vi.fn(),
         useSessions: hook(sessionState([])),
         useSessionPendingInteraction: hook(noPendingInteraction),
    +    useResource,
         useWorkspaces: hook(workspaceState([])),
         useStore: bindSnapshotSelector(store),
         actions: store.actions,
    diff --git a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx
    index 1c76b8b25f..b2ac4a6d1a 100644
    --- a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx
    +++ b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx
    @@ -1,4 +1,5 @@
     // @vitest-environment jsdom
    +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
     import { afterEach, describe, expect, it, vi } from 'vitest'
     import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
     import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
    @@ -13,6 +14,9 @@ import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from '../src/clien
     import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
     import { zh } from '../src/client/locales.ts'
     
    +// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
    +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
    +
     afterEach(cleanup)
     
     // The seat's key domain is workspace ∪ common; the stub mirrors the real
    @@ -94,6 +98,7 @@ function mount(
           anchorRef={anchorRef}
           useSessions={hook(sessions)}
           useSessionPendingInteraction={hook(noPendingInteraction)}
    +      useResource={useResource}
           useWorkspaces={hook(workspaceState(nextItems))}
           onPick={onPick}
           onClose={onClose}
    @@ -215,6 +220,7 @@ describe('WorkspacePicker', () => {
           ,
    @@ -231,6 +237,7 @@ describe('WorkspacePicker', () => {
           ,
    diff --git a/packages/client/web/package.json b/packages/client/web/package.json
    index af4d66d271..d2c132a525 100644
    --- a/packages/client/web/package.json
    +++ b/packages/client/web/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-client-web",
       "description": "Web boot kernel: static module table, Cordis loader, framework-free boot page, and UI-renderer handoff",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -26,6 +26,7 @@
         "@deepseek-ai/cordis-plugin-loader": "workspace:^",
         "@deepseek-ai/dsh-client-modules": "workspace:^",
         "@deepseek-ai/dsh-client-store": "workspace:^",
    +    "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^",
         "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
         "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
         "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
    diff --git a/packages/client/web/src/platform.ts b/packages/client/web/src/platform.ts
    index 197776cb6d..2549966986 100644
    --- a/packages/client/web/src/platform.ts
    +++ b/packages/client/web/src/platform.ts
    @@ -10,6 +10,7 @@ export const PLATFORM_MODULES = [
       '@deepseek-ai/dsh-client-store',
       '@deepseek-ai/dsh-client-ui-slots',
       '@deepseek-ai/dsh-client-ui-primitives',
    +  '@deepseek-ai/dsh-client-ui-dockkit',
     ] as const
     
     /** Client-bundle specifiers whose factories the parser preloads before the shell starts. */
    diff --git a/packages/client/web/src/seed.ts b/packages/client/web/src/seed.ts
    index 1f213641ca..d41f338d42 100644
    --- a/packages/client/web/src/seed.ts
    +++ b/packages/client/web/src/seed.ts
    @@ -14,6 +14,7 @@ import * as Cordis from '@deepseek-ai/cordis'
     import * as ClientStore from '@deepseek-ai/dsh-client-store'
     import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots'
     import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
    +import * as UiDockkit from '@deepseek-ai/dsh-client-ui-dockkit'
     import type { PlatformModule } from './platform.ts'
     
     /**
    @@ -33,5 +34,6 @@ export function getStaticModules(): Record {
         '@deepseek-ai/dsh-client-store': ClientStore,
         '@deepseek-ai/dsh-client-ui-slots': UiSlots,
         '@deepseek-ai/dsh-client-ui-primitives': UiPrimitives,
    +    '@deepseek-ai/dsh-client-ui-dockkit': UiDockkit,
       } satisfies Record
     }
    diff --git a/packages/client/web/tsconfig.json b/packages/client/web/tsconfig.json
    index d0a5a37049..03d273ce53 100644
    --- a/packages/client/web/tsconfig.json
    +++ b/packages/client/web/tsconfig.json
    @@ -26,6 +26,9 @@
         {
           "path": "../ui-primitives"
         },
    +    {
    +      "path": "../ui-dockkit"
    +    },
         {
           "path": "../ui-renderer"
         }
    diff --git a/packages/code-runtime/code-runtime-worker-thread/package.json b/packages/code-runtime/code-runtime-worker-thread/package.json
    index 186c40fe21..9a5b63f34c 100644
    --- a/packages/code-runtime/code-runtime-worker-thread/package.json
    +++ b/packages/code-runtime/code-runtime-worker-thread/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-code-runtime-worker-thread",
       "description": "Worker-thread implementation of the DeepSeek Harness code-execution seam",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json
    index 4e6201d0dc..db093cce66 100644
    --- a/packages/code-runtime/code-runtime/package.json
    +++ b/packages/code-runtime/code-runtime/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-code-runtime",
       "description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/compaction/command-compact/package.json b/packages/compaction/command-compact/package.json
    index f04b58da40..b69d90e655 100644
    --- a/packages/compaction/command-compact/package.json
    +++ b/packages/compaction/command-compact/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-command-compact",
       "description": "Human-facing slash command for explicit session compaction",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json
    index 4b76fc0bc0..1631201200 100644
    --- a/packages/compaction/compaction-basic/package.json
    +++ b/packages/compaction/compaction-basic/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-compaction-basic",
       "description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts
    index 3ab00ca5e7..b7b1bb883e 100644
    --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts
    +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts
    @@ -13,7 +13,6 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
     import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
     import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
     import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import TokenMeter from '@deepseek-ai/dsh-token-meter'
     import * as LlmRetry from '@deepseek-ai/dsh-llm-retry'
     import { Session, SessionId, type SessionEvent, type SurfaceEvent } from '@deepseek-ai/dsh-session'
    @@ -151,9 +150,6 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
       const ctx = new Context()
       await mountAgentLoopTestDependencies(ctx)
       await mountInvariants(ctx)
    -  // AgentLoop and TokenMeter both declare the registry as a required
    -  // injection; mount it before either activates.
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(AgentLoop, { agents: [] })
       await ctx.plugin(TokenMeter)
       ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
    @@ -317,7 +313,6 @@ describe('context-overflow recovery across the real loop and compaction-basic',
           const adapter = new OverflowRecoveryAdapter(delivery)
           await mountAgentLoopTestDependencies(ctx)
           await mountInvariants(ctx)
    -      await ctx.plugin(SessionProjectionRegistry)
           await ctx.plugin(AgentLoop, { agents: [] })
           await ctx.plugin(TokenMeter)
           ctx.llm.registerAdapter(['mock'], adapter)
    @@ -396,7 +391,6 @@ describe('context-overflow recovery across the real loop and compaction-basic',
         const adapter = new OverflowRecoveryAdapter('thrown', true)
         await mountAgentLoopTestDependencies(ctx)
         await mountInvariants(ctx)
    -    await ctx.plugin(SessionProjectionRegistry)
         await ctx.plugin(LlmRetry)
         await ctx.plugin(AgentLoop, { agents: [] })
         await ctx.plugin(TokenMeter)
    diff --git a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts
    index 5e295baf0a..e7425500f1 100644
    --- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts
    +++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts
    @@ -104,7 +104,6 @@ async function loopHarness(): Promise {
       await ctx.plugin(AgentInvariant)
       await ctx.plugin(AgentLoopInvariant)
       await ctx.plugin(CompactionInvariant)
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(AgentLoop, { agents: [] })
       await ctx.plugin(TokenMeter)
       const adapter = new TextAdapter()
    diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json
    index d67a10e588..23250df237 100644
    --- a/packages/compaction/compaction-tool-result-pruner/package.json
    +++ b/packages/compaction/compaction-tool-result-pruner/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-compaction-tool-result-pruner",
       "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/compaction/compaction/package.json b/packages/compaction/compaction/package.json
    index 874c150e92..d907fd61fa 100644
    --- a/packages/compaction/compaction/package.json
    +++ b/packages/compaction/compaction/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-compaction",
       "description": "Abstract compaction service seam (ctx.compaction) for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json
    index dca221aa1a..91e4df65bf 100644
    --- a/packages/context/agent-instructions/package.json
    +++ b/packages/context/agent-instructions/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-agent-instructions",
       "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -45,6 +45,7 @@
         "@deepseek-ai/cordis-plugin-loader": "workspace:^",
         "@deepseek-ai/dsh-agent": "workspace:^",
         "@deepseek-ai/dsh-agent-loop": "workspace:^",
    +    "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
         "@deepseek-ai/dsh-fs": "workspace:^",
         "@deepseek-ai/dsh-fs-local": "workspace:^",
         "@deepseek-ai/dsh-home-paths": "workspace:^",
    diff --git a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts
    index 6e9d89c040..7e35bdee21 100644
    --- a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts
    +++ b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts
    @@ -4,14 +4,10 @@ import { tmpdir } from 'node:os'
     import { join } from 'node:path'
     import { afterEach, describe, expect, it } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
    -import LlmRuntime from '@deepseek-ai/dsh-llm'
    -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
    -import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
    -import ToolRuntime from '@deepseek-ai/dsh-tools'
    -import AgentRegistry from '@deepseek-ai/dsh-agent'
    +import { SessionId } from '@deepseek-ai/dsh-session'
     import type { Agent } from '@deepseek-ai/dsh-agent'
     import AgentLoop from '@deepseek-ai/dsh-agent-loop'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
    +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
     import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
     import * as WorkspaceContext from '@deepseek-ai/dsh-agent-instructions'
     import { candidateScopeKey } from '../src/render.ts'
    @@ -38,12 +34,9 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> {
       await mkdir(join(workdir, '.git'), { recursive: true })
       await writeFile(join(workdir, 'AGENTS.md'), `If the user asks for the workspace context handshake, reply with exactly this string and nothing else: ${PROBE}.\n`)
       ctx = new Context()
    -  await ctx.plugin(LlmRuntime)
    -  await ctx.plugin(SessionStore)
    -  await ctx.plugin(SessionProjectionRegistry)
    -  await ctx.plugin(SystemPrompt, { personaPrefix: 'Answer the user exactly and concisely.' })
    -  await ctx.plugin(ToolRuntime)
    -  await ctx.plugin(AgentRegistry)
    +  await mountAgentLoopTestDependencies(ctx, {
    +    systemPrompt: { personaPrefix: 'Answer the user exactly and concisely.' },
    +  })
       await ctx.plugin(LocalFileSystem, { cwd: '/' })
       await ctx.plugin(ToolFs)
       await ctx.plugin(WorkspaceContext, { maxBytes: 65536 })
    diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts
    index 28669e88d0..68b1e5897d 100644
    --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts
    +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts
    @@ -1,15 +1,15 @@
     import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'
     import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
     import { tmpdir } from 'node:os'
    -import { describe, expect, it, vi } from 'vitest'
    +import { afterAll, describe, expect, it, vi } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
     import Loader from '@deepseek-ai/cordis-plugin-loader'
     import * as workspaceContext from '@deepseek-ai/dsh-agent-instructions'
     import LlmRuntime, { createUserMessage, ToolCallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
    -import SessionStore, { Session, SessionId, SessionSeq, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
    -import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent'
    +import SessionStore, { SessionId, SessionSeq, type SessionEvent, type SurfaceIntent, type UserMessage } from '@deepseek-ai/dsh-session'
    +import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
     import AgentLoop, { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
    +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
     import type {
       FsDirEntry,
    @@ -43,11 +43,27 @@ import {
     import { resolveConfig } from '../src/config.ts'
     import { candidateScopeKey, renderInstructionChanges, renderWorkspaceInstructionSet, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE } from '../src/render.ts'
     import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
    +import {
    +  mountAgentLoopTestDependencies,
    +  mountAgentLoopTestHarness,
    +} from '@deepseek-ai/dsh-agent-loop-testkit'
     
     /** Per-candidate reconciliation scope key: directory paired with the file name. */
     const sk = (directory: string, candidateName: string): string => candidateScopeKey(directory, candidateName)
     
     const testToolSignal = new AbortController().signal
    +const isolatedInboxCtx = new Context()
    +await mountAgentLoopTestDependencies(isolatedInboxCtx)
    +const isolatedAgentLoop = await mountAgentLoopTestHarness(isolatedInboxCtx)
    +let nextStubSession = 1
    +afterAll(() => isolatedInboxCtx.fiber.dispose())
    +
    +type TestAgent = Agent
    +
    +/** Admit one test Agent's pending input through the production loop driver. */
    +function claimInbox(agent: Agent, target: 'next-turn' | 'next-step'): UserMessage[] {
    +  return isolatedAgentLoop.claim(agent, target, 1)
    +}
     const requestTimeoutMs = process.platform === 'win32' ? 5_000 : 1_000
     
     async function tempRepo(): Promise {
    @@ -124,6 +140,10 @@ class RecordingFileSystem extends FileSystem {
         throw new Error('not needed in agent-instructions tests')
       }
     
    +  override async readByteRange(_target: FsTarget, _range: { offset: number; length: number }, _signal?: AbortSignal): Promise {
    +    throw new Error('not needed in agent-instructions tests')
    +  }
    +
       override async streamText(target: FsTarget, signal?: AbortSignal): Promise> {
         if (signal !== undefined) this.signals.push(signal)
         signal?.throwIfAborted()
    @@ -188,26 +208,30 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace
       return mountWorkspaceContextPlugin(ctx, config)
     }
     
    -function stubAgent(cwd?: string, seed: readonly SessionEvent[] = []): Agent {
    -  const id = SessionId('s1')
    -  const session = Session.create(id, seed, cwd === undefined
    -    ? undefined
    -    : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd, isSeeded: false })
    -  return {
    -    ctx: new Context(),
    -    id: SessionId('a1'),
    -    options: {},
    -    session,
    -    inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    -    status: 'idle',
    -    send: () => {},
    -    followup: () => {},
    -    steer: () => {},
    -    inject: () => { throw new Error('agent-instructions must append directly to the open step') },
    -    cancel() {},
    -    runMaintenance: task => task(new AbortController().signal),
    -    whenIdle: () => Promise.resolve(),
    +async function stubAgent(cwd?: string, seed: readonly SessionEvent[] = []): Promise {
    +  const id = SessionId(`agent-instructions-${String(nextStubSession++)}`)
    +  const agent = await isolatedAgentLoop.create(
    +    id,
    +    {},
    +    cwd === undefined ? {} : { cwd },
    +  )
    +  const append = agent.session.append.bind(agent.session) as unknown as (
    +    type: SessionEvent['type'],
    +    data: SessionEvent['data'],
    +    opts?: Partial,
    +  ) => SessionEvent
    +  for (const event of seed) {
    +    if ('surfaceOp' in event || 'sourceEventSeqs' in event) {
    +      append(event.type, event.data, {
    +        ...event.surfaceOp === undefined ? {} : { surfaceOp: event.surfaceOp },
    +        ...event.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: event.sourceEventSeqs },
    +      })
    +    } else {
    +      append(event.type, event.data)
    +    }
       }
    +  if (seed.at(-1)?.type !== 'session/end-seed') agent.session.append('session/end-seed', {})
    +  return agent
     }
     
     function stubToolExecution(
    @@ -255,10 +279,10 @@ function baselineEvents(agent: Agent): SessionEvent[] {
         && event.data.source.baseline === true)
     }
     
    -async function appendAdditionalContexts(ctx: Context, agent: Agent): Promise {
    +async function appendAdditionalContexts(ctx: Context, agent: TestAgent): Promise {
       await syncedWorkspaceContext(ctx, agent)
       let lastSeq: SessionSeq | undefined
    -  for (const claimed of agent.inbox.claim('next-step', 1)) {
    +  for (const claimed of claimInbox(agent, 'next-step')) {
         if (claimed.source.kind !== 'agent-instructions') continue
         const event = agent.session.append('user/message', claimed, { surfaceOp: 'append' })
         ctx.emit('session/event', agent.session, event)
    @@ -269,14 +293,14 @@ async function appendAdditionalContexts(ctx: Context, agent: Agent): Promise()
     
    -async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise {
    +async function composeBaselinePrefix(ctx: Context, agent: TestAgent): Promise {
       const signal = new AbortController().signal
       await agentEvents(ctx, agent).waterfall(
         'agent/pre-step',
         { messages: [], turn: 1, step: 1, signal },
         () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
       )
    -  const claimed = agent.inbox.claim('next-step', 1)
    +  const claimed = claimInbox(agent, 'next-step')
       const decision = await agentEvents(ctx, agent).waterfall(
         'agent/pre-step',
         { messages: claimed, turn: 1, step: 2, signal },
    @@ -492,7 +516,7 @@ describe('workspace context instruction discovery', () => {
           await symlink(join(outside, 'shared.md'), join(root, 'AGENTS.md'))
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -1019,7 +1043,7 @@ describe('workspace context request injection', () => {
           callId: ToolCallId('missing-turn-boundary'),
           name: 'read',
           arguments: { file_path: 'file.txt' },
    -      agent: stubAgent('/virtual/repo'),
    +      agent: await stubAgent('/virtual/repo'),
           signal: testToolSignal,
         })
     
    @@ -1036,7 +1060,7 @@ describe('workspace context request injection', () => {
         const ctx = new Context()
         try {
           await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 })
    -      const agent = stubAgent('/virtual/repo')
    +      const agent = await stubAgent('/virtual/repo')
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -1054,7 +1078,7 @@ describe('workspace context request injection', () => {
           await write(join(root, 'AGENTS.md'), 'repo rule')
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -1093,7 +1117,7 @@ describe('workspace context request injection', () => {
           await write(join(root, 'AGENTS.md'), 'repo rule')
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           const first = await composeBaselinePrefix(ctx, agent)
           const second = await composeBaselinePrefix(ctx, agent)
    @@ -1115,12 +1139,12 @@ describe('workspace context request injection', () => {
           await write(join(root, 'AGENTS.md'), 'repo rule')
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const original = stubAgent(root)
    +      const original = await stubAgent(root)
           await composeBaselinePrefix(ctx, original)
     
    -      const firstResume = stubAgent(root, original.session.snapshotEvents())
    +      const firstResume = await stubAgent(root, original.session.snapshotEvents())
           await composeBaselinePrefix(ctx, firstResume)
    -      const secondResume = stubAgent(root, firstResume.session.snapshotEvents())
    +      const secondResume = await stubAgent(root, firstResume.session.snapshotEvents())
           await composeBaselinePrefix(ctx, secondResume)
     
           expect(baselineEvents(firstResume)).toHaveLength(1)
    @@ -1143,11 +1167,11 @@ describe('workspace context request injection', () => {
           fs.entries.set(join(root, '.git'), { type: 'directory' })
           fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' })
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const original = stubAgent(root)
    +      const original = await stubAgent(root)
           await composeBaselinePrefix(ctx, original)
     
           fs.throwOnStat.add(join(root, 'AGENTS.md'))
    -      const resumed = stubAgent(root, original.session.snapshotEvents())
    +      const resumed = await stubAgent(root, original.session.snapshotEvents())
           await composeBaselinePrefix(ctx, resumed)
     
           expect(baselineEvents(resumed)).toHaveLength(1)
    @@ -1170,12 +1194,12 @@ describe('workspace context request injection', () => {
           await write(join(cwd, 'AGENTS.md'), 'package rule')
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 })
    -      const original = stubAgent(cwd)
    +      const original = await stubAgent(cwd)
           await composeBaselinePrefix(ctx, original)
     
    -      const firstResume = stubAgent(cwd, original.session.snapshotEvents())
    +      const firstResume = await stubAgent(cwd, original.session.snapshotEvents())
           await composeBaselinePrefix(ctx, firstResume)
    -      const secondResume = stubAgent(cwd, firstResume.session.snapshotEvents())
    +      const secondResume = await stubAgent(cwd, firstResume.session.snapshotEvents())
           await composeBaselinePrefix(ctx, secondResume)
     
           expect(baselineEvents(secondResume)).toHaveLength(1)
    @@ -1199,11 +1223,11 @@ describe('workspace context request injection', () => {
           await write(join(root, 'AGENTS.md'), 'root '.repeat(200))
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 })
    -      const original = stubAgent(cwd)
    +      const original = await stubAgent(cwd)
           await composeBaselinePrefix(ctx, original)
     
           await write(join(cwd, 'AGENTS.md'), 'package rule')
    -      const resumed = stubAgent(cwd, original.session.snapshotEvents())
    +      const resumed = await stubAgent(cwd, original.session.snapshotEvents())
           await composeBaselinePrefix(ctx, resumed)
     
           expect(baselineEvents(resumed)).toHaveLength(1)
    @@ -1232,7 +1256,7 @@ describe('workspace context request injection', () => {
           await write(join(root, 'AGENTS.md'), 'agents rule')
           await write(join(root, 'CLAUDE.md'), 'claude rule')
           await mountWorkspaceContext(originalCtx, { dshHome: home, maxBytes: 65536 })
    -      const original = stubAgent(root)
    +      const original = await stubAgent(root)
           await composeBaselinePrefix(originalCtx, original)
     
           await mountWorkspaceContext(resumedCtx, {
    @@ -1240,7 +1264,7 @@ describe('workspace context request injection', () => {
             maxBytes: 65536,
             instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'],
           })
    -      const resumed = stubAgent(root, original.session.snapshotEvents())
    +      const resumed = await stubAgent(root, original.session.snapshotEvents())
           await composeBaselinePrefix(resumedCtx, resumed)
     
           const baselines = baselineEvents(resumed)
    @@ -1259,7 +1283,7 @@ describe('workspace context request injection', () => {
             : [])
           expect(new Set(baselineIdentities).size).toBe(2)
     
    -      const repeated = stubAgent(root, resumed.session.snapshotEvents())
    +      const repeated = await stubAgent(root, resumed.session.snapshotEvents())
           await composeBaselinePrefix(resumedCtx, repeated)
           expect(baselineEvents(repeated)).toHaveLength(2)
         } finally {
    @@ -1285,7 +1309,7 @@ describe('workspace context request injection', () => {
             maxBytes: 65536,
             instructionFileCandidates: ['AGENTS.md'],
           })
    -      const original = stubAgent(root)
    +      const original = await stubAgent(root)
           await composeBaselinePrefix(agentsCtx, original)
     
           await mountWorkspaceContext(claudeCtx, {
    @@ -1293,7 +1317,7 @@ describe('workspace context request injection', () => {
             maxBytes: 65536,
             instructionFileCandidates: ['CLAUDE.md'],
           })
    -      const claudeResume = stubAgent(root, original.session.snapshotEvents())
    +      const claudeResume = await stubAgent(root, original.session.snapshotEvents())
           await composeBaselinePrefix(claudeCtx, claudeResume)
           const claudeBaseline = baselineEvents(claudeResume).at(-1)
           expect(claudeBaseline?.type === 'user/message' && claudeBaseline.data.source.kind === 'agent-instructions'
    @@ -1308,7 +1332,7 @@ describe('workspace context request injection', () => {
             maxBytes: 65536,
             instructionFileCandidates: ['AGENTS.md'],
           })
    -      const restored = stubAgent(root, claudeResume.session.snapshotEvents())
    +      const restored = await stubAgent(root, claudeResume.session.snapshotEvents())
           await composeBaselinePrefix(restoredCtx, restored)
           const restoredBaseline = baselineEvents(restored).at(-1)
           expect(restoredBaseline?.type === 'user/message' && restoredBaseline.data.source.kind === 'agent-instructions'
    @@ -1335,7 +1359,7 @@ describe('workspace context request injection', () => {
           await mkdir(join(root, '.git'), { recursive: true })
           await write(join(root, 'AGENTS.md'), 'agents rule')
           await mountWorkspaceContext(originalCtx, { dshHome: home, maxBytes: 65536 })
    -      const original = stubAgent(root)
    +      const original = await stubAgent(root)
           await composeBaselinePrefix(originalCtx, original)
     
           await mountWorkspaceContext(resumedCtx, {
    @@ -1343,7 +1367,7 @@ describe('workspace context request injection', () => {
             maxBytes: 65536,
             instructionFileCandidates: ['POLICY.md'],
           })
    -      const resumed = stubAgent(root, original.session.snapshotEvents())
    +      const resumed = await stubAgent(root, original.session.snapshotEvents())
           await composeBaselinePrefix(resumedCtx, resumed)
     
           const baselines = baselineEvents(resumed)
    @@ -1357,7 +1381,7 @@ describe('workspace context request injection', () => {
             { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' },
           ])
     
    -      const repeated = stubAgent(root, resumed.session.snapshotEvents())
    +      const repeated = await stubAgent(root, resumed.session.snapshotEvents())
           await composeBaselinePrefix(resumedCtx, repeated)
           expect(baselineEvents(repeated)).toHaveLength(2)
         } finally {
    @@ -1376,7 +1400,7 @@ describe('workspace context request injection', () => {
           await write(join(root, 'AGENTS.md'), 'repo rule')
           const ctx = new Context()
           const fiber = await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const original = stubAgent(root)
    +      const original = await stubAgent(root)
           await agentEvents(ctx, original).waterfall(
             'agent/pre-step',
             { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
    @@ -1387,9 +1411,9 @@ describe('workspace context request injection', () => {
     
           await fiber.dispose()
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const resumed = stubAgent(root, original.session.snapshotEvents())
    +      const resumed = await stubAgent(root, original.session.snapshotEvents())
           agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
    -      const claimed = resumed.inbox.claim('next-step', 1)
    +      const claimed = claimInbox(resumed, 'next-step')
           const decision = await agentEvents(ctx, resumed).waterfall(
             'agent/pre-step',
             { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
    @@ -1421,7 +1445,7 @@ describe('workspace context request injection', () => {
           await write(join(root, 'AGENTS.md'), 'old repo rule')
           const ctx = new Context()
           const fiber = await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const original = stubAgent(root)
    +      const original = await stubAgent(root)
           await agentEvents(ctx, original).waterfall(
             'agent/pre-step',
             { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
    @@ -1433,9 +1457,9 @@ describe('workspace context request injection', () => {
           await write(join(root, 'AGENTS.md'), 'new repo rule')
           await fiber.dispose()
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const resumed = stubAgent(root, original.session.snapshotEvents())
    +      const resumed = await stubAgent(root, original.session.snapshotEvents())
           agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
    -      const staleClaim = resumed.inbox.claim('next-step', 1)
    +      const staleClaim = claimInbox(resumed, 'next-step')
           const staleDecision = await agentEvents(ctx, resumed).waterfall(
             'agent/pre-step',
             { messages: staleClaim, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
    @@ -1474,7 +1498,7 @@ describe('workspace context request injection', () => {
           await mkdir(join(root, '.git'), { recursive: true })
           await write(join(root, 'AGENTS.md'), 'repo rule')
           await mountWorkspaceContext(originalCtx, { dshHome: home, maxBytes: 65536 })
    -      const original = stubAgent(root)
    +      const original = await stubAgent(root)
           await agentEvents(originalCtx, original).waterfall(
             'agent/pre-step',
             { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
    @@ -1486,9 +1510,9 @@ describe('workspace context request injection', () => {
           await originalCtx.fiber.dispose()
           if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' })
           await mountWorkspaceContextPlugin(resumedCtx, { dshHome: home, maxBytes })
    -      const resumed = stubAgent(root, original.session.snapshotEvents())
    +      const resumed = await stubAgent(root, original.session.snapshotEvents())
           agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' })
    -      const claimed = resumed.inbox.claim('next-step', 1)
    +      const claimed = claimInbox(resumed, 'next-step')
           const decision = await agentEvents(resumedCtx, resumed).waterfall(
             'agent/pre-step',
             { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
    @@ -1513,7 +1537,7 @@ describe('workspace context request injection', () => {
           await mkdir(join(root, '.git'), { recursive: true })
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           agent.session.append('user/message', createUserMessage({
             content: [{ type: 'text', text: 'stale nested instructions' }],
             source: {
    @@ -1547,7 +1571,7 @@ describe('workspace context request injection', () => {
           await write(join(root, 'AGENTS.md'), 'repo rule')
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           agent.session.append('user/message', createUserMessage({
             content: [{ type: 'text', text: 'stale nested instructions' }],
             source: {
    @@ -1586,7 +1610,7 @@ describe('workspace context request injection', () => {
           await write(join(root, 'AGENTS.md'), 'repo rule')
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           const prompt = createUserMessage({
             content: [{ type: 'text', text: 'current prompt' }],
             source: { kind: 'user' },
    @@ -1621,7 +1645,7 @@ describe('workspace context request injection', () => {
           await write(join(home, 'AGENTS.md'), 'global rule')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           await composeBaselinePrefix(ctx, agent)
     
           await write(join(home, 'AGENTS.md'), 'updated global rule')
    @@ -1647,7 +1671,7 @@ describe('workspace context request injection', () => {
           await write(join(root, 'AGENTS.md'), 'repo rule')
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           const downstream = { kind: 'reject' as const }
     
           const decision = await agentEvents(ctx, agent).waterfall(
    @@ -1674,7 +1698,7 @@ describe('workspace context request injection', () => {
           await write(join(root, 'file.txt'), 'hello')
           const ctx = new Context()
           const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           await composeBaselinePrefix(ctx, agent)
     
           // Hot remount over the live session: the durable baseline remains
    @@ -1711,7 +1735,7 @@ describe('workspace context request injection', () => {
           const ctx = new Context()
           await ctx.plugin(LocalFileSystem, { cwd: '/' })
           const fiber = await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           await composeBaselinePrefix(ctx, agent)
           const baseline = baselineEvents(agent)[0]
           expect(baseline).toBeDefined()
    @@ -1744,7 +1768,7 @@ describe('workspace context request injection', () => {
           await write(join(root, 'AGENTS.md'), 'first post-compaction request rule')
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           await composeBaselinePrefix(ctx, agent)
           const baseline = baselineEvents(agent)[0]
           expect(baseline).toBeDefined()
    @@ -1787,13 +1811,13 @@ describe('workspace context request injection', () => {
           await write(join(root, 'AGENTS.md'), 'old root rule')
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const original = stubAgent(root)
    +      const original = await stubAgent(root)
           await composeBaselinePrefix(ctx, original)
     
           // The first resumed pre-step retains the compatible visible baseline and
           // appends only the offline file transition needed to reach current state.
           await write(join(root, 'AGENTS.md'), 'new root rule after offline edit')
    -      const resumed = stubAgent(root, original.session.snapshotEvents())
    +      const resumed = await stubAgent(root, original.session.snapshotEvents())
     
           // Resume announces its lifecycle start before the first step.
           agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
    @@ -1827,7 +1851,7 @@ describe('workspace context request injection', () => {
           await write(join(cwd, 'AGENTS.md'), 'package rule')
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 })
    -      const agent = stubAgent(cwd)
    +      const agent = await stubAgent(cwd)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -1859,7 +1883,7 @@ describe('workspace context request injection', () => {
             }
           })
     
    -      const prefix = await composeBaselinePrefix(ctx, stubAgent(root))
    +      const prefix = await composeBaselinePrefix(ctx, await stubAgent(root))
     
           expect(prefix).toHaveLength(2)
           expect(blocksText(prefix[0]?.content)).toContain('Instructions from: AGENTS.md')
    @@ -1879,7 +1903,7 @@ describe('workspace context request injection', () => {
           await write(join(root, 'file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
           await write(join(root, 'AGENTS.md'), 'new root rule with more detail')
    @@ -1908,7 +1932,7 @@ describe('workspace context request injection', () => {
           await write(join(root, 'file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
           await rm(join(root, 'AGENTS.md'))
    @@ -1934,7 +1958,7 @@ describe('workspace context request injection', () => {
           await write(join(root, 'AGENTS.md'), 'shared root and global rule')
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: root, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -1954,7 +1978,7 @@ describe('workspace context request injection', () => {
           await write(join(root, 'file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -1978,7 +2002,7 @@ describe('workspace context request injection', () => {
           await write(join(root, 'AGENTS.md'), 'x'.repeat(1000))
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -2013,7 +2037,7 @@ describe('workspace context request injection', () => {
           fs.entries.set(join(root, '.git'), { type: 'directory' })
           fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'ctx.fs rule' })
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -2036,7 +2060,7 @@ describe('workspace context request injection', () => {
           fs.entries.set(join(root, '.git'), { type: 'directory' })
           fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'provider-only rule' })
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -2079,7 +2103,7 @@ describe('workspace context request injection', () => {
           fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'far too large' })
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 })
     
    -      const prefix = await composeBaselinePrefix(ctx, stubAgent(root))
    +      const prefix = await composeBaselinePrefix(ctx, await stubAgent(root))
     
           expect(prefix).toEqual([])
           expect(fs.readTargets).toEqual([])
    @@ -2104,7 +2128,7 @@ describe('workspace context request injection', () => {
           fs.omitSizes.add(instructionPath)
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 })
     
    -      const prefix = await composeBaselinePrefix(ctx, stubAgent(root))
    +      const prefix = await composeBaselinePrefix(ctx, await stubAgent(root))
     
           expect(prefix).toEqual([])
           expect(fs.readTargets).toEqual([instructionPath, instructionPath])
    @@ -2128,7 +2152,7 @@ describe('workspace context request injection', () => {
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
           const controller = new AbortController()
           const reason = new Error('cancel prefix')
    -      const pending = agentEvents(ctx, stubAgent(root)).waterfall(
    +      const pending = agentEvents(ctx, await stubAgent(root)).waterfall(
             'agent/pre-step',
             { messages: [], turn: 1, step: 1, signal: controller.signal },
             () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
    @@ -2160,7 +2184,7 @@ describe('workspace context request injection', () => {
           fs.entries.set(join(home, 'AGENTS.md'), { type: 'file', content: 'ctx global rule' })
           fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'ctx claude rule' })
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -2186,7 +2210,7 @@ describe('workspace context request injection', () => {
           fs.entries.set(join(root, '.git'), { type: 'directory' })
           fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' })
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -2209,7 +2233,7 @@ describe('workspace context request injection', () => {
           fs.entries.set(join(root, '.git'), { type: 'directory' })
           fs.entries.set(join(root, 'AGENTS.md'), { type: 'file' })
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -2232,7 +2256,7 @@ describe('workspace context request injection', () => {
           fs.entries.set(join(root, '.git'), { type: 'directory' })
           fs.throwOnStat.add(join(root, 'AGENTS.md'))
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -2254,7 +2278,7 @@ describe('workspace context request injection', () => {
           fs.throwOnStat.add(join(root, 'AGENTS.md'))
           fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'claude sibling rule' })
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -2279,7 +2303,7 @@ describe('workspace context request injection', () => {
           fs.throwOnStat.add(join(root, '.git'))
           fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' })
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -2301,8 +2325,8 @@ describe('workspace context request injection', () => {
           await write(join(repoB, 'AGENTS.md'), 'repo B only')
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agentA = stubAgent(repoA)
    -      const agentB = stubAgent(repoB)
    +      const agentA = await stubAgent(repoA)
    +      const agentB = await stubAgent(repoB)
     
           await composeBaselinePrefix(ctx, agentA)
           await composeBaselinePrefix(ctx, agentB)
    @@ -2329,7 +2353,7 @@ describe('workspace context request injection', () => {
           const ctx = new Context()
           await ctx.plugin(LocalFileSystem, { cwd: '/' })
           await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 })
    -      const agent = stubAgent(cwd)
    +      const agent = await stubAgent(cwd)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -2350,7 +2374,7 @@ describe('workspace context request injection', () => {
           const ctx = new Context()
           await ctx.plugin(LocalFileSystem, { cwd: '/' })
           await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -2371,7 +2395,7 @@ describe('workspace context request injection', () => {
           const ctx = new Context()
           const fiber = await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
           await fiber.dispose()
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -2390,7 +2414,7 @@ describe('workspace context request injection', () => {
           await write(join(root, 'AGENTS.md'), 'repo rule')
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 0 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -2409,7 +2433,7 @@ describe('workspace context request injection', () => {
           await write(join(root, 'AGENTS.md'), 'repo rule')
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: -1 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -2427,7 +2451,7 @@ describe('workspace context request injection', () => {
           await mkdir(join(root, '.git'), { recursive: true })
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await composeBaselinePrefix(ctx, agent)
     
    @@ -2540,6 +2564,7 @@ describe('dynamic nested workspace context injection', () => {
           ])
           await ctx.plugin(LlmRuntime)
           await ctx.plugin(SessionStore)
    +      await ctx.plugin(SessionProjectionRegistry)
           await ctx.plugin(SystemPrompt)
           await ctx.plugin(ToolRuntime)
           await ctx.plugin(AgentRegistry)
    @@ -2596,8 +2621,8 @@ describe('dynamic nested workspace context injection', () => {
         expect(state.versions).toEqual(new Map())
       })
     
    -  it('creates and releases version-cache state only for non-empty updates', () => {
    -    const agent = stubAgent('/repo')
    +  it('creates and releases version-cache state only for non-empty updates', async () => {
    +    const agent = await stubAgent('/repo')
         const cache: InstructionVersionCache = new WeakMap()
         const change = { action: 'set' as const, scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md', digest: 'digest' }
         applyInstructionVersionUpdates(agent.session, [], cache)
    @@ -2629,7 +2654,7 @@ describe('dynamic nested workspace context injection', () => {
             callId: ToolCallId('cancelled-dynamic-read'),
             name: 'read',
             arguments: { file_path: join('pkg', 'file.txt') },
    -        agent: stubAgent(root),
    +        agent: await stubAgent(root),
             signal: controller.signal,
           })
     
    @@ -2659,7 +2684,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/deep/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           const result = await ctx.tools.execute({
             signal: testToolSignal,
    @@ -2704,7 +2729,7 @@ describe('dynamic nested workspace context injection', () => {
           await mkdir(join(root, '.git'), { recursive: true })
           await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           const controller = new AbortController()
     
           ctx.emit('tools/result', stubToolExecution({
    @@ -2738,7 +2763,7 @@ describe('dynamic nested workspace context injection', () => {
             maxBytes: 65536,
             instructionFileCandidates: ['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md'],
           })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await ctx.tools.execute({
             signal: testToolSignal,
    @@ -2771,7 +2796,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/deep/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await ctx.tools.execute({
             signal: testToolSignal,
    @@ -2814,7 +2839,7 @@ describe('dynamic nested workspace context injection', () => {
             maxBytes: 65536,
             localInstructionFileCandidates: [],
           })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await ctx.tools.execute({
             signal: testToolSignal,
    @@ -2842,7 +2867,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/deep/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           const first = await ctx.tools.execute({
             signal: testToolSignal,
    @@ -2885,7 +2910,7 @@ describe('dynamic nested workspace context injection', () => {
           fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
           await ctx.plugin(ToolFs)
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           const first = await ctx.tools.execute({
             signal: testToolSignal,
    @@ -2922,7 +2947,7 @@ describe('dynamic nested workspace context injection', () => {
           fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
           await ctx.plugin(ToolFs)
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await ctx.tools.execute({
             signal: testToolSignal,
    @@ -2968,8 +2993,8 @@ describe('dynamic nested workspace context injection', () => {
           await ctx.plugin(ToolFs)
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
     
    -      const firstAgent = stubAgent(root)
    -      const secondAgent = stubAgent(root)
    +      const firstAgent = await stubAgent(root)
    +      const secondAgent = await stubAgent(root)
           const first = await ctx.tools.execute({
             signal: testToolSignal,
             callId: ToolCallId('read-from-first-session'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: firstAgent,
    @@ -3000,7 +3025,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await ctx.tools.execute({
             signal: testToolSignal,
    @@ -3043,7 +3068,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await ctx.tools.execute({
             signal: testToolSignal,
    @@ -3081,7 +3106,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/deep/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await ctx.tools.execute({
             signal: testToolSignal,
    @@ -3113,7 +3138,7 @@ describe('dynamic nested workspace context injection', () => {
             fs.entries.set(join(root, '.git'), { type: 'directory' })
             fs.entries.set(join(root, 'pkg/CLAUDE.md'), { type: 'file', content: 'nested rule' })
             fs.throwOnStat.add(join(root, 'pkg/AGENTS.md'))
    -        const agent = stubAgent(root)
    +        const agent = await stubAgent(root)
             const agentsScope = sk('pkg', 'AGENTS.md')
             const loaded = baselineInstructionState([{
               absolutePath: join(root, 'pkg/AGENTS.md'),
    @@ -3183,7 +3208,7 @@ describe('dynamic nested workspace context injection', () => {
           const fs = ctx.fs as RecordingFileSystem
           fs.entries.set(join(root, '.git'), { type: 'directory' })
           fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           const rootScope = sk('.', 'AGENTS.md')
           const loaded = baselineInstructionState([{
             absolutePath: join(root, 'AGENTS.md'),
    @@ -3228,7 +3253,7 @@ describe('dynamic nested workspace context injection', () => {
           const fs = ctx.fs as RecordingFileSystem
           fs.entries.set(join(root, '.git'), { type: 'directory' })
           fs.throwOnStat.add(join(root, 'pkg/AGENTS.md'))
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           agent.session.append('user/message', createUserMessage({
             content: [{ type: 'text', text: 'removed nested instructions' }],
             source: {
    @@ -3268,7 +3293,7 @@ describe('dynamic nested workspace context injection', () => {
           const fs = ctx.fs as RecordingFileSystem
           fs.entries.set(join(root, '.git'), { type: 'directory' })
           fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'shared rule' })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           const resolved = resolveConfig({
             dshHome: root,
             maxBytes: 65536,
    @@ -3303,7 +3328,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await ctx.tools.execute({
             signal: testToolSignal,
    @@ -3340,7 +3365,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await ctx.tools.execute({
             signal: testToolSignal,
    @@ -3378,7 +3403,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await ctx.tools.execute({
             signal: testToolSignal,
    @@ -3418,7 +3443,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await ctx.tools.execute({
             signal: testToolSignal,
    @@ -3458,7 +3483,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           await ctx.tools.execute({
             signal: testToolSignal,
    @@ -3503,7 +3528,7 @@ describe('dynamic nested workspace context injection', () => {
           fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
           await ctx.plugin(ToolFs)
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           const first = await ctx.tools.execute({
             signal: testToolSignal,
    @@ -3534,7 +3559,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/deep/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           const first = await ctx.tools.execute({
             signal: testToolSignal,
             callId: ToolCallId('read-before-resume'),
    @@ -3543,7 +3568,7 @@ describe('dynamic nested workspace context injection', () => {
             agent,
           })
           await appendAdditionalContexts(ctx, agent)
    -      const resumed = stubAgent(root, agent.session.snapshotEvents())
    +      const resumed = await stubAgent(root, agent.session.snapshotEvents())
     
           const afterResume = await ctx.tools.execute({
             signal: testToolSignal,
    @@ -3570,14 +3595,14 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const original = stubAgent(root)
    +      const original = await stubAgent(root)
           await ctx.tools.execute({
             signal: testToolSignal,
             callId: ToolCallId('read-before-offline-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: original,
           })
           await appendAdditionalContexts(ctx, original)
           await write(join(root, 'pkg/AGENTS.md'), 'new nested rule after resume')
    -      const resumed = stubAgent(root, original.session.snapshotEvents())
    +      const resumed = await stubAgent(root, original.session.snapshotEvents())
     
           await composeBaselinePrefix(ctx, resumed)
     
    @@ -3601,7 +3626,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/deep/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           const first = await ctx.tools.execute({
             signal: testToolSignal,
             callId: ToolCallId('read-before-compact'),
    @@ -3653,7 +3678,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           await composeBaselinePrefix(ctx, agent)
           const baseline = baselineEvents(agent)[0]
           expect(baseline).toBeDefined()
    @@ -3713,7 +3738,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/sub/file.txt'), 'subtree file')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           await ctx.tools.execute({
             signal: testToolSignal,
             callId: ToolCallId('read-package'),
    @@ -3751,7 +3776,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/sub/file.txt'), 'subtree file')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           await ctx.tools.execute({
             signal: testToolSignal,
             callId: ToolCallId('read-subtree-omitting-parent'),
    @@ -3788,7 +3813,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/deep/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           agent.session.append('user/message', createUserMessage({
             content: [
               { type: 'reasoning', text: 'Additional instructions from: pkg/AGENTS.md' },
    @@ -3839,7 +3864,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/deep/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           const rootResult = await ctx.tools.execute({
             signal: testToolSignal,
    @@ -3882,7 +3907,7 @@ describe('dynamic nested workspace context injection', () => {
           fs.throwOnRead.add(nested)
           await ctx.plugin(ToolFs)
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           const result = await ctx.tools.execute({
             signal: testToolSignal,
    @@ -3914,7 +3939,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/deep/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           ctx.on('tools/post-execute', async () => ({
             kind: 'accept' as const,
             value: {
    @@ -3976,7 +4001,7 @@ describe('dynamic nested workspace context injection', () => {
           await write(join(root, 'pkg/deep/file.txt'), 'hello')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           ctx.on('tools/post-execute', async () => ({
             kind: 'block' as const,
             feedback: [{ type: 'text' as const, text: 'blocked downstream' }],
    @@ -4023,7 +4048,7 @@ describe('dynamic nested workspace context injection', () => {
               : downstream
           })
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           const blocked = await ctx.tools.execute({
             signal: testToolSignal,
    @@ -4092,7 +4117,7 @@ describe('dynamic nested workspace context injection', () => {
               : downstream
           })
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           const blocked = await ctx.tools.execute({
             signal: testToolSignal,
    @@ -4119,7 +4144,7 @@ describe('dynamic nested workspace context injection', () => {
           const fs = ctx.fs as RecordingFileSystem
           fs.entries.set(join(root, '.git'), { type: 'directory' })
           fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           const turnStart = agent.session.append('turn/start', { turn: 1 })
           ctx.emit('session/event', agent.session, turnStart)
           const stepStart = agent.session.append('step/start', { turn: 1, step: 1 })
    @@ -4185,7 +4210,7 @@ describe('dynamic nested workspace context injection', () => {
           const fs = ctx.fs as RecordingFileSystem
           fs.entries.set(join(root, '.git'), { type: 'directory' })
           fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           agent.session.append('turn/start', { turn: 1 })
           agent.session.append('step/start', { turn: 1, step: 1 })
           agent.session.append('step/end', { turn: 1, step: 1 })
    @@ -4215,7 +4240,7 @@ describe('dynamic nested workspace context injection', () => {
           await ctx.plugin(RecordingFileSystem)
           await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 })
           const fs = ctx.fs as RecordingFileSystem
    -      const agent = stubAgent('/')
    +      const agent = await stubAgent('/')
           const plainResult = { callId: ToolCallId('plain'), content: [], isError: false as const, value: null }
           const aborted = new AbortController()
           aborted.abort(new Error('cancelled'))
    @@ -4265,7 +4290,7 @@ describe('dynamic nested workspace context injection', () => {
           await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 })
           const fs = ctx.fs as RecordingFileSystem
           const root = resolve('/')
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           const failure = new Error('projection failed')
           const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
           fs.entries.set(join(root, '.git'), { type: 'directory' })
    @@ -4303,7 +4328,7 @@ describe('dynamic nested workspace context injection', () => {
             callId: ToolCallId('read-with-disabled-budget'),
             name: 'read',
             arguments: { file_path: join('pkg', 'deep', 'file.txt') },
    -        agent: stubAgent(root),
    +        agent: await stubAgent(root),
           })
     
           expect(result.isError).toBe(false)
    @@ -4329,7 +4354,7 @@ describe('dynamic nested workspace context injection', () => {
           fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
           await ctx.plugin(ToolFs)
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 20 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           const first = await ctx.tools.execute({
             signal: testToolSignal,
    @@ -4369,7 +4394,7 @@ describe('dynamic nested workspace context injection', () => {
             callId: ToolCallId('read-missing'),
             name: 'read',
             arguments: { file_path: join('pkg', 'missing.txt') },
    -        agent: stubAgent(root),
    +        agent: await stubAgent(root),
           })
     
           expect(result.isError).toBe(true)
    @@ -4390,7 +4415,7 @@ describe('dynamic nested workspace context injection', () => {
           const ctx = new Context()
           const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
           await fiber.dispose()
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
     
           const result = await ctx.tools.execute({
             signal: testToolSignal,
    @@ -4426,7 +4451,7 @@ describe('workspace context inbox synchronization', () => {
           await write(join(root, 'AGENTS.md'), 'duplicate baseline')
           const ctx = new Context()
           await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           await syncWorkspaceContext(ctx, agent)
           const desired = agent.inbox.nextStep[0]!
           agent.inbox.append('next-step', createUserMessage({ content: desired.content, source: desired.source }))
    @@ -4451,7 +4476,7 @@ describe('workspace context inbox synchronization', () => {
           fs.entries.set(join(root, '.git'), { type: 'directory' })
           fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'tiny-budget rule' })
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 1 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           ctx.emit('tools/result', stubToolExecution({
             signal: testToolSignal,
             callId: ToolCallId('tiny-budget-touch'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
    @@ -4479,7 +4504,7 @@ describe('workspace context inbox synchronization', () => {
           await write(join(root, 'pkg/file.txt'), 'file')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           await ctx.tools.execute({
             signal: testToolSignal,
             callId: ToolCallId('pending-v1'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
    @@ -4527,7 +4552,7 @@ describe('workspace context inbox synchronization', () => {
           fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'restored A' })
           fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'restored B' })
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           const first = stubToolExecution({
             signal: testToolSignal,
             callId: ToolCallId('projected-before-abort'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent,
    @@ -4567,7 +4592,7 @@ describe('workspace context inbox synchronization', () => {
           fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'scope A' })
           fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'scope B' })
           await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(root)
    +      const agent = await stubAgent(root)
           const first = stubToolExecution({
             signal: testToolSignal,
             callId: ToolCallId('concurrent-a'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent,
    @@ -4605,13 +4630,13 @@ describe('workspace context inbox synchronization', () => {
           await write(join(root, 'b/file.txt'), 'b')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const original = stubAgent(root)
    +      const original = await stubAgent(root)
           await ctx.tools.execute({
             signal: testToolSignal,
             callId: ToolCallId('recover-pending-a'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent: original,
           })
           await syncWorkspaceContext(ctx, original)
    -      const resumed = stubAgent(root, original.session.snapshotEvents())
    +      const resumed = await stubAgent(root, original.session.snapshotEvents())
     
           await ctx.tools.execute({
             signal: testToolSignal,
    @@ -4640,9 +4665,9 @@ describe('workspace context inbox synchronization', () => {
           await write(join(root, 'pkg/file.txt'), 'file')
           const ctx = new Context()
           await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
    -      const agent = stubAgent(join(root, 'pkg'))
    +      const agent = await stubAgent(join(root, 'pkg'))
           await syncedWorkspaceContext(ctx, agent)
    -      const claimed = agent.inbox.claim('next-step', 1)
    +      const claimed = claimInbox(agent, 'next-step')
           await write(join(root, 'pkg/AGENTS.md'), 'new claimed rule with more detail')
           const downstream = { kind: 'enter' as const, messages: claimed }
     
    diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json
    index 1eb8175cf0..f060782c3f 100644
    --- a/packages/context/file-reference-local/package.json
    +++ b/packages/context/file-reference-local/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-file-reference-local",
       "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json
    index 1dbfe31d78..9c387dfc1c 100644
    --- a/packages/context/file-reference/package.json
    +++ b/packages/context/file-reference/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-file-reference",
       "description": "File-reference discovery contract and shared @file grammar",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json
    index bb8c47fcd4..15e8af62b3 100644
    --- a/packages/context/session-reference/package.json
    +++ b/packages/context/session-reference/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-reference",
       "description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferenceResolver)",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json
    index 13c94aaee1..a7deec5f2c 100644
    --- a/packages/context/time-context/package.json
    +++ b/packages/context/time-context/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-time-context",
       "description": "Opt-in durable per-step context with the current time and elapsed time",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts
    index 63d1bcb2b0..5d47095e5b 100644
    --- a/packages/context/time-context/tests/time-context.spec.ts
    +++ b/packages/context/time-context/tests/time-context.spec.ts
    @@ -4,11 +4,11 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
     import { createUserMessage, ToolCallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
     import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
     import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
    -import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
     import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
     import AgentLoop from '@deepseek-ai/dsh-agent-loop'
     import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
    -import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
    +import { unsupportedInbox, mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
     import * as timeContext from '@deepseek-ai/dsh-time-context'
     import type { Config } from '@deepseek-ai/dsh-time-context'
     
    @@ -38,11 +38,11 @@ async function mount(config: Config = {}) {
     }
     
     function sessionAgent(session: Session, id = 'agent'): Agent {
    -  return {
    +  const agent: Agent = {
         id: SessionId(id),
         options: {},
         session,
    -    inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +    inbox: unsupportedInbox(),
         status: 'running',
         ctx: new Context(),
         send: () => {},
    @@ -53,6 +53,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
         runMaintenance: task => task(new AbortController().signal),
         whenIdle: () => Promise.resolve(),
       }
    +  return agent
     }
     
     function openMessageTurn(session: Session, turn: number, clientTimeZone?: string): void {
    @@ -139,7 +140,6 @@ class ScriptedAdapter extends LlmAdapter {
     async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promise {
       const ctx = new Context()
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(AgentLoop, { agents: [] })
       await ctx.plugin(timeContext, config)
       ctx.llm.registerAdapter(['mock'], adapter)
    diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json
    index fa7f040bf4..43d6c7e956 100644
    --- a/packages/context/tmux-context/package.json
    +++ b/packages/context/tmux-context/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tmux-context",
       "description": "Opt-in durable per-step context with this agent's tmux pane and window location",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -39,6 +39,7 @@
       },
       "devDependencies": {
         "@deepseek-ai/dsh-agent": "workspace:^",
    +    "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
         "@deepseek-ai/dsh-shell": "workspace:^",
         "@deepseek-ai/dsh-llm": "workspace:^",
         "@deepseek-ai/dsh-session": "workspace:^",
    diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts
    index cc1c4985d3..bc9f9f0ed5 100644
    --- a/packages/context/tmux-context/tests/tmux-context.spec.ts
    +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts
    @@ -1,13 +1,14 @@
     import { afterEach, describe, expect, it, vi } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
     import { Session, SessionId } from '@deepseek-ai/dsh-session'
    -import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
     import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import { createUserMessage } from '@deepseek-ai/dsh-llm'
     import { ShellExecutor } from '@deepseek-ai/dsh-shell'
     import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
     import * as tmuxContext from '@deepseek-ai/dsh-tmux-context'
     import type { Config } from '@deepseek-ai/dsh-tmux-context'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     const SIGNAL = new AbortController().signal
     
    @@ -94,11 +95,11 @@ async function mount(
     }
     
     function sessionAgent(session: Session, id = 'agent'): Agent {
    -  return {
    +  const agent: Agent = {
         id: SessionId(id),
         options: {},
         session,
    -    inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +    inbox: unsupportedInbox(),
         status: 'running',
         ctx: new Context(),
         send: () => {},
    @@ -109,6 +110,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
         runMaintenance: task => task(new AbortController().signal),
         whenIdle: () => Promise.resolve(),
       }
    +  return agent
     }
     
     function openMessageTurn(session: Session, turn: number): void {
    diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json
    index a6579bb5ce..cf610b4769 100644
    --- a/packages/core/agent-default-model/package.json
    +++ b/packages/core/agent-default-model/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-agent-default-model",
       "description": "Default model selection shared by Agent entry points",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml
    index b1cc6893b2..6cda32c516 100644
    --- a/packages/core/agent-loop/README.i18n.yaml
    +++ b/packages/core/agent-loop/README.i18n.yaml
    @@ -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/core/agent-loop/README.md
    -README.md: 55966a4b0eed0c1cc3484314e809de79341072de
    -README.zh.md: 1d27bf3743f54d8fe66a58e75565fc85d94ae1ed
    +README.md: b24151c8fb512b144b15c017b662b0b2a57532fc
    +README.zh.md: d4c3b511426ce06c955d36d3f0f3852fe459ce0b
    diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md
    index 55966a4b0e..b24151c8fb 100644
    --- a/packages/core/agent-loop/README.md
    +++ b/packages/core/agent-loop/README.md
    @@ -68,6 +68,8 @@ const handle = await ctx.agents.create({
     })
     ```
     
    +Every inbox mutation commits one normalized `agent/inbox/spliced` event. The projection registry folds that event synchronously, so the live projection reflects the splice when `Session.append()` returns. Insertions, edits, removals, claiming, and cancellation replay through the same standard splice coordinates. Ordinary removals carry `outcome: 'canceled'` and emit `agent/inbox/discarded { message }`; claiming uses pure deletions with no outcome and emits `agent/inbox/claimed`. Every insertion emits `agent/inbox/inserted { message }`. `MessageId` stays unique across both pending lists. Consumers that need a removed message use the claimed or discarded notification instead of depending on a pre-splice `session/event` view.
    +
     ### What a step does
     
     Each step sends the agent's rendered system prompt, its visible tool schemas, and the session's derived history; the model's tool calls run through the guarded tool pipeline and every accepted fact is appended to the session log before the next step derives from it. Parallel-safe calls may overlap up to `maxParallelToolCalls`; exclusive calls run alone as ordering barriers. Cancellation is cooperative: `agent.cancel()` aborts the current activity and, unless `keepInbox` is set, clears pending work; a cancelled stream finalizes the text already delivered to the user.
    @@ -98,6 +100,7 @@ The loop deep-freezes each derived message identity on its first request and reu
     |---|---|
     | [`src/index.ts`](src/index.ts) | Plugin entry: `AgentLoop` service, config schema, declarative agent startup, factory registration |
     | [`src/agent.ts`](src/agent.ts) | The concrete `ReactLoopAgent` driver: inbox, turn/step machine, cancellation |
    +| [`src/inbox.ts`](src/inbox.ts) | Package-internal `ReactLoopInbox`: durable projection, structural commands, and loop-only claim state |
     | [`src/tool-calls.ts`](src/tool-calls.ts) | Tool scheduling: exclusive barriers and the bounded parallel pool |
     | [`src/runtime-context.ts`](src/runtime-context.ts) | Per-step runtime-context snapshot handling |
     | [`src/constants.ts`](src/constants.ts) | `DEFAULT_MAX_PARALLEL_TOOL_CALLS` |
    @@ -113,7 +116,7 @@ The loop is the production acquisition point for session write handles. When `ct
     
     ### Turn and step flow
     
    -The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. `agent/pre-step` decides what enters the step. An entered decision appends its complete `user/message` batch before the driver can claim again, while a rejected decision appends none. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable `assistant/chunk`, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor citing its chunk seqs, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results, and result context remain model-ordered.
    +The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. Its package-internal `ReactLoopInbox` constructor registers the standard `inbox` projection on the agent scope, then uses that projection for structural commands and loop-only claims. Registry reference counting keeps the shared key active until the last agent scope unloads. At a turn boundary the driver opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. `agent/pre-step` decides what enters the step. An entered decision appends its complete `user/message` batch before the driver can claim again, while a rejected decision appends none. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable `assistant/chunk`, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor citing its chunk seqs, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results, and result context remain model-ordered.
     
     ### Failure and cancellation
     
    diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md
    index 1d27bf3743..d4c3b51142 100644
    --- a/packages/core/agent-loop/README.zh.md
    +++ b/packages/core/agent-loop/README.zh.md
    @@ -68,6 +68,8 @@ const handle = await ctx.agents.create({
     })
     ```
     
    +每次 inbox 变更都会提交一条规范化的 `agent/inbox/spliced` 事件。投影注册表会同步折叠该事件,因此 `Session.append()` 返回时,实时投影已经反映该 splice。插入、编辑、移除、领取与取消都通过同一组标准 splice 坐标回放。普通删除携带 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`;领取使用不带 outcome 的纯删除,并发出 `agent/inbox/claimed`。每次插入都会发出 `agent/inbox/inserted { message }`。`MessageId` 在两个待处理列表之间保持唯一。需要被移除消息的消费方应使用 claimed 或 discarded 通知,而不依赖 splice 前的 `session/event` 投影视图。
    +
     ### 一个步骤做什么
     
     每个步骤都会发送该 agent 渲染后的系统提示词、其可见工具 schema 与会话的派生历史;模型的工具调用经过受守卫的工具流水线,每个被接纳的事实都会在下一步据此派生之前追加到会话日志。并行安全调用最多可重叠 `maxParallelToolCalls` 个;独占调用单独运行并构成排序屏障。取消是协作式的:`agent.cancel()` 中止当前活动,并在未设置 `keepInbox` 时清除待处理工作;被取消的流会终结已送达用户的文本。
    @@ -98,6 +100,7 @@ const handle = await ctx.agents.create({
     |---|---|
     | [`src/index.ts`](src/index.ts) | 插件入口:`AgentLoop` 服务、配置 schema、声明式 agent 启动、工厂注册 |
     | [`src/agent.ts`](src/agent.ts) | 具体 `ReactLoopAgent` 驱动器:收件箱、轮次/步骤状态机、取消 |
    +| [`src/inbox.ts`](src/inbox.ts) | 包内部的 `ReactLoopInbox`:持久投影、结构化命令与仅供循环使用的领取状态 |
     | [`src/tool-calls.ts`](src/tool-calls.ts) | 工具调度:独占屏障与有界并行池 |
     | [`src/runtime-context.ts`](src/runtime-context.ts) | 每步骤 runtime-context 快照处理 |
     | [`src/constants.ts`](src/constants.ts) | `DEFAULT_MAX_PARALLEL_TOOL_CALLS` |
    @@ -113,7 +116,7 @@ const handle = await ctx.agents.create({
     
     ### 轮次与步骤流程
     
    -驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。在轮次边界,它先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。`agent/pre-step` 决定什么进入该步骤。进入步骤的决定会在驱动器再次领取消息前追加完整的 `user/message` 批次,被拒绝的决定则不追加任何消息。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 `assistant/chunk` 之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个引用其分片 seq 的 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。
    +驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。其包内部 `ReactLoopInbox` 构造函数在 agent 作用域上注册标准 `inbox` 投影,随后将该投影用于结构化命令与仅供 loop 使用的领取操作。注册表引用计数会使共享 key 持续有效,直至最后一个 agent 作用域卸载。在轮次边界,驱动器先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。`agent/pre-step` 决定什么进入该步骤。进入步骤的决定会在驱动器再次领取消息前追加完整的 `user/message` 批次,被拒绝的决定则不追加任何消息。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 `assistant/chunk` 之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个引用其分片 seq 的 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。
     
     ### 失败与取消
     
    diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json
    index c5da4c8ee9..837aab76d6 100644
    --- a/packages/core/agent-loop/package.json
    +++ b/packages/core/agent-loop/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-agent-loop",
       "description": "The concrete agent loop plugin for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts
    index 634a1a52de..0bb02706ed 100644
    --- a/packages/core/agent-loop/src/agent.ts
    +++ b/packages/core/agent-loop/src/agent.ts
    @@ -15,7 +15,7 @@ import type {
       PreStepDecision,
       RequestErrorAction,
     } from '@deepseek-ai/dsh-agent'
    -import { Inbox, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
    +import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
     import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
     import {
       LlmError,
    @@ -32,6 +32,7 @@ import { joinContextSections, renderContextSections, renderPrompt } from '@deeps
     import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
     import type {} from '@deepseek-ai/dsh-session-projection'
     import type { Context } from '@deepseek-ai/cordis'
    +import { ReactLoopInbox } from './inbox.ts'
     import { RuntimeContextProjection } from './runtime-context.ts'
     import { AssistantStreamAttempt } from './assistant-stream.ts'
     import { executeToolCalls } from './tool-calls.ts'
    @@ -68,7 +69,7 @@ function requestProposal(header: EpochHeader): LlmCallConfig {
     
     /** Drives one session through turn and step boundaries. */
     export class ReactLoopAgent implements Agent {
    -  readonly inbox: Inbox
    +  readonly inbox: ReactLoopInbox
       private phase: Phase
       private activityDone: Promise = Promise.resolve()
     
    @@ -97,16 +98,12 @@ export class ReactLoopAgent implements Agent {
         public readonly session: Session,
       ) {
         this.dispatch = agentEvents(loopCtx, this)
    -    this.inbox = new Inbox(session, {
    -      inserted: (message) => { this.dispatch.emit('agent/inbox/inserted', { message }) },
    -      discarded: (message) => { this.dispatch.emit('agent/inbox/discarded', { message }) },
    -      claimed: (message, turn) => { this.dispatch.emit('agent/inbox/claimed', { message, turn }) },
    -    })
    +    this.scope = createScope(loopCtx, this)
    +    this.ctx = this.scope.ctx.extend({ agent: this })
    +    this.inbox = new ReactLoopInbox(this.ctx.sessionProjections, session, this.dispatch)
         /* v8 ignore next -- the loop registers its own turnBoundary unit, so the key is always present */
         const lastTurn = this.loopCtx.sessionProjections.stateOf(session, 'turnBoundary')?.lastTurn ?? 0
         this.phase = { kind: 'idle', lastTurn }
    -    this.scope = createScope(loopCtx, this)
    -    this.ctx = this.scope.ctx.extend({ agent: this })
         this.runtimeContext = new RuntimeContextProjection(this.ctx, session)
       }
     
    diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts
    new file mode 100644
    index 0000000000..db89cd3072
    --- /dev/null
    +++ b/packages/core/agent-loop/src/inbox.ts
    @@ -0,0 +1,247 @@
    +/**
    + * Driver-owned durable agent inbox projection and command facade.
    + *
    + * @module @deepseek-ai/dsh-agent-loop/inbox
    + */
    +
    +import type { MessageId } from '@deepseek-ai/dsh-llm'
    +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
    +import type SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
    +import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session'
    +import type {
    +  AgentEventDispatch,
    +  Inbox as InboxContract,
    +  InboxState,
    +  InboxTarget,
    +  InboxWireState,
    +} from '@deepseek-ai/dsh-agent'
    +import { z } from 'zod'
    +
    +/** Wire validation for pending agent input reconstructed from durable inbox splices. */
    +export const inboxProjectionSchema = z.object({
    +  'next-turn': z.array(z.custom()).readonly(),
    +  'next-step': z.array(z.custom()).readonly(),
    +}).readonly()
    +
    +/** Standard fold that reconstructs pending input and rejects invalid durable splice history. */
    +export const inboxProjectionDefinition = {
    +  key: 'inbox',
    +  stateSchema: inboxProjectionSchema,
    +  init: (): InboxState => ({ 'next-turn': [], 'next-step': [] }),
    +  apply(state: InboxState, event) {
    +    if (event.type !== 'agent/inbox/spliced') return state
    +    const splice = event.data
    +    try {
    +      const inbox = state[splice.target]
    +      const removedCount = splice.removedCount ?? 0
    +      if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length
    +        || !Number.isSafeInteger(removedCount) || removedCount < 0
    +        || splice.start + removedCount > inbox.length) {
    +        throw new Error('invalid inbox splice')
    +      }
    +      const next = inbox.toSpliced(splice.start, removedCount, ...splice.inserted)
    +      const ids = new Set()
    +      for (const message of splice.target === 'next-turn'
    +        ? [...next, ...state['next-step']]
    +        : [...state['next-turn'], ...next]) {
    +        if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
    +        ids.add(message.id)
    +      }
    +      return splice.target === 'next-turn'
    +        ? { 'next-turn': next, 'next-step': state['next-step'] }
    +        : { 'next-turn': state['next-turn'], 'next-step': next }
    +    } catch (error: unknown) {
    +      throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error })
    +    }
    +  },
    +  wire: {
    +    // The wire value is the fold state itself: every pending message already
    +    // round-trips the session log as lossless JSON. Only the static type
    +    // narrows to the JSON-safe projection table entry.
    +    viewSchema: inboxProjectionSchema as unknown as z.ZodType,
    +    view: (state: InboxState) => state as unknown as InboxWireState,
    +  },
    +  stateVersion: 1,
    +} satisfies ProjectionDefinition<'inbox', InboxState>
    +
    +/**
    + * Driver-owned durable Inbox implementation used by ReactLoopAgent and focused
    + * provider tests.
    + * @param projections - registry that owns the standard Inbox projection.
    + * @param session - session whose durable events store pending input.
    + * @param dispatch - agent-scoped notifications for Inbox lifecycle events.
    + */
    +export class ReactLoopInbox implements InboxContract {
    +  constructor(
    +    private readonly projections: SessionProjectionRegistry,
    +    private readonly session: Session,
    +    private readonly dispatch: AgentEventDispatch,
    +  ) {
    +    this.projections.register(inboxProjectionDefinition)
    +  }
    +
    +  /** Prompts awaiting individual turns. */
    +  get nextTurn(): readonly UserMessage[] {
    +    return this.current()['next-turn']
    +  }
    +
    +  /** Input awaiting the next step boundary. */
    +  get nextStep(): readonly UserMessage[] {
    +    return this.current()['next-step']
    +  }
    +
    +  /** Whether either pending-message list contains work. */
    +  get hasPending(): boolean {
    +    const state = this.current()
    +    return state['next-turn'].length > 0 || state['next-step'].length > 0
    +  }
    +
    +  /** Durably cancel all pending input, clearing next-step before next-turn. */
    +  clear(): void {
    +    this.splice('next-step', 0, this.nextStep.length, [])
    +    this.splice('next-turn', 0, this.nextTurn.length, [])
    +  }
    +
    +  /**
    +   * Remove and return the complete batch proposed for one step.
    +   * @param target - whether this boundary also consumes one queued turn.
    +   * @param turn - turn that will own the claimed batch.
    +   * @returns next-step input followed by the queued turn, when requested.
    +   */
    +  claim(target: InboxTarget, turn: number): UserMessage[] {
    +    const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false)
    +    if (target === 'next-turn') claimed.push(...this.mutate('next-turn', 0, 1, [], false))
    +    for (const message of claimed) this.dispatch.emit('agent/inbox/claimed', { message, turn })
    +    return claimed
    +  }
    +
    +  /**
    +   * Append one message to a pending list.
    +   * @param target - pending list to extend.
    +   * @param message - message to append.
    +   */
    +  append(target: InboxTarget, message: UserMessage): void {
    +    this.splice(target, this.current()[target].length, 0, [message])
    +  }
    +
    +  /**
    +   * Prepend one message to a pending list.
    +   * @param target - pending list to extend.
    +   * @param message - message to prepend.
    +   */
    +  prepend(target: InboxTarget, message: UserMessage): void {
    +    this.splice(target, 0, 0, [message])
    +  }
    +
    +  /**
    +   * Replace one pending message in place.
    +   * @param messageId - identity of the pending message to replace.
    +   * @param newMessage - replacement message.
    +   * @returns whether the message was still pending.
    +   */
    +  replace(messageId: MessageId, newMessage: UserMessage): boolean {
    +    const location = this.locate(messageId)
    +    if (location === undefined) return false
    +    this.splice(location.target, location.index, 1, [newMessage])
    +    return true
    +  }
    +
    +  /**
    +   * Remove one pending message.
    +   * @param messageId - identity of the pending message to remove.
    +   * @returns whether the message was still pending.
    +   */
    +  remove(messageId: MessageId): boolean {
    +    const location = this.locate(messageId)
    +    if (location === undefined) return false
    +    this.splice(location.target, location.index, 1, [])
    +    return true
    +  }
    +
    +  /**
    +   * Apply standard splice semantics and durably record the normalized result.
    +   * @param target - pending list to mutate.
    +   * @param start - splice position.
    +   * @param deleteCount - maximum number of messages to remove.
    +   * @param inserted - messages to insert at the resolved position.
    +   * @returns messages removed by the splice.
    +   */
    +  splice(
    +    target: InboxTarget,
    +    start: number,
    +    deleteCount: number,
    +    inserted: UserMessage[],
    +  ): UserMessage[] {
    +    return this.mutate(target, start, deleteCount, inserted, true)
    +  }
    +
    +  /** Locate one pending identity across both owned lists. */
    +  private locate(messageId: MessageId): { target: InboxTarget; index: number } | undefined {
    +    const state = this.current()
    +    for (const target of ['next-turn', 'next-step'] as const) {
    +      const index = state[target].findIndex(message => message.id === messageId)
    +      if (index >= 0) return { target, index }
    +    }
    +    return undefined
    +  }
    +
    +  /** Read the current durable projection state. */
    +  private current(): InboxState {
    +    const state = this.projections.stateOf(this.session, 'inbox')
    +    /* v8 ignore next -- the constructor registers this key before any read */
    +    if (state === undefined) {
    +      throw new Error(
    +        `agent "${this.session.id}" cannot read inbox state: its projection registration is not active`,
    +      )
    +    }
    +    return state
    +  }
    +
    +  /** Commit one normalized mutation and publish its live events. */
    +  private mutate(
    +    target: InboxTarget,
    +    start: number,
    +    deleteCount: number,
    +    inserted: UserMessage[],
    +    discardRemoved: boolean,
    +  ): UserMessage[] {
    +    const state = this.current()
    +    const inbox = state[target]
    +    const truncatedStart = Math.trunc(start)
    +    const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart
    +    const actualStart = offset < 0
    +      ? Math.max(inbox.length + offset, 0)
    +      : Math.min(offset, inbox.length)
    +    const truncatedDeleteCount = Math.trunc(deleteCount)
    +    const actualDeleteCount = Math.min(
    +      Math.max(Number.isNaN(truncatedDeleteCount) ? 0 : truncatedDeleteCount, 0),
    +      inbox.length - actualStart,
    +    )
    +    if (actualDeleteCount === 0 && inserted.length === 0) return []
    +    const candidate = inbox.toSpliced(actualStart, actualDeleteCount, ...inserted)
    +    const ids = new Set()
    +    for (const message of target === 'next-turn'
    +      ? [...candidate, ...state['next-step']]
    +      : [...state['next-turn'], ...candidate]) {
    +      if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
    +      ids.add(message.id)
    +    }
    +    const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' as const : undefined
    +    const splice: SessionEventMap['agent/inbox/spliced'] = {
    +      target,
    +      start: actualStart,
    +      ...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }),
    +      inserted,
    +      ...(outcome === undefined ? {} : { outcome }),
    +    }
    +    const removed = inbox.slice(actualStart, actualStart + actualDeleteCount)
    +    const event = this.session.append('agent/inbox/spliced', splice)
    +    if (discardRemoved) {
    +      for (const message of removed) this.dispatch.emit('agent/inbox/discarded', { message })
    +    }
    +    for (const message of event.data.inserted) {
    +      this.dispatch.emit('agent/inbox/inserted', { message })
    +    }
    +    return removed
    +  }
    +}
    diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts
    index 073ecae7b7..db25ec4e64 100644
    --- a/packages/core/agent-loop/src/index.ts
    +++ b/packages/core/agent-loop/src/index.ts
    @@ -584,7 +584,9 @@ export class AgentLoop extends Service implements AgentFactory {
             // Disposal IS a disposed-cause cancel followed by quiescence. New work
             // sent after this point is the sender's bug — the registries are about
             // to drop the agent, so nothing should still hold it.
    +        /* v8 ignore next -- Cordis effect teardown waits for synchronous setup before observing the machine slot. */
             if (machine === undefined) await machineReady.promise
    +        /* v8 ignore next -- setup failure untracks this disposer before resolving without a machine. */
             if (machine !== undefined) {
               machine.cancel({ kind: 'disposed' })
               await machine.whenIdle()
    @@ -617,15 +619,21 @@ export class AgentLoop extends Service implements AgentFactory {
         const untrack = this.ownership.track(dispose)
         let unfollowOwner: () => Promise | void
         try {
    -      unfollowOwner = ownerCtx.effect(() => () => {
    -        // Owner disposal owns the same quiescence boundary. Its teardown skips
    -        // unregistering this already-running owner effect from inside itself.
    -        if (disposing !== undefined) return
    -        abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
    -        return dispose(true)
    +      unfollowOwner = ownerCtx.effect(function* () {
    +        machine = new ReactLoopAgent(loopCtx, id, options, session)
    +        machineReady.resolve()
    +        yield machine.scope.rawDispose
    +        yield () => {
    +          // Owner disposal owns the same quiescence boundary. Its teardown skips
    +          // unregistering this already-running owner effect from inside itself.
    +          if (disposing !== undefined) return
    +          abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
    +          return dispose(true)
    +        }
           }, `agentLoop.lifecycle(${id})`)
           /* v8 ignore start -- ctx.effect throws only on an inactive fiber, which assertActive() above already rejected */
         } catch (error: unknown) {
    +      machineReady.resolve()
           untrack()
           callerSignal?.removeEventListener('abort', onCallerAbort)
           this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
    @@ -642,8 +650,9 @@ export class AgentLoop extends Service implements AgentFactory {
           throw abort.signal.reason instanceof Error ? abort.signal.reason : new Error(String(abort.signal.reason))
         }
         try {
    -      const agent = machine = new ReactLoopAgent(loopCtx, id, options, session)
    -      machineReady.resolve()
    +      /* v8 ignore next -- a synchronous effect exhausts the generator before returning */
    +      if (machine === undefined) throw new Error(`agent "${id}" lifecycle did not construct its driver`)
    +      const agent = machine
           assertLive()
     
           return {
    diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts
    index cb0ee6a933..68cb93f129 100644
    --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts
    +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts
    @@ -7,7 +7,6 @@ import ToolRuntime, { defineContentToolFixture, type PostToolDecision } from '@d
     import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
     import AgentLoop from '@deepseek-ai/dsh-agent-loop'
     import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
    -import { ReactLoopAgent } from '../src/agent.ts'
     import InvariantRegistry from '@deepseek-ai/dsh-invariants'
     import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
     import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
    @@ -249,7 +248,11 @@ describe('abort during tool execution ends the turn', () => {
             ? [event.data.content]
             : []))
           .toEqual([])
    -    expect(agent.inbox.nextStep.map(inboxText))
    +    expect(agent.session.snapshotEvents()
    +      .flatMap(event => event.type === 'agent/inbox/spliced' && event.data.target === 'next-step'
    +        ? [event.data.inserted.map(inboxText)]
    +        : [])
    +      .at(-1))
           .toEqual(['accepted result context during disposal'])
         expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start'))
           .toHaveLength(1)
    @@ -583,10 +586,11 @@ describe('turn numbering continues across seeded sessions', () => {
         await ctx2.plugin(AgentLoop, { agents: [] })
         ctx2.llm.registerAdapter(['mock'], second)
     
    -    const seeded = ctx2.sessions.create(SessionId('forked'), { seed: agent.session.snapshotEvents() })
    -    const forked = new ReactLoopAgent(
    -      ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded,
    -    )
    +    const { agent: forked } = await ctx2.agents.create({
    +      sessionId: SessionId('forked'),
    +      seed: agent.session.snapshotEvents(),
    +      agentOptions: { provider: 'mock', model: 'mock' },
    +    })
     
         const turns: number[] = []
         ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
    diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts
    new file mode 100644
    index 0000000000..028c42ca5c
    --- /dev/null
    +++ b/packages/core/agent-loop/tests/inbox.spec.ts
    @@ -0,0 +1,266 @@
    +import { Context } from '@deepseek-ai/cordis'
    +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
    +import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
    +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
    +import type { UserMessage } from '@deepseek-ai/dsh-session'
    +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
    +import { describe, expect, it } from 'vitest'
    +import { ReactLoopInbox } from '../src/inbox.ts'
    +
    +function unsupportedInbox(): Agent['inbox'] {
    +  const rejectMutation = (): never => {
    +    throw new Error('this test Agent does not support Inbox mutations')
    +  }
    +  return {
    +    nextTurn: [], nextStep: [], clear: rejectMutation, append: rejectMutation,
    +    prepend: rejectMutation, replace: rejectMutation, remove: rejectMutation, splice: rejectMutation,
    +  }
    +}
    +
    +function stubAgent(rawId: string, overrides: Partial = {}): Agent {
    +  const id = SessionId(rawId)
    +  const session = overrides.session ?? Session.create(id)
    +  const ctx = overrides.ctx ?? new Context()
    +  return {
    +    id,
    +    options: {},
    +    session,
    +    inbox: unsupportedInbox(),
    +    status: 'idle',
    +    ctx,
    +    send: () => {},
    +    followup: () => {},
    +    steer: () => {},
    +    inject: () => {},
    +    cancel() {},
    +    runMaintenance: task => task(new AbortController().signal),
    +    whenIdle: () => Promise.resolve(),
    +    ...overrides,
    +  }
    +}
    +
    +async function inboxAgent(rawId: string): Promise<{
    +  ctx: Context
    +  session: Session
    +  agent: Agent
    +  inbox: ReactLoopInbox
    +}> {
    +  const ctx = new Context()
    +  await ctx.plugin(SessionStore)
    +  await ctx.plugin(SessionProjectionRegistry)
    +  const session = ctx.sessions.create(SessionId(rawId))
    +  const agent = stubAgent(rawId, { ctx, session })
    +  const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
    +  Object.assign(agent, { inbox })
    +  return { ctx, session, agent, inbox }
    +}
    +
    +async function reconstructPersistedInbox(
    +  rawId: string,
    +  populate: (session: Session) => void,
    +): Promise {
    +  const ctx = new Context()
    +  await ctx.plugin(SessionStore)
    +  const session = ctx.sessions.create(SessionId(rawId))
    +  populate(session)
    +  await ctx.plugin(SessionProjectionRegistry)
    +  const agent = stubAgent(rawId, { ctx, session })
    +  const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
    +  try {
    +    void inbox.nextTurn
    +  } catch (error: unknown) {
    +    if (error instanceof Error) return error
    +    throw error
    +  }
    +  throw new Error('persisted inbox reconstruction unexpectedly succeeded')
    +}
    +
    +describe('ReactLoopInbox', () => {
    +  it('registers the durable projection in its constructor', async () => {
    +    const ctx = new Context()
    +    await ctx.plugin(SessionStore)
    +    await ctx.plugin(SessionProjectionRegistry)
    +    const session = ctx.sessions.create(SessionId('inbox-projection'))
    +    const pending = createUserMessage({
    +      content: [{ type: 'text', text: 'pending' }],
    +      source: { kind: 'user' },
    +    })
    +    session.append('agent/inbox/spliced', {
    +      target: 'next-turn', start: 0, inserted: [pending],
    +    })
    +    const agent = stubAgent('inbox-projection', { ctx, session })
    +    const dispatch = agentEvents(ctx, agent)
    +    const first = new ReactLoopInbox(ctx.sessionProjections, session, dispatch)
    +    const second = new ReactLoopInbox(ctx.sessionProjections, session, dispatch)
    +
    +    expect(first.nextTurn).toEqual([pending])
    +    expect(second.nextTurn).toEqual([pending])
    +    expect(ctx.sessionProjections.snapshot(session).values.inbox).toEqual({
    +      'next-turn': [pending],
    +      'next-step': [],
    +    })
    +  })
    +
    +  it('rejects invalid durable coordinates and duplicate identities during reconstruction', async () => {
    +    const outOfRange = await reconstructPersistedInbox('invalid-inbox-range', (session) => {
    +      session.append('agent/inbox/spliced', {
    +        target: 'next-turn', start: 0, removedCount: 1, inserted: [],
    +      })
    +    })
    +    expect(outOfRange.message).toBe('invalid persisted inbox splice at session seq 0')
    +    expect((outOfRange.cause as Error).message).toBe('invalid inbox splice')
    +
    +    const pending = createUserMessage({
    +      content: [{ type: 'text', text: 'duplicate' }],
    +      source: { kind: 'user' },
    +    })
    +    const duplicate = await reconstructPersistedInbox('invalid-inbox-duplicate', (session) => {
    +      session.append('agent/inbox/spliced', {
    +        target: 'next-turn', start: 0, inserted: [pending],
    +      })
    +      session.append('agent/inbox/spliced', {
    +        target: 'next-step', start: 0, inserted: [pending],
    +      })
    +    })
    +    expect(duplicate.message).toBe('invalid persisted inbox splice at session seq 1')
    +    expect((duplicate.cause as Error).message).toBe(`message "${pending.id}" is already pending`)
    +  })
    +
    +  it('projects inherited inbox events in a forked session', async () => {
    +    const ctx = new Context()
    +    await ctx.plugin(SessionStore)
    +    await ctx.plugin(SessionProjectionRegistry)
    +    const parent = ctx.sessions.create(SessionId('inbox-fork-parent'))
    +    const parentAgent = stubAgent('inbox-fork-parent', { ctx, session: parent })
    +    const parentInbox = new ReactLoopInbox(ctx.sessionProjections, parent, agentEvents(ctx, parentAgent))
    +    const inherited = createUserMessage({
    +      content: [{ type: 'text', text: 'parent pending' }],
    +      source: { kind: 'user' },
    +    })
    +    parentInbox.append('next-turn', inherited)
    +    const child = ctx.sessions.fork(parent, undefined, SessionId('inbox-fork-child'))
    +    const childAgent = stubAgent('inbox-fork-child', { ctx, session: child })
    +    const childInbox = new ReactLoopInbox(ctx.sessionProjections, child, agentEvents(ctx, childAgent))
    +
    +    expect(child.inheritedEventCount).toBe(parent.snapshotEvents().length)
    +    expect(childInbox.nextTurn).toEqual([inherited])
    +
    +    const own = createUserMessage({
    +      content: [{ type: 'text', text: 'child pending' }],
    +      source: { kind: 'user' },
    +    })
    +    childInbox.append('next-turn', own)
    +    expect(childInbox.nextTurn).toEqual([inherited, own])
    +
    +  })
    +
    +  it('updates the projection cell before session observers run', async () => {
    +    const { ctx, session, inbox } = await inboxAgent('inbox-live-projection')
    +    const pending = createUserMessage({
    +      content: [{ type: 'text', text: 'direct' }],
    +      source: { kind: 'user' },
    +    })
    +    let observed: readonly UserMessage[] | undefined
    +    ctx.on('session/event', (subject, event) => {
    +      if (subject === session && event.type === 'agent/inbox/spliced') {
    +        observed = ctx.sessionProjections.stateOf(session, 'inbox')?.['next-turn']
    +      }
    +    })
    +
    +    inbox.append('next-turn', pending)
    +
    +    expect(observed).toEqual([pending])
    +    expect(ctx.sessionProjections.snapshot(session).values.inbox).toEqual({
    +      'next-turn': [pending], 'next-step': [],
    +    })
    +  })
    +
    +  it('replaces a pending message by identity across both lists', async () => {
    +    const { ctx, agent } = await inboxAgent('replace-inbox')
    +    const inserted: UserMessage[] = []
    +    const discarded: UserMessage[] = []
    +    ctx.on('agent/inbox/inserted', ({ message }) => void inserted.push(message))
    +    ctx.on('agent/inbox/discarded', ({ message }) => void discarded.push(message))
    +    const original = createUserMessage({
    +      content: [{ type: 'text', text: 'original' }],
    +      source: { kind: 'user' },
    +    })
    +    const nextStep = createUserMessage({
    +      content: [{ type: 'text', text: 'step' }],
    +      source: { kind: 'user' },
    +    })
    +    const replacement = createUserMessage({
    +      content: [{ type: 'text', text: 'replacement' }],
    +      source: { kind: 'user' },
    +    })
    +    const editedStep = freezeMessage({
    +      ...nextStep,
    +      content: [{ type: 'text', text: 'edited step' }],
    +    })
    +    agent.inbox.append('next-turn', original)
    +    agent.inbox.append('next-step', nextStep)
    +
    +    expect(agent.inbox.replace(createUserMessage({
    +      content: [{ type: 'text', text: 'missing' }],
    +      source: { kind: 'user' },
    +    }).id, replacement)).toBe(false)
    +    expect(agent.inbox.replace(original.id, replacement)).toBe(true)
    +    expect(agent.inbox.replace(nextStep.id, editedStep)).toBe(true)
    +    expect(agent.inbox.nextTurn).toEqual([replacement])
    +    expect(agent.inbox.nextStep).toEqual([editedStep])
    +    expect(discarded).toEqual([original, nextStep])
    +    expect(inserted).toEqual([original, nextStep, replacement, editedStep])
    +    expect(() => { agent.inbox.replace(editedStep.id, replacement) })
    +      .toThrow(`message "${replacement.id}" is already pending`)
    +  })
    +
    +  it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', async () => {
    +    const { agent } = await inboxAgent('splice-inbox')
    +    const first = createUserMessage({
    +      content: [{ type: 'text', text: 'first' }],
    +      source: { kind: 'user' },
    +    })
    +    const second = createUserMessage({
    +      content: [{ type: 'text', text: 'second' }],
    +      source: { kind: 'user' },
    +    })
    +    const prefixed = createUserMessage({
    +      content: [{ type: 'text', text: 'prefixed' }],
    +      source: { kind: 'user' },
    +    })
    +
    +    agent.inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second])
    +    expect(agent.inbox.nextTurn).toEqual([first, second])
    +    expect(agent.inbox.splice('next-turn', -1, 1, [])).toEqual([second])
    +    agent.inbox.prepend('next-turn', prefixed)
    +    expect(agent.inbox.nextTurn).toEqual([prefixed, first])
    +    expect(agent.inbox.remove(second.id)).toBe(false)
    +    expect(() => { agent.inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`)
    +  })
    +
    +  it('clears both pending lists as durable cancellations', async () => {
    +    const { ctx, session, agent } = await inboxAgent('clear-inbox')
    +    const discarded: UserMessage[] = []
    +    ctx.on('agent/inbox/discarded', ({ message }) => void discarded.push(message))
    +    const nextTurn = createUserMessage({ content: [{ type: 'text', text: 'turn' }], source: { kind: 'user' } })
    +    const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } })
    +    agent.inbox.append('next-turn', nextTurn)
    +    agent.inbox.append('next-step', nextStep)
    +    const beforeClear = session.snapshotEvents().length
    +
    +    agent.inbox.clear()
    +
    +    expect(agent.inbox.nextTurn).toEqual([])
    +    expect(agent.inbox.nextStep).toEqual([])
    +    expect(discarded).toEqual([nextStep, nextTurn])
    +    expect(session.snapshotEvents().slice(beforeClear).map(event => event.type === 'agent/inbox/spliced'
    +      ? event.data
    +      : event.type)).toEqual([
    +      { target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
    +      { target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
    +    ])
    +
    +    agent.inbox.clear()
    +    expect(session.snapshotEvents()).toHaveLength(beforeClear + 2)
    +  })
    +})
    diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts
    index 8ef5929522..b50fb45942 100644
    --- a/packages/core/agent-loop/tests/interception.spec.ts
    +++ b/packages/core/agent-loop/tests/interception.spec.ts
    @@ -292,7 +292,8 @@ describe('agent/pre-step', () => {
     
         decision.resolve({ kind: 'enter', messages: claimed })
         await idle
    -    expect(agent.inbox.hasPending).toBe(false)
    +    expect(agent.inbox.nextTurn).toEqual([])
    +    expect(agent.inbox.nextStep).toEqual([])
     
         const staged = events(agent).filter(event =>
           event.type === 'turn/start' || event.type === 'user/message')
    @@ -472,7 +473,8 @@ describe('agent/pre-step', () => {
           send(agent, 'blocked prompt')
         }).toThrow('append unavailable')
         expect(events(agent)).toEqual([])
    -    expect(agent.inbox.hasPending).toBe(false)
    +    expect(agent.inbox.nextTurn).toEqual([])
    +    expect(agent.inbox.nextStep).toEqual([])
         expect(agent.status).toBe('idle')
       })
     
    diff --git a/packages/core/agent-loop/tests/request-freeze.spec.ts b/packages/core/agent-loop/tests/request-freeze.spec.ts
    index cfb2c64da2..c86e88cb65 100644
    --- a/packages/core/agent-loop/tests/request-freeze.spec.ts
    +++ b/packages/core/agent-loop/tests/request-freeze.spec.ts
    @@ -8,7 +8,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
     import { createAssistantMessage, createUserMessage, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
     import type { GenerateOptions, ToolSchema } from '@deepseek-ai/dsh-llm'
     import { Session, SessionId, SessionLogOffset, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import * as values from '@deepseek-ai/dsh-util-values'
     import { ReactLoopAgent } from '../src/agent.ts'
     import { MockAdapter, textResponse } from './mock-adapter.ts'
    @@ -23,14 +22,13 @@ afterEach(async () => {
       }
     })
     
    -async function harness(adapter?: MockAdapter): Promise {
    +async function harness(adapter?: MockAdapter): Promise<{ ctx: Context; loopCtx: Context }> {
       const ctx = new Context()
       cleanups.push(() => ctx.fiber.dispose())
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
    -  await ctx.plugin(AgentLoop, { agents: [] })
    +  const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
       if (adapter) ctx.effect(() => ctx.llm.registerAdapter(['mock'], adapter))
    -  return ctx
    +  return { ctx, loopCtx: loopFiber.ctx }
     }
     
     async function send(agent: Agent, text: string): Promise {
    @@ -46,7 +44,7 @@ function expectFrozen(value: unknown): void {
     
     describe('loop-owned request freezing', () => {
       it('adopts restored identities, freezes nested messages at dispatch, and leaves event wrappers mutable', async () => {
    -    const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three'), textResponse('four')]))
    +    const { ctx, loopCtx } = await harness(new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three'), textResponse('four')]))
         const id = SessionId('restored-freeze')
         const seed = Session.create(id)
         seed.append('user/message', createUserMessage({
    @@ -75,7 +73,7 @@ describe('loop-owned request freezing', () => {
         expect(Object.isFrozen(userEvent.data.content)).toBe(false)
         expect(Object.isFrozen(assistantEvent.data.message)).toBe(false)
         ctx.effect(() => ctx.sessions.enter(session))
    -    const agent = new ReactLoopAgent(ctx, id, { provider: 'mock', model: 'mock' }, session)
    +    const agent = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session)
         cleanups.push(async () => {
           agent.cancel({ kind: 'disposed' })
           await agent.whenIdle()
    @@ -124,7 +122,7 @@ describe('loop-owned request freezing', () => {
         expect(Object.isFrozen(session.deriveMessages())).toBe(false)
         expect(freeze.mock.calls.filter(([value]) => value === userEvent.data)).toHaveLength(1)
         expect(freeze.mock.calls.filter(([value]) => value === replacement.data)).toHaveLength(1)
    -    const resumed = new ReactLoopAgent(ctx, id, { provider: 'mock', model: 'mock' }, session)
    +    const resumed = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session)
         cleanups.push(async () => {
           resumed.cancel({ kind: 'disposed' })
           await resumed.whenIdle()
    @@ -136,7 +134,7 @@ describe('loop-owned request freezing', () => {
       })
     
       it('retries freezing an identity whose previous traversal failed', async () => {
    -    const ctx = await harness(new MockAdapter([textResponse('done')]))
    +    const { ctx } = await harness(new MockAdapter([textResponse('done')]))
         const agent = await ctx.agentLoop.create(SessionId('freeze-failure'), { provider: 'mock', model: 'mock' })
         const message = agent.session.append('user/message', createUserMessage({
           content: [{ type: 'text', text: 'history' }], source: { kind: 'user' },
    @@ -163,7 +161,7 @@ describe('loop-owned request freezing', () => {
     
       it.each([true, false])('freezes each local header with an adapter present: %s', async (registered) => {
         const adapter = registered ? new MockAdapter([textResponse('one'), textResponse('two')]) : undefined
    -    const ctx = await harness(adapter)
    +    const { ctx } = await harness(adapter)
         const schemas: ToolSchema[][] = []
         const stops: string[][] = []
         ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
    @@ -206,7 +204,7 @@ describe('loop-owned request freezing', () => {
       })
     
       it('keeps the live request signal mutable and observes cancellation after dispatch', async () => {
    -    const ctx = await harness(new MockAdapter(['hang']))
    +    const { ctx } = await harness(new MockAdapter(['hang']))
         const agent = await ctx.agentLoop.create(SessionId('cancel-freeze'), { provider: 'mock', model: 'mock' })
         const started = Promise.withResolvers()
         ctx.on('llm/stream', (request, next) => { started.resolve(request); return next() })
    diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts
    index c27d0d2534..5f19bbc422 100644
    --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts
    +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts
    @@ -195,6 +195,33 @@ describe('agent scope lifecycle', () => {
         expect(after.sections.find(s => s.name === 'deployment:persona-prefix')?.text).toBe('You are the deployment.')
       })
     
    +  it('keeps the inbox projection until the last owning agent fiber unloads', async () => {
    +    const ctx = await harness()
    +    let first!: Awaited>
    +    let second!: Awaited>
    +    const firstOwner = await ctx.plugin(Object.assign(async (inner: Context) => {
    +      first = await inner.agents.create({
    +        sessionId: SessionId('projection-owner-first'),
    +        agentOptions: { provider: 'mock', model: 'mock' },
    +      })
    +    }, { inject: ['agents'] }))
    +    const secondOwner = await ctx.plugin(Object.assign(async (inner: Context) => {
    +      second = await inner.agents.create({
    +        sessionId: SessionId('projection-owner-second'),
    +        agentOptions: { provider: 'mock', model: 'mock' },
    +      })
    +    }, { inject: ['agents'] }))
    +
    +    expect(ctx.sessionProjections.stateOf(first.agent.session, 'inbox')).toBeDefined()
    +    await firstOwner.dispose()
    +    expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeDefined()
    +    await secondOwner.dispose()
    +    expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeUndefined()
    +
    +    await Promise.all([first.dispose(), second.dispose()])
    +    await ctx.fiber.dispose()
    +  })
    +
       it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => {
         const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')]))
         const a = await ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
    diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json
    index 9be6d2f497..3108889177 100644
    --- a/packages/core/agent-tool-presentation/package.json
    +++ b/packages/core/agent-tool-presentation/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-agent-tool-presentation",
       "description": "Agent-plane presentation selector: composes one agent's tools as PTC mode, native, or both",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml
    index 7c0fb5c510..5180dec6b1 100644
    --- a/packages/core/agent/README.i18n.yaml
    +++ b/packages/core/agent/README.i18n.yaml
    @@ -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/core/agent/README.md
    -README.md: f413abd855be17fde2e38997df8269d1ca2670b5
    -README.zh.md: 74797894177994cb3098aa67e969f45ced357634
    +README.md: b1333e96c1eaf83c4a5598a23bdae068e227c25f
    +README.zh.md: d15ce22b8f845e268fb935382ff16c7fd20b9c67
    diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md
    index f413abd855..b1333e96c1 100644
    --- a/packages/core/agent/README.md
    +++ b/packages/core/agent/README.md
    @@ -84,14 +84,19 @@ The package is built on one separation: the public `Agent` surface and registry
     
     `PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages, startsRequestSeries? }`. The enter branch contains the complete identified, frozen message batch. `startsRequestSeries: true` declares a distinct model-message series; a wrapping listener preserves that declaration and the batch unless it intentionally replaces either one. Claiming removes offered messages from the inbox, while messages inserted after the claim remain pending for a later boundary.
     
    +### Durable inbox
    +
    +`Agent.inbox` exposes only the structural `Inbox` interface and the projection vocabulary stays in this package. dsh-agent-loop owns the package-internal `ReactLoopInbox` and the standard `inbox` projection; constructing its concrete inbox ensures that the projection registry owns one registration for the durable `agent/inbox/spliced` fold. The registry remains the sole owner of the live `{ 'next-turn', 'next-step' }` state. Reconstruction rejects unsafe or out-of-range splice coordinates and duplicate `MessageId` values across both pending lists and reports the offending event seq.
    +
    +`Inbox` exposes pending `nextTurn` and `nextStep` messages and mutates them through `append`, `prepend`, `replace`, `remove`, `clear`, and `splice`. Ordinary removals and `clear()` are durable cancellations. At a step boundary, the loop's internal implementation claims pending input through pure deletion splices. Live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`.
    +
     ### Source map
     
     | File | Role |
     |---|---|
     | [`src/index.ts`](src/index.ts) | Plugin entry: `AgentRegistry`, factory slot, initiator scope, `CreateAgentOptions`/`ResumeAgentOptions` |
    -| [`src/runtime-types.ts`](src/runtime-types.ts) | `Agent`, `AgentStatus`, and the `agent/*` event declarations |
    -| [`src/types.ts`](src/types.ts) | `AgentOptions`, cancellation causes, and inbox vocabulary |
    -| [`src/inbox.ts`](src/inbox.ts) | The `Inbox` projection over durable `agent/inbox/spliced` events |
    +| [`src/runtime-types.ts`](src/runtime-types.ts) | `Agent`, structural `Inbox`, `AgentStatus`, and the `agent/*` event declarations |
    +| [`src/types.ts`](src/types.ts) | `AgentOptions`, cancellation causes, and inbox projection vocabulary |
     | [`src/dispatch.ts`](src/dispatch.ts) | `agentEvents` fused dispatcher and `assembleContextFor(agent)` |
     | [`src/consumed-work.ts`](src/consumed-work.ts) | `foldConsumedWork(events)`: what the log's consumed work became |
     | [`src/model-selection.ts`](src/model-selection.ts) | `installModelSelection`: coupling one selection to assembly and routing |
    diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md
    index 7479789417..d15ce22b8f 100644
    --- a/packages/core/agent/README.zh.md
    +++ b/packages/core/agent/README.zh.md
    @@ -84,14 +84,19 @@ await handle.agent.whenIdle()
     
     `PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages, startsRequestSeries? }`。enter 分支包含完整、带标识且冻结的消息批次。`startsRequestSeries: true` 声明一个独立的模型消息序列;包装下游 enter 的监听器会保留该声明与批次,除非有意替换其中一项。领取会从 inbox 移除候选消息,领取后插入的消息则等待后续边界。
     
    +### 持久 inbox
    +
    +`Agent.inbox` 只暴露结构化 `Inbox` 接口,投影词汇仍位于本包。dsh-agent-loop 持有包内部的 `ReactLoopInbox` 与标准 `inbox` 投影;构造具体 inbox 时会确保投影注册表为持久 `agent/inbox/spliced` fold 持有一份注册。注册表继续作为实时 `{ 'next-turn', 'next-step' }` 状态的唯一所有者。重建过程会拒绝不安全或越界的 splice 坐标,以及跨两份待处理列表重复的 `MessageId`,并报告出错事件的 seq。
    +
    +`Inbox` 暴露待处理的 `nextTurn` 与 `nextStep` 消息,并通过 `append`、`prepend`、`replace`、`remove`、`clear` 与 `splice` 变更它们。普通删除和 `clear()` 都是持久取消。在步骤边界,循环的内部实现会通过纯删除 splice 领取待处理输入。实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }`、`agent/inbox/claimed { message, turn }` 与 `agent/inbox/discarded { message }`。
    +
     ### 源码地图
     
     | 文件 | 职责 |
     |---|---|
     | [`src/index.ts`](src/index.ts) | 插件入口:`AgentRegistry`、工厂槽位、发起方作用域、`CreateAgentOptions`/`ResumeAgentOptions` |
    -| [`src/runtime-types.ts`](src/runtime-types.ts) | `Agent`、`AgentStatus` 与 `agent/*` 事件声明 |
    -| [`src/types.ts`](src/types.ts) | `AgentOptions`、取消原因与收件箱词汇 |
    -| [`src/inbox.ts`](src/inbox.ts) | 持久 `agent/inbox/spliced` 事件之上的 `Inbox` 投影 |
    +| [`src/runtime-types.ts`](src/runtime-types.ts) | `Agent`、结构化 `Inbox`、`AgentStatus` 与 `agent/*` 事件声明 |
    +| [`src/types.ts`](src/types.ts) | `AgentOptions`、取消原因与收件箱投影词汇 |
     | [`src/dispatch.ts`](src/dispatch.ts) | `agentEvents` 融合分发器与 `assembleContextFor(agent)` |
     | [`src/consumed-work.ts`](src/consumed-work.ts) | `foldConsumedWork(events)`:日志消费掉的工作最终怎样了 |
     | [`src/model-selection.ts`](src/model-selection.ts) | `installModelSelection`:把一个选择耦合到组装与路由 |
    diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json
    index 065f505420..11fa110fcf 100644
    --- a/packages/core/agent/package.json
    +++ b/packages/core/agent/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-agent",
       "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -41,20 +41,22 @@
         "@deepseek-ai/dsh-llm": "workspace:^",
         "@deepseek-ai/dsh-scope": "workspace:^",
         "@deepseek-ai/dsh-session": "workspace:^",
    +    "@deepseek-ai/dsh-session-projection": "workspace:^",
         "@deepseek-ai/dsh-system-prompt": "workspace:^",
         "@deepseek-ai/dsh-typert-protocol": "workspace:^",
    -    "@deepseek-ai/cordis": "workspace:^",
    -    "@deepseek-ai/dsh-session-projection": "workspace:^"
    +    "@deepseek-ai/dsh-util-values": "workspace:^",
    +    "@deepseek-ai/cordis": "workspace:^"
       },
       "devDependencies": {
         "@deepseek-ai/dsh-invariants": "workspace:^",
         "@deepseek-ai/dsh-llm": "workspace:^",
         "@deepseek-ai/dsh-scope": "workspace:^",
         "@deepseek-ai/dsh-session": "workspace:^",
    +    "@deepseek-ai/dsh-session-projection": "workspace:^",
         "@deepseek-ai/dsh-system-prompt": "workspace:^",
         "@deepseek-ai/dsh-typert-protocol": "workspace:^",
         "@deepseek-ai/dsh-typert-registry": "workspace:^",
    -    "@deepseek-ai/cordis": "workspace:^",
    -    "@deepseek-ai/dsh-session-projection": "workspace:^"
    +    "@deepseek-ai/dsh-util-values": "workspace:^",
    +    "@deepseek-ai/cordis": "workspace:^"
       }
     }
    diff --git a/packages/core/agent/src/inbox.ts b/packages/core/agent/src/inbox.ts
    deleted file mode 100644
    index c5d50b222a..0000000000
    --- a/packages/core/agent/src/inbox.ts
    +++ /dev/null
    @@ -1,220 +0,0 @@
    -/**
    - * Incremental projection of durable agent inbox events.
    - *
    - * @module @deepseek-ai/dsh-agent/inbox
    - */
    -
    -import type { MessageId } from '@deepseek-ai/dsh-llm'
    -import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session'
    -import type { InboxTarget } from './types.ts'
    -
    -/** Mutable state privately owned by an {@link Inbox}. */
    -type InboxState = Record
    -
    -/** Live notifications committed by inbox mutations. */
    -export interface InboxNotifications {
    -  /** Publish one inserted message. */
    -  inserted(message: UserMessage): void
    -  /** Publish one discarded message. */
    -  discarded(message: UserMessage): void
    -  /** Publish one claimed message inside its owning turn. */
    -  claimed(message: UserMessage, turn: number): void
    -}
    -
    -/** A replay-once projection that incrementally consumes later inbox splices. */
    -export class Inbox {
    -  private readonly state: InboxState = { 'next-turn': [], 'next-step': [] }
    -
    -  constructor(
    -    private readonly session: Session,
    -    private readonly notifications: InboxNotifications,
    -  ) {
    -    for (const event of session.ownEvents()) {
    -      if (event.type !== 'agent/inbox/spliced') continue
    -      try {
    -        this.apply(event.data)
    -      } catch (error: unknown) {
    -        throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error })
    -      }
    -    }
    -  }
    -
    -  /** Prompts awaiting individual turns. */
    -  get nextTurn(): readonly UserMessage[] {
    -    return this.state['next-turn']
    -  }
    -
    -  /** Input awaiting the next step boundary. */
    -  get nextStep(): readonly UserMessage[] {
    -    return this.state['next-step']
    -  }
    -
    -  /** Whether either pending-message list contains work. */
    -  get hasPending(): boolean {
    -    return this.nextTurn.length > 0 || this.nextStep.length > 0
    -  }
    -
    -  /** Durably cancel all pending input, clearing next-step before next-turn. */
    -  clear(): void {
    -    this.splice('next-step', 0, this.nextStep.length, [])
    -    this.splice('next-turn', 0, this.nextTurn.length, [])
    -  }
    -
    -  /**
    -   * Remove and return the complete batch proposed for one step, publishing
    -   * each claimed message. The durable splices are pure deletions.
    -   * @param target - whether this boundary also consumes one queued turn.
    -   * @param turn - turn that will own the claimed batch.
    -   * @returns next-step input followed by the queued turn, when requested.
    -   * @internal - The agent loop's step-boundary operation, not a plugin extension point.
    -   */
    -  claim(target: InboxTarget, turn: number): UserMessage[] {
    -    const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false)
    -    if (target === 'next-turn') {
    -      claimed.push(...this.mutate('next-turn', 0, 1, [], false))
    -    }
    -    for (const message of claimed) this.notifications.claimed(message, turn)
    -    return claimed
    -  }
    -
    -  /**
    -   * Append one message to a pending list and durably record the insertion.
    -   * @param target - pending list to extend.
    -   * @param message - message to append.
    -   * @throws if the message identity is already pending.
    -   */
    -  append(target: InboxTarget, message: UserMessage): void {
    -    this.splice(target, this.state[target].length, 0, [message])
    -  }
    -
    -  /**
    -   * Prepend one message to a pending list and durably record the insertion.
    -   * @param target - pending list to extend.
    -   * @param message - message to prepend.
    -   * @throws if the message identity is already pending.
    -   */
    -  prepend(target: InboxTarget, message: UserMessage): void {
    -    this.splice(target, 0, 0, [message])
    -  }
    -
    -  /**
    -   * Replace one pending message in place, possibly changing its identity. A
    -   * successful replacement publishes the old message as discarded and the new
    -   * message as inserted.
    -   * @param messageId - identity of the pending message to replace.
    -   * @param newMessage - replacement message.
    -   * @returns whether the message was still pending.
    -   * @throws if the replacement duplicates another pending message identity.
    -   */
    -  replace(messageId: MessageId, newMessage: UserMessage): boolean {
    -    const location = this.locate(messageId)
    -    if (location === undefined) return false
    -    this.splice(location.target, location.index, 1, [newMessage])
    -    return true
    -  }
    -
    -  /**
    -   * Remove one pending message and durably record its cancellation.
    -   * @param messageId - identity of the pending message to remove.
    -   * @returns whether the message was still pending.
    -   */
    -  remove(messageId: MessageId): boolean {
    -    const location = this.locate(messageId)
    -    if (location === undefined) return false
    -    this.splice(location.target, location.index, 1, [])
    -    return true
    -  }
    -
    -  /**
    -   * Apply standard splice semantics and durably record the normalized result.
    -   * The durable event commits before the live projection mutates, so synchronous
    -   * `session/event` observers see the pre-splice lists and can reconstruct the
    -   * removed messages from the normalized coordinates.
    -   * @param target - pending list to mutate.
    -   * @param start - splice position.
    -   * @param deleteCount - maximum number of messages to remove.
    -   * @param inserted - messages to insert at the resolved position.
    -   * @returns messages removed by the splice.
    -   */
    -  splice(
    -    target: InboxTarget,
    -    start: number,
    -    deleteCount: number,
    -    inserted: UserMessage[],
    -  ): UserMessage[] {
    -    return this.mutate(target, start, deleteCount, inserted, true)
    -  }
    -
    -  /** Locate one pending identity across both owned lists. */
    -  private locate(messageId: MessageId): { target: InboxTarget; index: number } | undefined {
    -    for (const target of ['next-turn', 'next-step'] as const) {
    -      const index = this.state[target].findIndex(message => message.id === messageId)
    -      if (index >= 0) return { target, index }
    -    }
    -    return undefined
    -  }
    -
    -  /** Commit one normalized mutation and publish its live notifications. */
    -  private mutate(
    -    target: InboxTarget,
    -    start: number,
    -    deleteCount: number,
    -    inserted: UserMessage[],
    -    discardRemoved: boolean,
    -  ): UserMessage[] {
    -    const inbox = this.state[target]
    -    const truncatedStart = Math.trunc(start)
    -    const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart
    -    const actualStart = offset < 0
    -      ? Math.max(inbox.length + offset, 0)
    -      : Math.min(offset, inbox.length)
    -    const truncatedDeleteCount = Math.trunc(deleteCount)
    -    const actualDeleteCount = Math.min(
    -      Math.max(Number.isNaN(truncatedDeleteCount) ? 0 : truncatedDeleteCount, 0),
    -      inbox.length - actualStart,
    -    )
    -    if (actualDeleteCount === 0 && inserted.length === 0) return []
    -    const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' as const : undefined
    -    const splice = {
    -      target,
    -      start: actualStart,
    -      ...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }),
    -      inserted,
    -      ...(outcome === undefined ? {} : { outcome }),
    -    }
    -    this.validate(splice)
    -    const event = this.session.append('agent/inbox/spliced', splice)
    -    const removed = inbox.splice(actualStart, actualDeleteCount, ...event.data.inserted)
    -    if (discardRemoved) {
    -      for (const message of removed) this.notifications.discarded(message)
    -    }
    -    for (const message of event.data.inserted) this.notifications.inserted(message)
    -    return removed
    -  }
    -
    -  /** Apply one normalized durable splice to the projection. */
    -  private apply(splice: SessionEventMap['agent/inbox/spliced']): UserMessage[] {
    -    this.validate(splice)
    -    const inbox = this.state[splice.target]
    -    return inbox.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted)
    -  }
    -
    -  /** Validate one normalized splice against the current projection. */
    -  private validate(splice: SessionEventMap['agent/inbox/spliced']): void {
    -    const inbox = this.state[splice.target]
    -    const removedCount = splice.removedCount ?? 0
    -    if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length
    -      || !Number.isSafeInteger(removedCount) || removedCount < 0
    -      || splice.start + removedCount > inbox.length) {
    -      throw new Error('invalid inbox splice')
    -    }
    -    const candidate = inbox.toSpliced(splice.start, removedCount, ...splice.inserted)
    -    const ids = new Set()
    -    for (const message of splice.target === 'next-turn'
    -      ? [...candidate, ...this.nextStep]
    -      : [...this.nextTurn, ...candidate]) {
    -      if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
    -      ids.add(message.id)
    -    }
    -  }
    -}
    diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts
    index 7df60db27c..e9d41bc0dd 100644
    --- a/packages/core/agent/src/index.ts
    +++ b/packages/core/agent/src/index.ts
    @@ -18,7 +18,6 @@ import type { AgentOptions } from './runtime-types.ts'
     export * from './runtime-types.ts'
     export * from './types.ts'
     export type * from './projection.ts'
    -export * from './inbox.ts'
     export * from './consumed-work.ts'
     export * from './model-selection.ts'
     export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
    diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts
    index 2b6931b257..8f2f471f12 100644
    --- a/packages/core/agent/src/runtime-types.ts
    +++ b/packages/core/agent/src/runtime-types.ts
    @@ -8,12 +8,11 @@
     import type { Context } from '@deepseek-ai/cordis'
     import type { Scoped } from '@deepseek-ai/dsh-scope'
     import type {
    -  LlmAttemptId, LlmCallConfig, LlmFailure, ReasoningEffortId, ResolvedRetryPolicy, StreamChunk,
    +  LlmAttemptId, LlmCallConfig, LlmFailure, MessageId, ReasoningEffortId, ResolvedRetryPolicy, StreamChunk,
     } from '@deepseek-ai/dsh-llm'
     import type { AgentCancelCause, Session, SessionSeq, UserMessage } from '@deepseek-ai/dsh-session'
     export type { AgentCancelCause } from '@deepseek-ai/dsh-session'
    -import type { Inbox } from './inbox.ts'
    -import type { Agent } from './types.ts'
    +import type { Agent, InboxTarget } from './types.ts'
     export type { Agent } from './types.ts'
     import type {} from '@deepseek-ai/dsh-system-prompt'
     declare module '@deepseek-ai/dsh-system-prompt' {
    @@ -45,6 +44,61 @@ export interface CancelOptions {
       keepInbox?: boolean | undefined
     }
     
    +/** Agent-owned access to pending work; concrete storage belongs to the driver. */
    +export interface Inbox {
    +  /** Prompts awaiting individual turns. */
    +  readonly nextTurn: readonly UserMessage[]
    +  /** Input awaiting the next step boundary. */
    +  readonly nextStep: readonly UserMessage[]
    +
    +  /** Durably cancel all pending input, clearing next-step before next-turn. */
    +  clear(): void
    +
    +  /**
    +   * Append one message to a pending list.
    +   * @param target - pending list to extend.
    +   * @param message - message to append.
    +   */
    +  append(target: InboxTarget, message: UserMessage): void
    +
    +  /**
    +   * Prepend one message to a pending list.
    +   * @param target - pending list to extend.
    +   * @param message - message to prepend.
    +   */
    +  prepend(target: InboxTarget, message: UserMessage): void
    +
    +  /**
    +   * Replace one pending message in place.
    +   * @param messageId - identity of the pending message to replace.
    +   * @param newMessage - replacement message.
    +   * @returns whether the message was still pending.
    +   */
    +  replace(messageId: MessageId, newMessage: UserMessage): boolean
    +
    +  /**
    +   * Remove one pending message.
    +   * @param messageId - identity of the pending message to remove.
    +   * @returns whether the message was still pending.
    +   */
    +  remove(messageId: MessageId): boolean
    +
    +  /**
    +   * Apply standard splice semantics and durably record the normalized result.
    +   * @param target - pending list to mutate.
    +   * @param start - splice position.
    +   * @param deleteCount - maximum number of messages to remove.
    +   * @param inserted - messages to insert at the resolved position.
    +   * @returns messages removed by the splice.
    +   */
    +  splice(
    +    target: InboxTarget,
    +    start: number,
    +    deleteCount: number,
    +    inserted: UserMessage[],
    +  ): UserMessage[]
    +}
    +
     /**
      * An agent's lifecycle state, emitted on every transition as `agent/status`:
      * `idle` means no driver is active; `running` begins when waking input starts
    @@ -112,7 +166,7 @@ declare module './types.ts' {
         readonly options: AgentOptions
         /** The live session this agent drives; its log is the durable source of truth. */
         readonly session: Session
    -    /** The agent-owned projection of durable pending work. */
    +    /** Agent-owned access to durable pending work. */
         readonly inbox: Inbox
         /** The current lifecycle state, mirrored on every `agent/status` transition. */
         readonly status: AgentStatus
    diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts
    index c7b82e243e..d0be69ac58 100644
    --- a/packages/core/agent/src/types.ts
    +++ b/packages/core/agent/src/types.ts
    @@ -7,6 +7,7 @@
     import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
     import type { OptionalSessionSeq, SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
     import type { TypertContext, TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
    +import type { JsonValue } from '@deepseek-ai/dsh-util-values'
     
     /** Public live-agent handle; the runtime face augments its live capabilities. */
     export interface Agent {
    @@ -28,6 +29,34 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
     /** One of the two ordered pending-message lists owned by an agent. */
     export type InboxTarget = 'next-turn' | 'next-step'
     
    +/** Complete pending Inbox value reconstructed from durable splices. */
    +export interface InboxState {
    +  readonly 'next-turn': readonly UserMessage[]
    +  readonly 'next-step': readonly UserMessage[]
    +}
    +
    +/**
    + * Wire-JSON pending Inbox value. Each message round-trips the session log
    + * losslessly, but the fold state's full `UserMessage` type cannot cross a
    + * typert Remote boundary (its source union carries an `unknown` replay
    + * field), so the typed projection table keeps this JSON-safe form.
    + */
    +export interface InboxWireState {
    +  readonly 'next-turn': readonly JsonValue[]
    +  readonly 'next-step': readonly JsonValue[]
    +}
    +
    +declare module '@deepseek-ai/dsh-session-projection/types' {
    +  interface SessionProjectionStateMap {
    +    /** Pending agent input reconstructed from durable inbox splices. */
    +    inbox: InboxState
    +  }
    +  interface SessionProjectionMap {
    +    /** Pending agent input reconstructed from durable inbox splices. */
    +    inbox: InboxWireState
    +  }
    +}
    +
     /**
      * Turn and step boundaries folded from one agent session log.
      *
    @@ -52,8 +81,8 @@ declare module '@deepseek-ai/dsh-session/types' {
       interface SessionEventMap {
         /**
          * One normalized mutation of an agent's durable pending-message lists.
    -     * Live dispatch precedes projection mutation, so synchronous observers may
    -     * read the pre-splice inbox to recover the removed messages.
    +     * The session-projection registry applies the committed event before
    +     * `Session.append()` returns; Inbox live notifications follow that commit.
          */
         'agent/inbox/spliced': {
           target: InboxTarget
    diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts
    index e1da38b652..f0649dd5f5 100644
    --- a/packages/core/agent/tests/agent.spec.ts
    +++ b/packages/core/agent/tests/agent.spec.ts
    @@ -1,11 +1,7 @@
     import { describe, expect, expectTypeOf, it } from 'vitest'
     import { Context, Service, symbols } from '@deepseek-ai/cordis'
    -import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
    -import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
    -import AgentRegistry, {
    -  agentEvents,
    -  Inbox,
    -} from '@deepseek-ai/dsh-agent'
    +import { Session, SessionId } from '@deepseek-ai/dsh-session'
    +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
     import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
     
     import type {
    @@ -19,14 +15,17 @@ import type {
     
     function stubAgent(rawId: string, overrides: Partial = {}): Agent {
       const id = SessionId(rawId)
    -  const session = Session.create(id)
    +  const session = overrides.session ?? Session.create(id)
    +  const ctx = overrides.ctx ?? new Context()
       const agent: Agent = {
         id,
         options: {},
         session,
    -    inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +    inbox: {
    +      nextTurn: [], nextStep: [],
    +    } as never,
         status: 'idle',
    -    ctx: new Context(),
    +    ctx,
         send: () => {},
         followup: () => {},
         steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
    @@ -34,114 +33,11 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent {
         cancel() {},
         runMaintenance: task => task(new AbortController().signal),
         whenIdle: () => Promise.resolve(),
    +    ...overrides,
       }
    -  return Object.assign(agent, overrides)
    +  return agent
     }
     
    -describe('Inbox', () => {
    -  it('rejects an invalid durable splice during reconstruction', () => {
    -    const session = Session.create(SessionId('invalid-inbox-replay'))
    -    session.append('agent/inbox/spliced', {
    -      target: 'next-turn',
    -      start: 1,
    -      inserted: [],
    -    })
    -
    -    expect(() => new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }))
    -      .toThrow('invalid persisted inbox splice at session seq 0')
    -  })
    -
    -  it('replaces a pending message by identity across both lists', () => {
    -    const session = Session.create(SessionId('replace-inbox'))
    -    const inserted: UserMessage[] = []
    -    const discarded: UserMessage[] = []
    -    const inbox = new Inbox(session, {
    -      claimed: () => {},
    -      inserted: message => void inserted.push(message),
    -      discarded: message => void discarded.push(message),
    -    })
    -    const original = createUserMessage({
    -      content: [{ type: 'text', text: 'original' }],
    -      source: { kind: 'user' },
    -    })
    -    const nextStep = createUserMessage({
    -      content: [{ type: 'text', text: 'step' }],
    -      source: { kind: 'user' },
    -    })
    -    const replacement = createUserMessage({
    -      content: [{ type: 'text', text: 'replacement' }],
    -      source: { kind: 'user' },
    -    })
    -    const editedStep = freezeMessage({
    -      ...nextStep,
    -      content: [{ type: 'text', text: 'edited step' }],
    -    })
    -    inbox.append('next-turn', original)
    -    inbox.append('next-step', nextStep)
    -
    -    expect(inbox.replace(createUserMessage({
    -      content: [{ type: 'text', text: 'missing' }],
    -      source: { kind: 'user' },
    -    }).id, replacement)).toBe(false)
    -    expect(inbox.replace(original.id, replacement)).toBe(true)
    -    expect(inbox.replace(nextStep.id, editedStep)).toBe(true)
    -    expect(inbox.nextTurn).toEqual([replacement])
    -    expect(inbox.nextStep).toEqual([editedStep])
    -    expect(discarded).toEqual([original, nextStep])
    -    expect(inserted).toEqual([original, nextStep, replacement, editedStep])
    -    expect(() => { inbox.replace(editedStep.id, replacement) })
    -      .toThrow(`message "${replacement.id}" is already pending`)
    -  })
    -
    -  it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', () => {
    -    const session = Session.create(SessionId('splice-inbox'))
    -    const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
    -    const first = createUserMessage({
    -      content: [{ type: 'text', text: 'first' }],
    -      source: { kind: 'user' },
    -    })
    -    const second = createUserMessage({
    -      content: [{ type: 'text', text: 'second' }],
    -      source: { kind: 'user' },
    -    })
    -
    -    inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second])
    -    expect(inbox.nextTurn).toEqual([first, second])
    -    expect(inbox.splice('next-turn', -1, 1, [])).toEqual([second])
    -    expect(inbox.remove(second.id)).toBe(false)
    -    expect(() => { inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`)
    -  })
    -
    -  it('clears both pending lists as durable cancellations', () => {
    -    const session = Session.create(SessionId('clear-inbox'))
    -    const discarded: UserMessage[] = []
    -    const inbox = new Inbox(session, {
    -      claimed: () => {},
    -      inserted: () => {},
    -      discarded: message => void discarded.push(message),
    -    })
    -    const nextTurn = createUserMessage({ content: [{ type: 'text', text: 'turn' }], source: { kind: 'user' } })
    -    const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } })
    -    inbox.append('next-turn', nextTurn)
    -    inbox.append('next-step', nextStep)
    -    const beforeClear = session.snapshotEvents().length
    -
    -    inbox.clear()
    -
    -    expect(inbox.hasPending).toBe(false)
    -    expect(discarded).toEqual([nextStep, nextTurn])
    -    expect(session.snapshotEvents().slice(beforeClear).map(event => event.type === 'agent/inbox/spliced'
    -      ? event.data
    -      : event.type)).toEqual([
    -      { target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
    -      { target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
    -    ])
    -
    -    inbox.clear()
    -    expect(session.snapshotEvents()).toHaveLength(beforeClear + 2)
    -  })
    -})
    -
     describe('AgentRegistry', () => {
       it('contributes Agent lookup and scoped Context providers while Typert is live', async () => {
         const ctx = new Context()
    diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json
    index f7aae0283e..068c1e6b8f 100644
    --- a/packages/core/agent/tsconfig.json
    +++ b/packages/core/agent/tsconfig.json
    @@ -23,15 +23,15 @@
         {
           "path": "../../core/session"
         },
    +    {
    +      "path": "../../session/session-projection"
    +    },
         {
           "path": "../../core/system-prompt"
         },
         {
           "path": "../../runtime-diagnostics/invariants"
         },
    -    {
    -      "path": "../../session/session-projection"
    -    },
         {
           "path": "../../typert/protocol"
         }
    diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json
    index b2c3649773..bc1689ad4e 100644
    --- a/packages/core/scope/package.json
    +++ b/packages/core/scope/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-scope",
       "description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/core/session/package.json b/packages/core/session/package.json
    index 006fe2693a..31706ba72b 100644
    --- a/packages/core/session/package.json
    +++ b/packages/core/session/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session",
       "description": "Event-sourced session store for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json
    index d743b7681b..c4a31d5990 100644
    --- a/packages/core/system-prompt/package.json
    +++ b/packages/core/system-prompt/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-system-prompt",
       "description": "System prompt assembly registry for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json
    index 4c1e01f423..b292b6eecd 100644
    --- a/packages/core/tools/package.json
    +++ b/packages/core/tools/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tools",
       "description": "Tool registry and execution pipeline for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/credentials/authorization/package.json b/packages/credentials/authorization/package.json
    index f12cdbcf6a..0b015ebfae 100644
    --- a/packages/credentials/authorization/package.json
    +++ b/packages/credentials/authorization/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-authorization",
       "description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json
    index 67b1e09781..268a6a5e70 100644
    --- a/packages/credentials/credentials-local/package.json
    +++ b/packages/credentials/credentials-local/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-credentials-local",
       "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json
    index f5276743fb..4e9c49e5ad 100644
    --- a/packages/credentials/credentials/package.json
    +++ b/packages/credentials/credentials/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-credentials",
       "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json
    index 701a9f79b3..8c0b15f0bf 100644
    --- a/packages/e2b/e2b/package.json
    +++ b/packages/e2b/e2b/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-e2b",
       "description": "Shared E2B sandbox lifecycle for DeepSeek Harness provider adapters",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -37,6 +37,7 @@
       "devDependencies": {
         "@deepseek-ai/cordis": "workspace:^",
         "@deepseek-ai/dsh-agent": "workspace:^",
    +    "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
         "@deepseek-ai/dsh-app-boot": "workspace:^",
         "@deepseek-ai/dsh-bash-local": "workspace:^",
         "@deepseek-ai/dsh-fs-e2b": "workspace:^",
    diff --git a/packages/e2b/e2b/tests/composition.e2e.ts b/packages/e2b/e2b/tests/composition.e2e.ts
    index dbbdff065e..de6f192bf3 100644
    --- a/packages/e2b/e2b/tests/composition.e2e.ts
    +++ b/packages/e2b/e2b/tests/composition.e2e.ts
    @@ -3,7 +3,6 @@ import { join, posix } from 'node:path'
     import { fileURLToPath } from 'node:url'
     import { Context } from '@deepseek-ai/cordis'
     import { describe, expect, it } from 'vitest'
    -import { Inbox } from '@deepseek-ai/dsh-agent'
     import type { Agent } from '@deepseek-ai/dsh-agent'
     import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
     import {
    @@ -17,6 +16,7 @@ import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
     import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import { Session, SessionId } from '@deepseek-ai/dsh-session'
     import E2BSubprocessRuntime from '@deepseek-ai/dsh-subprocess-e2b'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     const fixtureRoot = fileURLToPath(new URL('./fixtures/composition/', import.meta.url))
     const binScript = join(fixtureRoot, 'bin.ts')
    @@ -87,7 +87,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
             id: ownerId,
             options: {},
             session: ownerSession,
    -        inbox: new Inbox(ownerSession, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +        inbox: unsupportedInbox(),
             status: 'idle',
             ctx,
             send() {},
    diff --git a/packages/e2b/e2b/tests/fixtures/composition/bin.ts b/packages/e2b/e2b/tests/fixtures/composition/bin.ts
    index 4e4ffc120e..d762a2dda7 100644
    --- a/packages/e2b/e2b/tests/fixtures/composition/bin.ts
    +++ b/packages/e2b/e2b/tests/fixtures/composition/bin.ts
    @@ -1,8 +1,7 @@
     import { readFile } from 'node:fs/promises'
     import { resolve } from 'node:path'
     import { boot } from '@deepseek-ai/dsh-app-boot'
    -import { Inbox } from '@deepseek-ai/dsh-agent'
    -import type { Agent } from '@deepseek-ai/dsh-agent'
    +import { type Agent } from '@deepseek-ai/dsh-agent'
     import { Session, SessionId } from '@deepseek-ai/dsh-session'
     import type {} from '@deepseek-ai/dsh-fs-e2b'
     import type {} from '@deepseek-ai/dsh-bash-local'
    @@ -16,11 +15,23 @@ const ctx = await boot('e2b-composition', resolve(configPath))
     const ownerFiber = ctx.plugin(() => {})
     const ownerId = SessionId('e2b-live-owner')
     const session = Session.create(ownerId)
    +const unsupportedInboxMutation = (): never => {
    +  throw new Error('the E2B composition owner does not support Inbox mutations')
    +}
     const owner: Agent = {
       id: ownerId,
       options: {},
       session,
    -  inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +  inbox: {
    +    nextTurn: [],
    +    nextStep: [],
    +    clear: unsupportedInboxMutation,
    +    append: unsupportedInboxMutation,
    +    prepend: unsupportedInboxMutation,
    +    replace: unsupportedInboxMutation,
    +    remove: unsupportedInboxMutation,
    +    splice: unsupportedInboxMutation,
    +  },
       status: 'idle',
       ctx: ownerFiber.ctx,
       send() {},
    diff --git a/packages/e2b/fs-e2b/README.i18n.yaml b/packages/e2b/fs-e2b/README.i18n.yaml
    index dd50944ac8..f14ed73cd9 100644
    --- a/packages/e2b/fs-e2b/README.i18n.yaml
    +++ b/packages/e2b/fs-e2b/README.i18n.yaml
    @@ -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/e2b/fs-e2b/README.md
    -README.md: 3219bd55425da17781c84b69564bb71adccaf077
    -README.zh.md: 3bd31796c4d49af72c1795da1feea4efb9407318
    +README.md: 5a6106fb0a87fcab515207b38b4ae3d9aab7c5d6
    +README.zh.md: 0d2d796d69f3c934e9f518b9ce4c2c0cdb356241
    diff --git a/packages/e2b/fs-e2b/README.md b/packages/e2b/fs-e2b/README.md
    index 3219bd5542..5a6106fb0a 100644
    --- a/packages/e2b/fs-e2b/README.md
    +++ b/packages/e2b/fs-e2b/README.md
    @@ -44,7 +44,7 @@ Mounting it does not copy or mirror your local files — the sandbox's working d
     
     ### Reading files
     
    -The agent can read a file's whole contents, stream large files, or read raw bytes up to a size cap. Binary files and files that are not valid UTF-8 text are refused with a clear message instead of being garbled; reads past the size cap fail with a message naming the limit.
    +The agent can read a file's whole contents, stream large files, or read raw bytes up to a size cap or as one byte window. Binary files and files that are not valid UTF-8 text are refused with a clear message instead of being garbled; reads past the size cap fail with a message naming the limit.
     
     ### Writing and editing files
     
    diff --git a/packages/e2b/fs-e2b/README.zh.md b/packages/e2b/fs-e2b/README.zh.md
    index 3bd31796c4..0d2d796d69 100644
    --- a/packages/e2b/fs-e2b/README.zh.md
    +++ b/packages/e2b/fs-e2b/README.zh.md
    @@ -44,7 +44,7 @@ kind: "package-reference"
     
     ### 读取文件
     
    -agent 可以读取文件的完整内容、流式读取大文件,或在大小上限内读取原始字节。二进制文件与不是有效 UTF-8 文本的文件会被明确拒绝而不是乱码显示;超过大小上限的读取会以指明上限的消息失败。
    +agent 可以读取文件的完整内容、流式读取大文件,或在大小上限内或按字节窗口读取原始字节。二进制文件与不是有效 UTF-8 文本的文件会被明确拒绝而不是乱码显示;超过大小上限的读取会以指明上限的消息失败。
     
     ### 写入与编辑文件
     
    diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json
    index 21fad29c32..35822d80a7 100644
    --- a/packages/e2b/fs-e2b/package.json
    +++ b/packages/e2b/fs-e2b/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-fs-e2b",
       "description": "E2B filesystem implementation for DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/e2b/fs-e2b/src/index.ts b/packages/e2b/fs-e2b/src/index.ts
    index f78a382495..2d879adb0c 100644
    --- a/packages/e2b/fs-e2b/src/index.ts
    +++ b/packages/e2b/fs-e2b/src/index.ts
    @@ -292,6 +292,52 @@ export class E2BFileSystem extends FileSystem {
         return whole
       }
     
    +  override async readByteRange(target: FsTarget, range: { offset: number; length: number }, signal?: AbortSignal): Promise {
    +    const sandbox = await this.ctx.e2b.getSandbox()
    +    await this.requireRegular(target, signal)
    +    if (range.length === 0) return new Uint8Array(0)
    +    // The SDK streams only from the file's start: skip to `offset`, keep
    +    // `length` bytes, and cancel the stream there, so no more than the window
    +    // beyond the skipped prefix is ever transferred.
    +    const stream = await openReadStream(sandbox, target, signal)
    +    const reader = stream.getReader()
    +    const window = new Uint8Array(range.length)
    +    const end = range.offset + range.length
    +    let position = 0
    +    let filled = 0
    +    let drained = false
    +    try {
    +      while (filled < range.length) {
    +        assertNotAborted(signal, 'read')
    +        const next = await reader.read()
    +        if (next.done) {
    +          drained = true
    +          break
    +        }
    +        const from = Math.max(range.offset, position)
    +        const to = Math.min(end, position + next.value.byteLength)
    +        if (to > from) {
    +          window.set(next.value.subarray(from - position, to - position), filled)
    +          filled += to - from
    +        }
    +        position += next.value.byteLength
    +      }
    +    } catch (error: unknown) {
    +      throw mapError(error, 'read', target.displayPath, signal)
    +    } finally {
    +      if (!drained) {
    +        try {
    +          await reader.cancel()
    +        } catch (_streamCancellationFailure) {
    +          // The window is complete or the read already failed; a cancellation
    +          // failure on the abandoned remote stream adds nothing actionable.
    +        }
    +      }
    +      reader.releaseLock()
    +    }
    +    return filled === range.length ? window : window.subarray(0, filled)
    +  }
    +
       override async streamText(target: FsTarget, signal?: AbortSignal): Promise> {
         const sandbox = await this.ctx.e2b.getSandbox()
         await this.requireRegular(target, signal)
    diff --git a/packages/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts
    index 516e3e6e39..09b0a8e26b 100644
    --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts
    +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts
    @@ -171,6 +171,8 @@ class FakeRemote {
               start: (controller) => {
                 for (const chunk of chunks) controller.enqueue(chunk)
                 if (!this.streamKeepOpen) controller.close()
    +            // SDK fidelity: an abort of the request signal fails the open stream.
    +            options.signal?.addEventListener('abort', () => { controller.error(new DOMException('aborted', 'AbortError')) }, { once: true })
               },
               cancel: () => { this.streamCancel() },
             })
    @@ -508,6 +510,57 @@ describe('E2BFileSystem identity, metadata, and reads', () => {
         expect((await fs.readBytes(await fs.resolve('empty.bin'), undefined, 4)).byteLength).toBe(0)
       })
     
    +  it('readByteRange skips to the offset, keeps the window, and cancels the stream there', async () => {
    +    const remote = new FakeRemote()
    +    remote.file('/workspace/ramp.bin', [1, 2, 3, 4, 5, 6, 7, 8, 9])
    +    const { fs } = await setup(remote)
    +    const target = await fs.resolve('ramp.bin')
    +    remote.streamChunks = [bytes([1, 2, 3]), bytes([4, 5, 6]), bytes([7, 8, 9])]
    +    remote.streamKeepOpen = true
    +    expect(Array.from(await fs.readByteRange(target, { offset: 4, length: 3 }))).toEqual([5, 6, 7])
    +    expect(remote.reads).toEqual([{ path: '/workspace/ramp.bin', format: 'stream' }])
    +    expect(remote.streamCancel).toHaveBeenCalledOnce()
    +  })
    +
    +  it('readByteRange shortens at the end, empties past it, and skips the read for length 0', async () => {
    +    const remote = new FakeRemote()
    +    remote.file('/workspace/ramp.bin', [1, 2, 3, 4, 5, 6, 7, 8, 9])
    +    remote.dir('/workspace/directory')
    +    const { fs } = await setup(remote)
    +    const target = await fs.resolve('ramp.bin')
    +    remote.streamChunks = [bytes([1, 2, 3]), bytes([4, 5, 6]), bytes([7, 8, 9])]
    +    expect(Array.from(await fs.readByteRange(target, { offset: 7, length: 10 }))).toEqual([8, 9])
    +    expect(remote.streamCancel).not.toHaveBeenCalled()
    +    expect((await fs.readByteRange(target, { offset: 9, length: 2 })).byteLength).toBe(0)
    +    remote.reads.length = 0
    +    expect((await fs.readByteRange(target, { offset: 0, length: 0 })).byteLength).toBe(0)
    +    expect(remote.reads).toEqual([])
    +    await expectCode(fs.readByteRange(await fs.resolve('missing'), { offset: 0, length: 1 }), 'FS_NOT_FOUND')
    +    await expectCode(fs.readByteRange(await fs.resolve('directory'), { offset: 0, length: 1 }), 'FS_NOT_REGULAR_FILE')
    +  })
    +
    +  it('readByteRange maps a failing open, an abort mid-stream, and tolerates a failing cancel', async () => {
    +    const remote = new FakeRemote()
    +    remote.file('/workspace/ramp.bin', [1, 2, 3, 4])
    +    const { fs } = await setup(remote)
    +    const target = await fs.resolve('ramp.bin')
    +    remote.nextReadError = new DOMException('aborted', 'AbortError')
    +    await expectCode(fs.readByteRange(target, { offset: 0, length: 2 }), 'FS_ABORTED')
    +
    +    // The window wants more than the one chunk delivered; the abort fails the open stream.
    +    remote.streamChunks = [bytes([1])]
    +    remote.streamKeepOpen = true
    +    const controller = new AbortController()
    +    const pending = fs.readByteRange(target, { offset: 0, length: 4 }, controller.signal)
    +    await new Promise((resolve) => { setTimeout(resolve, 0) })
    +    controller.abort()
    +    await expectCode(pending, 'FS_ABORTED')
    +
    +    remote.streamChunks = [bytes([1, 2, 3, 4])]
    +    remote.streamCancel.mockRejectedValueOnce(new Error('cancel failed'))
    +    expect(Array.from(await fs.readByteRange(target, { offset: 1, length: 2 }))).toEqual([2, 3])
    +  })
    +
       it('honors aborts before and during remote reads', async () => {
         const remote = new FakeRemote()
         remote.file('/workspace/a', 'a')
    diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json
    index d1d40d16ce..4c343a8c7d 100644
    --- a/packages/e2b/subprocess-e2b/package.json
    +++ b/packages/e2b/subprocess-e2b/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-subprocess-e2b",
       "description": "E2B subprocess implementation for DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json
    index cefca03207..9948372966 100644
    --- a/packages/experimental/agent-team-profile/package.json
    +++ b/packages/experimental/agent-team-profile/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-experimental-agent-team-profile",
       "description": "Private profile bundle enabling Agent Teams over dsh-base",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "private": true,
       "repository": {
         "type": "git",
    diff --git a/packages/experimental/agent-team-web-profile/package.json b/packages/experimental/agent-team-web-profile/package.json
    index 0707387952..8523220425 100644
    --- a/packages/experimental/agent-team-web-profile/package.json
    +++ b/packages/experimental/agent-team-web-profile/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-experimental-agent-team-web-profile",
       "description": "Private Web profile layer for Agent Teams Remote and UI plugins",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "private": true,
       "repository": {
         "type": "git",
    diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json
    index 7e31c91a7c..89b241a6ac 100644
    --- a/packages/experimental/agent-team/package.json
    +++ b/packages/experimental/agent-team/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-experimental-agent-team",
       "description": "Implicit-root Agent Teams roster, durable peer mailbox, and shared task DAG",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "private": true,
       "repository": {
         "type": "git",
    diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts
    index 7fc870e5db..6ff838ddf0 100644
    --- a/packages/experimental/agent-team/tests/persistence.spec.ts
    +++ b/packages/experimental/agent-team/tests/persistence.spec.ts
    @@ -8,9 +8,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
     import AgentLoop from '@deepseek-ai/dsh-agent-loop'
     import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
     import { createUserMessage } from '@deepseek-ai/dsh-llm'
    -import { SessionId } from '@deepseek-ai/dsh-session'
    -import type { SessionEvent } from '@deepseek-ai/dsh-session'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
    +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
     import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
     import SubagentService, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
     import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
    @@ -102,7 +100,6 @@ async function stack(
       const ctx = new Context()
       contexts.add(ctx)
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
       await backend.mount(ctx, root)
       await ctx.plugin(TestSessionQuery)
       await ctx.plugin(AgentLoop, { agents: [] })
    diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts
    index 321192432f..c48190f421 100644
    --- a/packages/experimental/agent-team/tests/team.spec.ts
    +++ b/packages/experimental/agent-team/tests/team.spec.ts
    @@ -8,7 +8,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
     import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
     import { createUserMessage } from '@deepseek-ai/dsh-llm'
     import { SessionLogOffset, SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
     import SubagentService from '@deepseek-ai/dsh-subagent'
     import { deliverSubagentPrompt, type HostPromptDeliverer } from '@deepseek-ai/dsh-subagent/internal'
    @@ -62,7 +61,6 @@ async function setup(
     ) {
       const ctx = new Context()
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
       const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-'))
       roots.push(storageRoot)
       await ctx.plugin(JsonlSessionPersistence, { root: storageRoot })
    @@ -170,7 +168,6 @@ describe('Team identity and provisioning', () => {
       it('supports direct-constructor defaults and recovers roots that already exist', async () => {
         const ctx = new Context()
         await mountAgentLoopTestDependencies(ctx)
    -    await ctx.plugin(SessionProjectionRegistry)
         const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-direct-'))
         roots.push(storageRoot)
         await ctx.plugin(JsonlSessionPersistence, { root: storageRoot })
    @@ -1411,7 +1408,6 @@ describe('Team mailbox and waiting', () => {
       it('waits for one change, supports cancellation, times out, and releases waiters on HMR disposal', async () => {
         const ctx = new Context()
         await mountAgentLoopTestDependencies(ctx)
    -    await ctx.plugin(SessionProjectionRegistry)
         const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-wait-'))
         roots.push(storageRoot)
         await ctx.plugin(JsonlSessionPersistence, { root: storageRoot })
    diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json
    index 8b9f874627..072377641e 100644
    --- a/packages/experimental/client-ui-agent-team/package.json
    +++ b/packages/experimental/client-ui-agent-team/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-experimental-client-ui-agent-team",
       "description": "Web Agent Teams roster, task board, and teammate navigation",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "private": true,
       "repository": {
         "type": "git",
    diff --git a/packages/experimental/code-runtime-python/package.json b/packages/experimental/code-runtime-python/package.json
    index 150b71cb1a..f568ef1b56 100644
    --- a/packages/experimental/code-runtime-python/package.json
    +++ b/packages/experimental/code-runtime-python/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-experimental-code-runtime-python",
       "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "repository": {
         "type": "git",
         "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
    diff --git a/packages/experimental/inspector/package.json b/packages/experimental/inspector/package.json
    index 0423299bc5..88e5c45059 100644
    --- a/packages/experimental/inspector/package.json
    +++ b/packages/experimental/inspector/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-experimental-inspector",
       "description": "Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "private": true,
       "repository": {
         "type": "git",
    diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json
    index 978c2922e6..c155c65a14 100644
    --- a/packages/experimental/tool-agent-team/package.json
    +++ b/packages/experimental/tool-agent-team/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-experimental-tool-agent-team",
       "description": "Scoped model-facing Agent Teams tools over ctx.agentTeams",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "private": true,
       "repository": {
         "type": "git",
    diff --git a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts
    index dffad3b4d7..6ae2fddca3 100644
    --- a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts
    +++ b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts
    @@ -9,7 +9,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
     import { ToolCallId } from '@deepseek-ai/dsh-llm'
     import { scopeOf } from '@deepseek-ai/dsh-scope'
     import { SessionId } from '@deepseek-ai/dsh-session'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
     import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
     import SubagentService from '@deepseek-ai/dsh-subagent'
    @@ -56,7 +55,6 @@ afterEach(() => {
     async function setup(script: ConstructorParameters[0], legacyControl = false) {
       const ctx = new Context()
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
       const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-tool-team-'))
       roots.push(storageRoot)
       await ctx.plugin(JsonlSessionPersistence, { root: storageRoot })
    diff --git a/packages/experimental/webworker-packer/package.json b/packages/experimental/webworker-packer/package.json
    index 5fc68aa9d1..24bd3520ea 100644
    --- a/packages/experimental/webworker-packer/package.json
    +++ b/packages/experimental/webworker-packer/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-experimental-webworker-packer",
       "description": "Build-time packer for the browser runtime's base VFS image and ordered data-overlay archives",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "private": true,
       "repository": {
         "type": "git",
    diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json
    index 261524be98..fa34ea105c 100644
    --- a/packages/experimental/webworker-runtime/package.json
    +++ b/packages/experimental/webworker-runtime/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-experimental-webworker-runtime",
       "description": "Browser-only harness runtime: in-memory VFS, module transform and loader, postMessage tunnel, and the dedicated Web Worker assembly, with the Node-compatibility layer that lets the host tree run unchanged",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "private": true,
       "repository": {
         "type": "git",
    diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json
    index 84fc670e75..3cbb614a02 100644
    --- a/packages/extensions/cordis-client-runner/package.json
    +++ b/packages/extensions/cordis-client-runner/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-cordis-client-runner",
       "description": "Browser half of dynamic dual-half plugin packages: event subscription, closure evaluation, guard facade, and loader entries",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
    index 4fc5def3e6..47bf4af64a 100644
    --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
    +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
    @@ -92,13 +92,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
             parameters: [],
           },
           {
    -        signature: 'openDetails(): void',
    -        description: 'Open the details panel (no-op when already open).',
    -        parameters: [],
    +        signature: 'openRightbar(track: boolean, fullscreen: boolean): void',
    +        description: 'Report the right panel\'s presentation without changing its expanded state.',
    +        parameters: [{ name: 'track', description: 'whether the normal panel width reserves a grid track, including beneath a fullscreen overlay.' }, { name: 'fullscreen', description: 'whether the panel covers the frame and hides its outer resize handle; independent of the underlying grid track.' }],
           },
           {
    -        signature: 'closeDetails(): void',
    -        description: 'Close the details panel.',
    +        signature: 'closeRightbar(): void',
    +        description: 'Report the right panel as hidden: no track, no handle.',
             parameters: [],
           },
         ],
    diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
    index 4fa57cbf0e..23582cf6e3 100644
    --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
    +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
    @@ -91,6 +91,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -125,6 +126,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -182,6 +184,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
           'MessageId',
         ],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -204,7 +207,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         replaceRisk: 'none',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n      { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-chat/src/client/contract/slots.ts:227',
    +    source: 'packages/client/ui-chat/src/client/contract/slots.ts:212',
       },
       {
         key: 'conversation.chat.commandview',
    @@ -229,6 +232,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
           'CompactionSummaryNode',
         ],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -249,7 +253,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         occupants: [],
         replaceRisk: 'none',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n      { name: \'conversation.chat.commandview\', key: \'\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-chat/src/client/contract/slots.ts:215',
    +    source: 'packages/client/ui-chat/src/client/contract/slots.ts:200',
       },
       {
         key: 'conversation.chat.node',
    @@ -266,16 +270,18 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
           },
         ],
         ownerProps: [
    -      '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n  selectedCallId?: ToolCallId | undefined\n  cwd?: string | undefined\n  openFile: (path: string) => void\n  inspectCall: (callId: ToolCallId) => void\n  forkAt: (seq: number) => void\n  /**\n   * Session-authorized image loader, down-threaded from the Chat view so a\n   * chat-node renderer can render the attachment presentation slot directly\n   * with only the durable references plus this loader, instead of receiving a\n   * rendering closure.\n   */\n  loadImage: MessageImageLoader\n  renderMessageImages: RenderMessageImages\n  fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n  /** Turn-process state when this Node belongs to a projected Turn. */\n  turnProcess?: TurnProcessOwnerProps | undefined\n}',
    +      '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n  cwd?: string | undefined\n  openFile: (path: string, options?: OpenFileOptions) => void\n  inspectCall: (callId: ToolCallId) => void\n  forkAt: (seq: number) => void\n  /**\n   * Session-authorized image loader, down-threaded from the Chat view so a\n   * chat-node renderer can render the attachment presentation slot directly\n   * with only the durable references plus this loader, instead of receiving a\n   * rendering closure.\n   */\n  loadImage: MessageImageLoader\n  renderMessageImages: RenderMessageImages\n  fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n  /** Turn-process state when this Node belongs to a projected Turn. */\n  turnProcess?: TurnProcessOwnerProps | undefined\n}',
         ],
         ownerPropsReferences: [
           'MarkdownFileMentions',
           'MessageImageLoader',
    +      'OpenFileOptions',
           'RenderMessageImages',
           'TurnProcessOwnerProps',
           'TurnTailOwnerProps',
         ],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -314,7 +320,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         replaceRisk: 'shadows-shipped-ui',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n      { name: \'conversation.chat.node\', key: \'\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-chat/src/client/contract/slots.ts:196',
    +    source: 'packages/client/ui-chat/src/client/contract/slots.ts:181',
       },
       {
         key: 'conversation.chat.turnTail',
    @@ -337,6 +343,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
           'TurnLocation',
         ],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -359,7 +366,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         replaceRisk: 'none',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n      { name: \'conversation.chat.turnTail\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-chat/src/client/contract/slots.ts:221',
    +    source: 'packages/client/ui-chat/src/client/contract/slots.ts:206',
       },
       {
         key: 'conversation.composer',
    @@ -384,6 +391,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
           'SessionSnapshot',
         ],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -408,7 +416,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         replaceRisk: 'none',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n      { name: \'conversation.composer\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:145',
    +    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157',
       },
       {
         key: 'conversation.composer.bar',
    @@ -424,6 +432,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
           'Workspace',
         ],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -444,7 +453,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         replaceRisk: 'shadows-shipped-ui',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n      { name: \'conversation.composer.bar\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163',
    +    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:175',
       },
       {
         key: 'conversation.composer.dock',
    @@ -475,6 +484,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ownerProps: [],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -497,43 +507,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         replaceRisk: 'none',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n      { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157',
    -  },
    -  {
    -    key: 'conversation.details.tool',
    -    kind: 'single',
    -    scope: 'session',
    -    summary: 'Whole details-panel body for the selected Tool call.',
    -    doc: 'Whole details-panel body for the selected Tool call. The component receives\nthe running or settled block and optional workspace root. A registration\nreplaces the shipped Tool details renderer; absence uses the raw fallback.',
    -    registerOptions: [],
    -    ownerProps: [
    -      '/** Tool block rendered in the details panel. */\nexport interface DetailsToolOwnerProps {\n  block: ToolCallBlock\n  cwd?: string | undefined\n}',
    -    ],
    -    ownerPropsReferences: [],
    -    standardProps: [
    -      'useWorkspaces: SnapshotSelectorHook',
    -      'useSessions: UseSessions',
    -      'useSessionPendingInteraction: UseSessionPendingInteraction',
    -      'useWorkspaces: SnapshotSelectorHook',
    -      'useChat: UseChat',
    -      'useConversation: UseConversation',
    -      'useInput: SnapshotSelectorHook',
    -      'inputActions: InputActions',
    -      'useSession: SessionSnapshotSelector',
    -      'sessionId: SessionId',
    -      'useProjection: UseProjection',
    -      'useTrajectory: UseTrajectory',
    -    ],
    -    keyDomain: '',
    -    hookContext: '',
    -    slotInject: '',
    -    declaredBy: 'an entry in \'details\' (client-ui-chat), so it exists while that entry is mounted',
    -    occupants: [
    -      'client-ui-tool ToolDetails',
    -    ],
    -    replaceRisk: 'shadows-shipped-ui',
    -    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n      { name: \'conversation.details.tool\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-chat/src/client/contract/slots.ts:233',
    +    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:169',
       },
       {
         key: 'conversation.hero.agentPreset',
    @@ -547,6 +521,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -561,7 +536,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         replaceRisk: 'shadows-shipped-ui',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n      { name: \'conversation.hero.agentPreset\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:151',
    +    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163',
       },
       {
         key: 'conversation.hero.brand.mark',
    @@ -575,6 +550,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -587,7 +563,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         occupants: [],
         replaceRisk: 'none',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n      { name: \'conversation.hero.brand.mark\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:149',
    +    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161',
       },
       {
         key: 'conversation.hero.workspace',
    @@ -603,6 +579,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
           'Workspace',
         ],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -617,7 +594,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         replaceRisk: 'shadows-shipped-ui',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n      { name: \'conversation.hero.workspace\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:147',
    +    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:159',
       },
       {
         key: 'conversation.hero.workspace.directoryFlow',
    @@ -631,6 +608,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -664,6 +642,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
           'DraftFileUploads',
         ],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -684,7 +663,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         replaceRisk: 'shadows-shipped-ui',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n      { name: \'conversation.input.attachments\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:165',
    +    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:177',
       },
       {
         key: 'conversation.input.dock',
    @@ -720,6 +699,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
           'SessionSnapshot',
         ],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -744,7 +724,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         replaceRisk: 'none',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n      { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:153',
    +    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:165',
       },
       {
         key: 'conversation.input.left',
    @@ -775,6 +755,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ownerProps: [],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -795,7 +776,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         occupants: [],
         replaceRisk: 'none',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n      { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:159',
    +    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171',
       },
       {
         key: 'conversation.input.model',
    @@ -809,6 +790,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -831,7 +813,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         replaceRisk: 'shadows-shipped-ui',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n      { name: \'conversation.input.model\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173',
    +    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185',
       },
       {
         key: 'conversation.input.overlay',
    @@ -862,6 +844,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ownerProps: [],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -885,7 +868,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         replaceRisk: 'none',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n      { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:155',
    +    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:167',
       },
       {
         key: 'conversation.input.plan',
    @@ -899,6 +882,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -921,7 +905,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         replaceRisk: 'shadows-shipped-ui',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n      { name: \'conversation.input.plan\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171',
    +    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:183',
       },
       {
         key: 'conversation.input.right',
    @@ -952,6 +936,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ownerProps: [],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -972,7 +957,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         occupants: [],
         replaceRisk: 'none',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n      { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161',
    +    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173',
       },
       {
         key: 'conversation.message.images',
    @@ -990,6 +975,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
           'MessageImageSource',
         ],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1012,7 +998,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         replaceRisk: 'shadows-shipped-ui',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n      { name: \'conversation.message.images\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-chat/src/client/contract/slots.ts:209',
    +    source: 'packages/client/ui-chat/src/client/contract/slots.ts:194',
       },
       {
         key: 'conversation.session',
    @@ -1024,6 +1010,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ownerProps: [],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1058,6 +1045,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ownerProps: [],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1113,6 +1101,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1140,6 +1129,43 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n      { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
         source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131',
       },
    +  {
    +    key: 'conversation.session.header.corner',
    +    kind: 'single',
    +    scope: 'session',
    +    summary: 'The header\'s far-right corner, past the utilities\' edge and into the header\'s own padding, for one control that must keep its place whether or not it currently shows anything.',
    +    doc: 'The header\'s far-right corner, past the utilities\' edge and into the\nheader\'s own padding, for one control that must keep its place whether or\nnot it currently shows anything. The corner reserves its width while an\noccupant is registered, so the utilities beside it never move; an\noccupant with nothing to show renders a same-size placeholder.',
    +    registerOptions: [],
    +    ownerProps: [
    +      '/** The header corner\'s occupant derives its state from standard Session props. */\nexport interface ConversationHeaderCornerOwnerProps {\n  /** Marker field: the occupant receives no owner-specific values. */\n  children?: never\n}',
    +    ],
    +    ownerPropsReferences: [],
    +    standardProps: [
    +      'useResource: UseResource',
    +      'useWorkspaces: SnapshotSelectorHook',
    +      'useSessions: UseSessions',
    +      'useSessionPendingInteraction: UseSessionPendingInteraction',
    +      'useWorkspaces: SnapshotSelectorHook',
    +      'useChat: UseChat',
    +      'useConversation: UseConversation',
    +      'useInput: SnapshotSelectorHook',
    +      'inputActions: InputActions',
    +      'useSession: SessionSnapshotSelector',
    +      'sessionId: SessionId',
    +      'useProjection: UseProjection',
    +      'useTrajectory: UseTrajectory',
    +    ],
    +    keyDomain: '',
    +    hookContext: '',
    +    slotInject: '',
    +    declaredBy: 'an entry in \'conversation.session.header\' (client-ui-conversation), so it exists while that entry is mounted',
    +    occupants: [
    +      'client-ui-sidebar-right ExpandButton',
    +    ],
    +    replaceRisk: 'shadows-shipped-ui',
    +    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n      { name: \'conversation.session.header.corner\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    +    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:149',
    +  },
       {
         key: 'conversation.session.header.lineage',
         kind: 'single',
    @@ -1154,6 +1180,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
           'SessionId',
         ],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1209,6 +1236,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1250,6 +1278,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
           'MessageImageSource',
         ],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1307,6 +1336,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
           'ConversationViewRequest',
         ],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1330,20 +1360,21 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         replaceRisk: 'none',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n      { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:143',
    +    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:155',
       },
       {
    -    key: 'details',
    +    key: 'rightbar',
         kind: 'single',
         scope: 'session',
    -    summary: 'The right details column, shown when the layout opens it.',
    -    doc: 'The right details column, shown when the layout opens it. OCCUPIED by\nui-conversation\'s DetailsPanel, which declares the tool-details seat\ninside it — registering here replaces the column and takes that seat\nwith it. Absent an occupant the column renders nothing.\n\nNo owner props: the framework injects the session id and hooks for the\n`session` scope, and `ctx.layout` owns whether the column is open.',
    +    summary: 'The right column: a track the centre makes room for, or nothing.',
    +    doc: 'The right column: a track the centre makes room for, or nothing. OCCUPIED\nby the right Sidebar, which uses the resolved column width in normal\nmode and covers the viewport in fullscreen, retaining the wide-screen\ncolumn reservation underneath.\n\nWhether the panel is shown, and whether it takes a track, is the\noccupant\'s own recorded business — it reports the composition of its\nexpanded and presentation state through `ctx.layout`, and the frame sizes\nthe track and places the resize handle from that. The expand control is\nnot this column\'s: it is a button in the conversation header. With no\ncurrent session nothing is mounted here.',
         registerOptions: [],
         ownerProps: [
    -      '/** Details owner share: empty — sessionId arrives as a framework-standard prop. */\nexport interface DetailsOwnerProps {}',
    +      '/** Right column owner share: resolved normal geometry and opening eligibility. */\nexport interface RightbarOwnerProps {\n  /** Resolved normal panel width in px, not the saved preference; zero if it cannot fit. */\n  width: number\n  /** Current frame width in px. */\n  viewportWidth: number\n  /**\n   * Whether a normal right panel can retain 300px beside a 400px center.\n   * Before a narrow opening, includes the space from collapsing the left sidebar.\n   */\n  canShow: boolean\n}',
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1362,11 +1393,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         slotInject: '',
         declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted',
         occupants: [
    -      'client-ui-chat DetailsPanel',
    +      'client-ui-sidebar-right RightbarSeat',
         ],
         replaceRisk: 'shadows-shipped-ui',
    -    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'details\', () => ctx.slots.register(\n      { name: \'details\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-layout/src/client/index.ts:75',
    +    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'rightbar\', () => ctx.slots.register(\n      { name: \'rightbar\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    +    source: 'packages/client/ui-layout/src/client/index.ts:79',
       },
       {
         key: 'root',
    @@ -1380,6 +1411,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1427,6 +1459,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1455,6 +1488,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1502,6 +1536,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1535,6 +1570,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1582,6 +1618,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1617,6 +1654,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
           'ProviderDirectoryEntry',
         ],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1662,6 +1700,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1698,6 +1737,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1748,6 +1788,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1796,6 +1837,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1827,6 +1869,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1872,6 +1915,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ownerProps: [],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1884,7 +1928,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         occupants: [],
         replaceRisk: 'none',
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n      { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    -    source: 'packages/client/ui-layout/src/client/index.ts:86',
    +    source: 'packages/client/ui-layout/src/client/index.ts:90',
       },
       {
         key: 'sidebar',
    @@ -1898,6 +1942,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1926,6 +1971,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -1954,6 +2000,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -2001,6 +2048,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -2017,6 +2065,186 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.footer.action\', () => ctx.slots.register(\n      { name: \'sidebar.footer.action\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
         source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:46',
       },
    +  {
    +    key: 'sidebar.right.pane.tab',
    +    kind: 'keyed',
    +    scope: 'session',
    +    summary: 'One tab\'s body, dispatched with the `id` of the type in force for `tab.kind`.',
    +    doc: 'One tab\'s body, dispatched with the `id` of the type in force for\n`tab.kind`. A tab type registers here under its definition\'s `id` and\nreceives every tab of that kind, in every pane, docked or floating. A kind\nwith no type in force renders the owner\'s "nothing can view this" notice\nrather than an empty pane.',
    +    registerOptions: [
    +      {
    +        name: 'key',
    +        requirement: 'required',
    +        type: 'string',
    +        doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.',
    +      },
    +    ],
    +    ownerProps: [],
    +    ownerPropsReferences: [],
    +    standardProps: [
    +      'useResource: UseResource',
    +      'useWorkspaces: SnapshotSelectorHook',
    +      'useSessions: UseSessions',
    +      'useSessionPendingInteraction: UseSessionPendingInteraction',
    +      'useWorkspaces: SnapshotSelectorHook',
    +      'useChat: UseChat',
    +      'useConversation: UseConversation',
    +      'useInput: SnapshotSelectorHook',
    +      'inputActions: InputActions',
    +      'useSession: SessionSnapshotSelector',
    +      'sessionId: SessionId',
    +      'useProjection: UseProjection',
    +      'useTrajectory: UseTrajectory',
    +    ],
    +    keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet',
    +    hookContext: 'TabHookContext',
    +    slotInject: 'SidebarRightTabInjected',
    +    declaredBy: 'an entry in \'rightbar\' (client-ui-sidebar-right), so it exists while that entry is mounted',
    +    occupants: [
    +      'client-ui-sidebar-files FilesBody',
    +      'client-ui-sidebar-right GuideBody',
    +      'client-ui-sidebar-textpreview TextPreview',
    +    ],
    +    replaceRisk: 'none',
    +    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.right.pane.tab\', () => ctx.slots.register(\n      { name: \'sidebar.right.pane.tab\', key: \'\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    +    source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:47',
    +  },
    +  {
    +    key: 'sidebar.right.pane.tab.title',
    +    kind: 'keyed',
    +    scope: 'session',
    +    summary: 'A tab\'s title as its chip (and a floating panel\'s header) shows it, dispatched with the same key and information hook as the body.',
    +    doc: 'A tab\'s title as its chip (and a floating panel\'s header) shows it,\ndispatched with the same key and information hook as the body. A type with a\nlive title — a terminal named after its shell, a chat after its first\nline — registers here and reads its own store; one without registers\nnothing and the chip shows the registry\'s `title(address)` text captured\nat open time.',
    +    registerOptions: [
    +      {
    +        name: 'key',
    +        requirement: 'required',
    +        type: 'string',
    +        doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.',
    +      },
    +    ],
    +    ownerProps: [],
    +    ownerPropsReferences: [],
    +    standardProps: [
    +      'useResource: UseResource',
    +      'useWorkspaces: SnapshotSelectorHook',
    +      'useSessions: UseSessions',
    +      'useSessionPendingInteraction: UseSessionPendingInteraction',
    +      'useWorkspaces: SnapshotSelectorHook',
    +      'useChat: UseChat',
    +      'useConversation: UseConversation',
    +      'useInput: SnapshotSelectorHook',
    +      'inputActions: InputActions',
    +      'useSession: SessionSnapshotSelector',
    +      'sessionId: SessionId',
    +      'useProjection: UseProjection',
    +      'useTrajectory: UseTrajectory',
    +    ],
    +    keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet',
    +    hookContext: 'TabHookContext',
    +    slotInject: 'SidebarRightTabInjected',
    +    declaredBy: 'an entry in \'rightbar\' (client-ui-sidebar-right), so it exists while that entry is mounted',
    +    occupants: [],
    +    replaceRisk: 'none',
    +    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.right.pane.tab.title\', () => ctx.slots.register(\n      { name: \'sidebar.right.pane.tab.title\', key: \'\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    +    source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:61',
    +  },
    +  {
    +    key: 'sidebar.right.tab.guide',
    +    kind: 'chain',
    +    scope: 'session',
    +    summary: 'The guide tab\'s body.',
    +    doc: 'The guide tab\'s body. Selectors run in chain order and the first\nnon-declining entry replaces the shipped guide entirely; with no entry, or\nwith every entry declining, the shipped guide renders.',
    +    registerOptions: [
    +      {
    +        name: 'select',
    +        requirement: 'required',
    +        type: '(owner) => unknown | null',
    +        doc: 'Pure routing selector. Entries are tried in ascending order; the first non-null result wins and arrives as the component\'s `matched` prop. All-null falls through to the owner\'s fallback.',
    +      },
    +    ],
    +    ownerProps: [],
    +    ownerPropsReferences: [],
    +    standardProps: [
    +      'useResource: UseResource',
    +      'useWorkspaces: SnapshotSelectorHook',
    +      'useSessions: UseSessions',
    +      'useSessionPendingInteraction: UseSessionPendingInteraction',
    +      'useWorkspaces: SnapshotSelectorHook',
    +      'useChat: UseChat',
    +      'useConversation: UseConversation',
    +      'useInput: SnapshotSelectorHook',
    +      'inputActions: InputActions',
    +      'useSession: SessionSnapshotSelector',
    +      'sessionId: SessionId',
    +      'useProjection: UseProjection',
    +      'useTrajectory: UseTrajectory',
    +    ],
    +    keyDomain: '',
    +    hookContext: 'UseSidebarRightTabInfo',
    +    slotInject: '{ hooks: { tabInfo: SlotHookFactory<\'sidebar.right.tab.guide\', UseSidebarRightTabInfo> } }',
    +    declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-sidebar-right), so it exists while that entry is mounted',
    +    occupants: [],
    +    replaceRisk: 'none',
    +    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.right.tab.guide\', () => ctx.slots.register(\n      { name: \'sidebar.right.tab.guide\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    +    source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:72',
    +  },
    +  {
    +    key: 'sidebar.right.tab.menu.item',
    +    kind: 'list',
    +    scope: 'session',
    +    summary: 'Extra items at the end of one tab\'s actions menu, in registration order.',
    +    doc: 'Extra items at the end of one tab\'s actions menu, in registration order.\nEntries decide their own visibility from the tab they are given. Without a\nregistrant the menu shows only the kit\'s own layout actions.',
    +    registerOptions: [
    +      {
    +        name: 'id',
    +        requirement: 'required',
    +        type: 'string',
    +        doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.',
    +      },
    +      {
    +        name: 'order',
    +        requirement: 'optional',
    +        type: 'number',
    +        doc: 'Position among the entries, ascending (default 0).',
    +      },
    +      {
    +        name: 'label',
    +        requirement: 'optional',
    +        type: 'string | (() => string)',
    +        doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.',
    +      },
    +    ],
    +    ownerProps: [
    +      '/** Owner share of one tab-menu item occurrence. */\nexport interface SidebarRightTabMenuOwnerProps {\n  /** The tab whose menu is open. */\n  tab: TabRecord\n  /**\n   * Dismiss the menu.\n   *\n   * An item that acts MUST call this: the menu is the kit\'s, and it closes on\n   * its own actions only. An item that leaves it open leaves a menu floating\n   * over content the action may have just replaced.\n   */\n  dismiss: () => void\n}',
    +    ],
    +    ownerPropsReferences: [
    +      'TabRecord',
    +    ],
    +    standardProps: [
    +      'useResource: UseResource',
    +      'useWorkspaces: SnapshotSelectorHook',
    +      'useSessions: UseSessions',
    +      'useSessionPendingInteraction: UseSessionPendingInteraction',
    +      'useWorkspaces: SnapshotSelectorHook',
    +      'useChat: UseChat',
    +      'useConversation: UseConversation',
    +      'useInput: SnapshotSelectorHook',
    +      'inputActions: InputActions',
    +      'useSession: SessionSnapshotSelector',
    +      'sessionId: SessionId',
    +      'useProjection: UseProjection',
    +      'useTrajectory: UseTrajectory',
    +    ],
    +    keyDomain: '',
    +    hookContext: '',
    +    slotInject: '',
    +    declaredBy: 'an entry in \'rightbar\' (client-ui-sidebar-right), so it exists while that entry is mounted',
    +    occupants: [],
    +    replaceRisk: 'none',
    +    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.right.tab.menu.item\', () => ctx.slots.register(\n      { name: \'sidebar.right.tab.menu.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
    +    source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:83',
    +  },
       {
         key: 'sidebar.settings',
         kind: 'single',
    @@ -2029,6 +2257,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -2057,6 +2286,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -2085,6 +2315,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
         ],
         ownerPropsReferences: [],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -2117,6 +2348,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
           'MessageImageSource',
         ],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -2156,13 +2388,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
           },
         ],
         ownerProps: [
    -      '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n  /** Tool call identity, stable across running and settled forms. */\n  callId: string\n  /** Wire Tool name and keyed dispatch value. */\n  toolName: string\n  /** Frozen running call or settled result node. */\n  block: ToolCallBlock\n  /** Session workspace root for relative summaries. */\n  cwd?: string | undefined\n  /** Host account home; POSIX home-rooted summaries display as `~`. */\n  home?: string | undefined\n  /** Open a Tool argument path through the Host. */\n  openFile: (path: string) => void\n  /**\n   * Session-authorized image loader for the `tool.call.images` slot, supplied\n   * by the chat node that owns this call. A composed chat node always\n   * supplies it (`ChatNodeOwnerProps.loadImage` is required), so the tool\n   * layer never imports an attachment implementation nor handles URL\n   * authorization.\n   */\n  loadImage: MessageImageLoader\n  /** Inspect this call in the trajectory view when available. */\n  inspect?: (() => void) | undefined\n}',
    +      '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n  /** Tool call identity, stable across running and settled forms. */\n  callId: string\n  /** Wire Tool name and keyed dispatch value. */\n  toolName: string\n  /** Frozen running call or settled result node. */\n  block: ToolCallBlock\n  /** Session workspace root for relative summaries. */\n  cwd?: string | undefined\n  /** Host account home; POSIX home-rooted summaries display as `~`. */\n  home?: string | undefined\n  /**\n   * Open a Tool argument path. A view that knows which line the call was about\n   * passes it, and the opened surface lands there.\n   */\n  openFile: (path: string, options?: OpenFileOptions) => void\n  /**\n   * Session-authorized image loader for the `tool.call.images` slot, supplied\n   * by the chat node that owns this call. A composed chat node always\n   * supplies it (`ChatNodeOwnerProps.loadImage` is required), so the tool\n   * layer never imports an attachment implementation nor handles URL\n   * authorization.\n   */\n  loadImage: MessageImageLoader\n  /** Inspect this call in the trajectory view when available. */\n  inspect?: (() => void) | undefined\n}',
         ],
         ownerPropsReferences: [
           'ChatNodeOwnerProps',
           'MessageImageLoader',
    +      'OpenFileOptions',
         ],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    @@ -2225,6 +2459,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
           'CordisDynamicPluginRunId',
         ],
         standardProps: [
    +      'useResource: UseResource',
           'useWorkspaces: SnapshotSelectorHook',
           'useSessions: UseSessions',
           'useSessionPendingInteraction: UseSessionPendingInteraction',
    diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json
    index 5db0c13745..71ee72771c 100644
    --- a/packages/extensions/cordis-host-runner/package.json
    +++ b/packages/extensions/cordis-host-runner/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-cordis-host-runner",
       "description": "Dynamic package definition registry, host-half sandbox lifecycle, and invoke handler table for model-mounted dual-half packages",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/extensions/tool-cordis/package.json b/packages/extensions/tool-cordis/package.json
    index 823396feee..dbc51cef2e 100644
    --- a/packages/extensions/tool-cordis/package.json
    +++ b/packages/extensions/tool-cordis/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-cordis",
       "description": "Self-referential cordis toolset: inspect the live runtime, mount and dispose model-written plugins",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
    index a1c333b265..c784f26af1 100644
    --- a/packages/extensions/tool-cordis/src/api-catalog.ts
    +++ b/packages/extensions/tool-cordis/src/api-catalog.ts
    @@ -610,6 +610,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
             parameters: [{ name: 'id', description: 'entry id (package name).' }],
             returns: 'the path, or undefined for an unknown id.',
           },
    +      {
    +        signature: 'fetchBundle(request: Request): Response',
    +        description: 'Serve an advertised revisioned bundle or source map without a Web server. Unknown URLs return 404, unsupported methods return 405, and `HEAD` returns the same immutable headers without a body.',
    +        parameters: [{ name: 'request', description: 'shell-carrier request for a `/plugins` resource.' }],
    +        returns: 'the exact response also exposed by the optional Web route.',
    +      },
           {
             signature: 'artifactBaseline(id: string): ClientArtifactBaseline | undefined',
             description: 'Filesystem baseline captured before an entry\'s current bytes were read. HMR compares it with the live files when installing a watch, so a write between startup composition and watch installation cannot disappear into the watcher\'s initial state.',
    @@ -1008,6 +1014,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
             parameters: [{ name: 'target', description: 'the resolved target to read.' }, { name: 'signal', description: 'aborts the read.' }, { name: 'maxBytes', description: 'inclusive byte cap on the complete content.' }],
             returns: 'the full raw content, at most `maxBytes` long.',
           },
    +      {
    +        signature: 'abstract readByteRange(target: FsTarget, range: { offset: number; length: number }, signal?: AbortSignal): Promise',
    +        description: 'Read one byte window of the regular file as raw bytes with no decoding or binary rejection: the bytes at `[offset, offset + length)`, shorter when the file ends inside the window and empty when `offset` lies at or past its end. The window is the bound here, not the file: a backend transfers at most `length` bytes of content beyond the prefix it skips to reach `offset` and never buffers the whole file, so the caller\'s cap on `length` is the guard against unbounded buffering.',
    +        parameters: [{ name: 'target', description: 'the resolved target to read.' }, { name: 'range', description: '`offset`, the 0-based first byte, and `length`, the largest byte count; both non-negative integers.' }, { name: 'signal', description: 'aborts the read.' }],
    +        returns: 'the window\'s bytes, at most `length` long.',
    +      },
           {
             signature: 'abstract listDir(target: FsTarget, signal?: AbortSignal): Promise',
             description: 'List direct children of a directory in stable name order. Returns resolved child targets plus cheap metadata only; never reads file contents.',
    @@ -2885,6 +2897,43 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
           },
         ],
       },
    +  {
    +    key: 'workspaceFiles',
    +    summary: 'Host Remote service over the composed filesystem, confined to one workspace.',
    +    description: 'Host Remote service over the composed filesystem, confined to one workspace.',
    +    methods: [
    +      {
    +        signature: '@Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise',
    +        description: 'Read one page of lines from a UTF-8 text file inside the Agent\'s workspace.',
    +        parameters: [{ name: 'agent', description: 'target Agent resolved from the Session identity on the wire.' }, { name: 'path', description: 'workspace path, absolute or relative to the workspace root.' }, { name: 'range', description: 'the line window; omitted fields take the page defaults.' }, { name: 'signal', description: 'caller cancellation.' }],
    +        returns: 'the page, the file\'s version at the stat before it, and whether it reaches the last line.',
    +      },
    +      {
    +        signature: '@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise',
    +        description: 'Read one byte window of a regular file inside the Agent\'s workspace: raw bytes, no text decoding and no binary rejection.',
    +        parameters: [{ name: 'agent', description: 'target Agent resolved from the Session identity on the wire.' }, { name: 'path', description: 'workspace path, absolute or relative to the workspace root.' }, { name: 'range', description: 'the byte window; omitted fields take the window defaults.' }, { name: 'signal', description: 'caller cancellation.' }],
    +        returns: 'the window in base64, the file\'s version and size at the stat before it, and whether it reaches the last byte.',
    +      },
    +      {
    +        signature: '@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise',
    +        description: 'Report one regular file\'s identity, version, and size without its content.',
    +        parameters: [{ name: 'agent', description: 'target Agent resolved from the Session identity on the wire.' }, { name: 'path', description: 'workspace path, absolute or relative to the workspace root.' }, { name: 'signal', description: 'caller cancellation.' }],
    +        returns: 'the file\'s absolute path, current version, and byte size.',
    +      },
    +      {
    +        signature: '@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise',
    +        description: 'List the direct children of one directory inside the Agent\'s workspace.',
    +        parameters: [{ name: 'agent', description: 'target Agent resolved from the Session identity on the wire.' }, { name: 'path', description: 'workspace path, absolute or relative to the workspace root.' }, { name: 'signal', description: 'caller cancellation.' }],
    +        returns: 'the directory\'s children in the backend\'s stable name order, bounded by the entry cap.',
    +      },
    +      {
    +        signature: '@Remote({ mode: \'stream\' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable',
    +        description: 'Stream every `fs/observed` observation of a file inside the Agent\'s workspace. Only Agent filesystem operations report here; the OS is not watched.',
    +        parameters: [{ name: 'agent', description: 'target Agent resolved from the Session identity on the wire.' }, { name: 'signal', description: 'generation cancellation.' }],
    +        returns: '`ready` once the Host observation queue is active and the workspace root is resolved, then queued and live observations in emission order.',
    +      },
    +    ],
    +  },
       {
         key: 'workspaceRegistry',
         summary: 'Durable workspace registry.',
    @@ -6306,6 +6355,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
         name: 'WorkspaceBaseline',
         declaration: 'export interface WorkspaceBaseline {\n    readonly items: readonly WorkspaceView[];\n    readonly archivedSessionIds: readonly SessionId[];\n}',
       },
    +  {
    +    name: 'WorkspaceByteRange',
    +    declaration: 'export interface WorkspaceByteRange {\n    readonly offset?: number;\n    readonly length?: number;\n}',
    +  },
       {
         name: 'WorkspaceCreateRequest',
         declaration: 'export interface WorkspaceCreateRequest {\n    readonly path: string;\n}',
    @@ -6322,6 +6375,38 @@ export const TYPE_API: readonly TypeApiEntry[] = [
         name: 'WorkspaceDeleteValue',
         declaration: 'export interface WorkspaceDeleteValue {\n    readonly deleted: true;\n}',
       },
    +  {
    +    name: 'WorkspaceDirectoryEntry',
    +    declaration: 'export interface WorkspaceDirectoryEntry {\n    readonly name: string;\n    readonly type: \'file\' | \'directory\' | \'other\';\n    readonly size?: number;\n}',
    +  },
    +  {
    +    name: 'WorkspaceDirectoryListing',
    +    declaration: 'export interface WorkspaceDirectoryListing {\n    readonly path: string;\n    readonly entries: readonly WorkspaceDirectoryEntry[];\n    readonly truncated: boolean;\n}',
    +  },
    +  {
    +    name: 'WorkspaceFileBytes',
    +    declaration: 'export interface WorkspaceFileBytes extends WorkspaceFileStat {\n    readonly offset: number;\n    readonly data: string;\n    readonly eof: boolean;\n}',
    +  },
    +  {
    +    name: 'WorkspaceFileChange',
    +    declaration: 'export type WorkspaceFileChange = {\n    readonly absolutePath: string;\n    readonly version: string;\n} | {\n    readonly absolutePath: string;\n    readonly absent: true;\n};',
    +  },
    +  {
    +    name: 'WorkspaceFileRange',
    +    declaration: 'export interface WorkspaceFileRange {\n    readonly offset?: number;\n    readonly limit?: number;\n}',
    +  },
    +  {
    +    name: 'WorkspaceFileStat',
    +    declaration: 'export interface WorkspaceFileStat {\n    readonly absolutePath: string;\n    readonly version: string;\n    readonly bytes?: number;\n}',
    +  },
    +  {
    +    name: 'WorkspaceFileText',
    +    declaration: 'export interface WorkspaceFileText extends WorkspaceFileStat {\n    readonly offset: number;\n    readonly text: string;\n    readonly lines: number;\n    readonly eof: boolean;\n}',
    +  },
    +  {
    +    name: 'WorkspaceFileWatchFrame',
    +    declaration: 'export type WorkspaceFileWatchFrame = {\n    readonly kind: \'ready\';\n} | {\n    readonly kind: \'change\';\n    readonly change: WorkspaceFileChange;\n};',
    +  },
       {
         name: 'WorkspaceFollowFrame',
         declaration: 'export type WorkspaceFollowFrame = {\n    readonly type: \'baseline\';\n    readonly value: WorkspaceBaseline;\n} | WorkspaceFollowIncrement;',
    diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json
    index b8082543be..84551a334b 100644
    --- a/packages/extensions/ui-cordis/package.json
    +++ b/packages/extensions/ui-cordis/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-client-ui-cordis",
       "description": "Cordis dynamic-plugin definition card: the keyed cordis_define tool row with its run/stop switch",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json
    index 68f6a36286..d93e2fdb5a 100644
    --- a/packages/feedback/command-feedback/package.json
    +++ b/packages/feedback/command-feedback/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-command-feedback",
       "description": "Log-only session feedback producer and human-facing slash command",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -36,6 +36,7 @@
         "@deepseek-ai/cordis-plugin-include": "workspace:^",
         "@deepseek-ai/cordis-plugin-loader": "workspace:^",
         "@deepseek-ai/dsh-agent": "workspace:^",
    +    "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
         "@deepseek-ai/dsh-commands": "workspace:^",
         "@deepseek-ai/dsh-llm": "workspace:^",
         "@deepseek-ai/dsh-session": "workspace:^",
    diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts
    index 71aa288cc5..e59020bfc6 100644
    --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts
    +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts
    @@ -1,11 +1,12 @@
     import { beforeEach, describe, expect, it, vi } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
     import Loader from '@deepseek-ai/cordis-plugin-loader'
    -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry from '@deepseek-ai/dsh-agent'
     import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
     import CommandRuntime from '@deepseek-ai/dsh-commands'
     import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session'
     import * as commandFeedback from '@deepseek-ai/dsh-command-feedback'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     const { USER_ID, getOrCreateAnonymousUserId } = vi.hoisted(() => {
       const USER_ID = '01234567-89ab-4cde-8f01-23456789abcd'
    @@ -28,13 +29,12 @@ interface Harness {
     /** Build a live idle agent over a store-owned session, as an app's spine does. */
     function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
       const session = ctx.sessions.create(SessionId(id))
    -  const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
       let status: AgentStatus = 'idle'
       const agent: Agent = {
         id: session.id,
         options: {},
         session,
    -    inbox,
    +    inbox: unsupportedInbox(),
         ctx: new Context(),
         get status() { return status },
         send: () => {},
    diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts
    index 1ad8abf770..6ab826e516 100644
    --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts
    +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts
    @@ -6,12 +6,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
     import Loader from '@deepseek-ai/cordis-plugin-loader'
     import Include from '@deepseek-ai/cordis-plugin-include'
    -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry from '@deepseek-ai/dsh-agent'
     import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
     import CommandRuntime from '@deepseek-ai/dsh-commands'
     import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
     import * as CommandFeedback from '@deepseek-ai/dsh-command-feedback'
     import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     let root: string | undefined
     let context: Context | undefined
    @@ -29,13 +30,12 @@ function agent(ctx: Context): Agent {
       const scope = ctx.plugin(() => {})
       const id = SessionId('feedback-loader-agent')
       const session = ctx.sessions.create(id)
    -  const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
       let status: AgentStatus = 'idle'
       const value: Agent = {
         id,
         options: {},
         session,
    -    inbox,
    +    inbox: unsupportedInbox(),
         ctx: scope.ctx,
         get status() { return status },
         send: () => {},
    diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json
    index 1f3834d036..4ec204fa32 100644
    --- a/packages/feedback/message-feedback/package.json
    +++ b/packages/feedback/message-feedback/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-message-feedback",
       "description": "Canonical Session-log ratings and notes for finalized assistant messages",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml
    index 265af90b94..bd66fe68e1 100644
    --- a/packages/fs/fs-local/README.i18n.yaml
    +++ b/packages/fs/fs-local/README.i18n.yaml
    @@ -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/fs/fs-local/README.md
    -README.md: 07fb96d457abd3807e892d37d473e92093078664
    -README.zh.md: 4980de0c7fe771f75e38d896644d62c29c352c04
    +README.md: 24ca13b384e8a3d123b77ce916b86656bc3bd114
    +README.zh.md: a23ba0c7bb87309d1fd093a6f55e4505776f02ed
    diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md
    index 07fb96d457..24ca13b384 100644
    --- a/packages/fs/fs-local/README.md
    +++ b/packages/fs/fs-local/README.md
    @@ -50,7 +50,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
     
     ### What you can do
     
    -Read any regular UTF-8 text file whole or as a stream, read raw bytes up to a cap you choose, and list one directory level in stable name order. Create or replace a file atomically, and apply a literal text edit atomically; both mutations serialize per file, so concurrent writers never interleave. The version guard is optional: omit it for unconditional create-or-overwrite, or supply it to fail when the file changed since you last observed it.
    +Read any regular UTF-8 text file whole or as a stream, read raw bytes up to a cap you choose or as one byte window, and list one directory level in stable name order. Create or replace a file atomically, and apply a literal text edit atomically; both mutations serialize per file, so concurrent writers never interleave. The version guard is optional: omit it for unconditional create-or-overwrite, or supply it to fail when the file changed since you last observed it.
     
     Failures are typed `FsError`s with stable codes — `FS_NOT_FOUND`, `FS_NOT_TEXT` (binary content), `FS_STALE_VERSION` (changed since observation), `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` (no unique literal match), and others — so callers branch on the code, never on message text. A missing target on a guarded edit reports `FS_STALE_VERSION` either way.
     
    diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md
    index 4980de0c7f..a23ba0c7bb 100644
    --- a/packages/fs/fs-local/README.zh.md
    +++ b/packages/fs/fs-local/README.zh.md
    @@ -50,7 +50,7 @@ kind: "package-reference"
     
     ### 你能做什么
     
    -完整或流式读取任意普通 UTF-8 文本文件,按你选择的上限读取原始字节,并按稳定名称顺序列出一层目录。原子地创建或替换文件,并原子地应用字面量文本编辑;两个变更操作都按文件串行化,并发写入方绝不会交错。版本防护是可选的:省略它即无条件创建或覆盖,提供它则在文件自上次观察以来发生变化时失败。
    +完整或流式读取任意普通 UTF-8 文本文件,按你选择的上限或按字节窗口读取原始字节,并按稳定名称顺序列出一层目录。原子地创建或替换文件,并原子地应用字面量文本编辑;两个变更操作都按文件串行化,并发写入方绝不会交错。版本防护是可选的:省略它即无条件创建或覆盖,提供它则在文件自上次观察以来发生变化时失败。
     
     失败是携带稳定错误码的类型化 `FsError`——`FS_NOT_FOUND`、`FS_NOT_TEXT`(二进制内容)、`FS_STALE_VERSION`(自观察以来已变化)、`FS_EDIT_NOT_FOUND` 或 `FS_AMBIGUOUS_EDIT`(无唯一字面量匹配)等——因此调用方依据错误码分支,绝不解析消息文本。带防护的编辑遇到缺失目标时,无论哪种情况都报告 `FS_STALE_VERSION`。
     
    diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json
    index 63ec5ead39..99739636e6 100644
    --- a/packages/fs/fs-local/package.json
    +++ b/packages/fs/fs-local/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-fs-local",
       "description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts
    index 17bfb6115b..e07567e0e3 100644
    --- a/packages/fs/fs-local/src/fsio.ts
    +++ b/packages/fs/fs-local/src/fsio.ts
    @@ -426,6 +426,44 @@ export async function readWholeBytes(
       return Buffer.concat(chunks, bytes)
     }
     
    +/**
    + * Read the bytes at `[offset, offset + length)` of a regular file with no
    + * decoding or binary rejection. The window is the bound: the stream opens at
    + * `offset` and closes after `length` bytes, so no more than the window is ever
    + * buffered whatever the file's size; a window at or past the end is empty.
    + * @param target - the resolved file to read.
    + * @param range - `offset`, the 0-based first byte, and `length`, the largest byte count.
    + * @param signal - aborts the read (`FS_ABORTED`).
    + * @returns the window's bytes, at most `length` long.
    + */
    +export async function readByteWindow(
    +  target: LocalTarget,
    +  range: { offset: number; length: number },
    +  signal?: AbortSignal,
    +): Promise {
    +  await statRegularFile(target, 'read', signal)
    +  if (range.length === 0) return new Uint8Array(0)
    +  const stream = createReadStream(target.targetKey, {
    +    start: range.offset,
    +    end: range.offset + range.length - 1,
    +    ...signal ? { signal } : {},
    +  })
    +  const chunks: Buffer[] = []
    +  let bytes = 0
    +  try {
    +    for await (const chunk of stream as AsyncIterable) {
    +      chunks.push(chunk)
    +      bytes += chunk.length
    +    }
    +  } catch (error: unknown) {
    +    /* v8 ignore next 2 -- a mid-stream abort needs cancellation racing an active read; pre-abort is deterministic. */
    +    if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED')
    +    /* v8 ignore next -- any other stream failure needs an I/O fault after a successful stat. */
    +    throw error
    +  }
    +  return Buffer.concat(chunks, bytes)
    +}
    +
     /**
      * Stream a whole regular UTF-8 text file as decoded text chunks. Same text
      * semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,
    diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts
    index 7c50532a0e..22fc6310fb 100644
    --- a/packages/fs/fs-local/src/index.ts
    +++ b/packages/fs/fs-local/src/index.ts
    @@ -27,6 +27,7 @@ import {
       probe,
       probeNoFollow,
       readForEdit,
    +  readByteWindow,
       readTextForDiff,
       readWholeBytes,
       readWholeText,
    @@ -156,6 +157,10 @@ export class LocalFileSystem extends FileSystem {
         return readWholeBytes({ displayPath: target.displayPath, targetKey: target.targetKey }, signal, maxBytes, this.internals)
       }
     
    +  override async readByteRange(target: FsTarget, range: { offset: number; length: number }, signal?: AbortSignal): Promise {
    +    return readByteWindow({ displayPath: target.displayPath, targetKey: target.targetKey }, range, signal)
    +  }
    +
       override async listDir(target: FsTarget, signal?: AbortSignal): Promise {
         const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
         return entries.map(entry => ({
    diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts
    index b791b424fb..9b88e4cad3 100644
    --- a/packages/fs/fs-local/tests/filesystem.spec.ts
    +++ b/packages/fs/fs-local/tests/filesystem.spec.ts
    @@ -308,6 +308,45 @@ describe('readBytes', () => {
       })
     })
     
    +describe('readByteRange', () => {
    +  /** 256 bytes, each equal to its offset. */
    +  const ramp = Buffer.from(Array.from({ length: 256 }, (_, i) => i))
    +
    +  it('reads a window from the middle of a file larger than the window', async () => {
    +    await writeFile(join(dir, 'ramp.bin'), ramp)
    +    const window = await fs.readByteRange(await fs.resolve('ramp.bin'), { offset: 100, length: 4 })
    +    expect([...window]).toEqual([100, 101, 102, 103])
    +  })
    +
    +  it('shortens a window the file ends inside and empties one at or past the end', async () => {
    +    await writeFile(join(dir, 'ramp.bin'), ramp)
    +    const target = await fs.resolve('ramp.bin')
    +    expect([...await fs.readByteRange(target, { offset: 253, length: 10 })]).toEqual([253, 254, 255])
    +    expect((await fs.readByteRange(target, { offset: 256, length: 10 })).length).toBe(0)
    +    expect((await fs.readByteRange(target, { offset: 1000, length: 10 })).length).toBe(0)
    +  })
    +
    +  it('returns an empty window for length 0 without touching content', async () => {
    +    await writeFile(join(dir, 'ramp.bin'), ramp)
    +    expect((await fs.readByteRange(await fs.resolve('ramp.bin'), { offset: 0, length: 0 })).length).toBe(0)
    +  })
    +
    +  it('carries NUL and invalid UTF-8 untouched', async () => {
    +    const raw = Buffer.from([0x00, 0xff, 0xfe, 0x41])
    +    await writeFile(join(dir, 'a.bin'), raw)
    +    expect(Buffer.from(await fs.readByteRange(await fs.resolve('a.bin'), { offset: 0, length: 4 }))).toEqual(raw)
    +  })
    +
    +  it('rejects a missing file, a directory, and an already-aborted signal', async () => {
    +    await expect(fs.readByteRange(await fs.resolve('nope'), { offset: 0, length: 1 })).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
    +    await expect(fs.readByteRange(await fs.resolve('.'), { offset: 0, length: 1 })).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
    +    await writeFile(join(dir, 'a.bin'), 'data')
    +    const controller = new AbortController()
    +    controller.abort()
    +    await expect(fs.readByteRange(await fs.resolve('a.bin'), { offset: 0, length: 1 }, controller.signal)).rejects.toMatchObject({ code: 'FS_ABORTED' })
    +  })
    +})
    +
     describe('listDir', () => {
       it('lists files and directories in stable name order with resolved child targets', async () => {
         await mkdir(join(dir, 'skills', 'dir-skill'), { recursive: true })
    diff --git a/packages/fs/fs-observation-policy/package.json b/packages/fs/fs-observation-policy/package.json
    index 5f200342d5..517ac5ec0e 100644
    --- a/packages/fs/fs-observation-policy/package.json
    +++ b/packages/fs/fs-observation-policy/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-fs-observation-policy",
       "description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service API)",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json
    index cb091195cb..d0d9af8829 100644
    --- a/packages/fs/fs-sandbox/package.json
    +++ b/packages/fs/fs-sandbox/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-fs-sandbox",
       "description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/fs/fs/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml
    index 52604a6ba0..5086ed032f 100644
    --- a/packages/fs/fs/README.i18n.yaml
    +++ b/packages/fs/fs/README.i18n.yaml
    @@ -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/fs/fs/README.md
    -README.md: cba98c41a77d6fc3508ef2d4ac81a231120f1836
    -README.zh.md: 0a8b3f7198f93592603a37c749b58d8735e7d24f
    +README.md: b7d0231c87de9073a1ec8eb819872c1d12e247a1
    +README.zh.md: 202b71a893950e3d7bd9a49bb13d5b69710f3194
    diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md
    index cba98c41a7..b7d0231c87 100644
    --- a/packages/fs/fs/README.md
    +++ b/packages/fs/fs/README.md
    @@ -52,7 +52,7 @@ The contract is built on one separation and three commitments:
     - **Contract over mechanism.** The service names what a storage layer can do — resolve, stat, read, list, write, edit — and never how it stores bytes. Backends own target identity, execution-world coordinates, decoding, binary rejection, and atomicity.
     - **Policy stays off the base class.** Observed-state, read-before-edit, and version-guarded mutations are a plugin's job (`dsh-fs-observation-policy`), added by supplying the optional guard — so a sandboxed or remote backend inherits no model-facing observation policy.
     - **`editText` stays on the seam.** Version check, literal match, and atomic rewrite share one critical section, so error attribution and one-wins/one-stale concurrency stay correct; a remote backend may implement it as a native compare-and-edit.
    -- **Bounds live at this seam.** `readBytes` requires `maxBytes` and fails with `FS_TOO_LARGE` rather than truncating, so no backend ever buffers an unbounded file.
    +- **Bounds live at this seam.** `readBytes` requires `maxBytes` and fails with `FS_TOO_LARGE` rather than truncating, so no backend ever buffers an unbounded file. `readByteRange` is bounded by its window instead: a backend transfers at most the requested `length` beyond the prefix it skips, so the caller's cap on `length` is the guard.
     
     ### Source map
     
    @@ -63,7 +63,7 @@ The contract is built on one separation and three commitments:
     
     ### How a call flows
     
    -Every ordinary operation starts with `resolve(path, { cwd })`, which produces a stable `FsTarget` (an opaque `targetKey` plus a `displayPath` for model/UI output); the same file reached through different paths yields the same key. `processPathFromHostPath(hostPath)` separately maps an absolute host file into this execution world when the backend shares or explicitly maps it, and otherwise returns `undefined`. Reads then go `stat` → `readText`/`streamText`/`readBytes`, listings go `listDir`, and mutations go through one per-target critical section: the optional guard is checked, the new content is applied, and the result is published atomically.
    +Every ordinary operation starts with `resolve(path, { cwd })`, which produces a stable `FsTarget` (an opaque `targetKey` plus a `displayPath` for model/UI output); the same file reached through different paths yields the same key. `processPathFromHostPath(hostPath)` separately maps an absolute host file into this execution world when the backend shares or explicitly maps it, and otherwise returns `undefined`. Reads then go `stat` → `readText`/`streamText`/`readBytes`/`readByteRange`, listings go `listDir`, and mutations go through one per-target critical section: the optional guard is checked, the new content is applied, and the result is published atomically.
     
     ### The `fs/*` policy events
     
    @@ -109,7 +109,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
     
     These limits define when the contract is a poor fit or needs special operational care. They are current package constraints, not a general filesystem comparison or a task backlog.
     
    -- **Text-only mutations by contract** — text reads and both mutations reject binary or non-UTF-8 content with `FS_NOT_TEXT`; `readBytes` is the single raw-byte primitive, and binary-safe mutations remain deferred.
    +- **Text-only mutations by contract** — text reads and both mutations reject binary or non-UTF-8 content with `FS_NOT_TEXT`; `readBytes` and `readByteRange` are the raw-byte primitives, and binary-safe mutations remain deferred.
     - **Thirteen primitives only** — no delete, rename, copy, or watch; `listDir` lists a single level, with recursion, globbing, pagination, and search out of scope ([directory-listing note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)).
     - **No I/O deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive ([fs family stance](../README.md)).
     - **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend.
    diff --git a/packages/fs/fs/README.zh.md b/packages/fs/fs/README.zh.md
    index 0a8b3f7198..202b71a893 100644
    --- a/packages/fs/fs/README.zh.md
    +++ b/packages/fs/fs/README.zh.md
    @@ -52,7 +52,7 @@ kind: "package-reference"
     - **约定高于机制。** 服务只命名存储层能做什么——解析、stat、读取、列出、写入、编辑——绝不规定如何存储字节。后端拥有目标身份、执行世界坐标、解码、二进制拒绝与原子性。
     - **策略不放在基类上。** 已观察状态、编辑前读取与版本防护的变更是插件(`dsh-fs-observation-policy`)的职责,通过提供可选防护来添加——因此沙箱化或远程后端不会继承任何面向模型的观察策略。
     - **`editText` 留在 seam 上。** 版本校验、字面量匹配与原子重写共享同一个临界区,错误归因与一方胜出/一方陈旧的并发语义因此保持正确;远程后端也可以将其实现为原生比较并编辑操作。
    -- **界限制在此 seam 上。** `readBytes` 要求 `maxBytes`,并以 `FS_TOO_LARGE` 失败而不是截断,因此任何后端都不会无界缓冲文件。
    +- **界限制在此 seam 上。** `readBytes` 要求 `maxBytes`,并以 `FS_TOO_LARGE` 失败而不是截断,因此任何后端都不会无界缓冲文件。`readByteRange` 则以窗口为界:后端最多传输所请求的 `length` 字节(外加为到达 `offset` 而跳过的前缀),因此由调用方对 `length` 的上限承担防护。
     
     ### 源码地图
     
    @@ -63,7 +63,7 @@ kind: "package-reference"
     
     ### 调用流程
     
    -每个普通操作都以 `resolve(path, { cwd })` 开始,它产生稳定的 `FsTarget`(不透明 `targetKey` 加用于模型/UI 输出的 `displayPath`);经不同路径到达同一文件会产生相同 key。`processPathFromHostPath(hostPath)` 在后端共享或显式映射宿主文件时,单独把绝对宿主文件映射进此执行世界,否则返回 `undefined`。读取随后执行 `stat` → `readText`/`streamText`/`readBytes`,列出执行 `listDir`,变更则经过每个目标一个临界区:先检查可选防护,应用新内容,再原子发布结果。
    +每个普通操作都以 `resolve(path, { cwd })` 开始,它产生稳定的 `FsTarget`(不透明 `targetKey` 加用于模型/UI 输出的 `displayPath`);经不同路径到达同一文件会产生相同 key。`processPathFromHostPath(hostPath)` 在后端共享或显式映射宿主文件时,单独把绝对宿主文件映射进此执行世界,否则返回 `undefined`。读取随后执行 `stat` → `readText`/`streamText`/`readBytes`/`readByteRange`,列出执行 `listDir`,变更则经过每个目标一个临界区:先检查可选防护,应用新内容,再原子发布结果。
     
     ### `fs/*` 策略事件
     
    @@ -109,7 +109,7 @@ kind: "package-reference"
     
     这些限制说明该约定何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用文件系统对比或任务积压。
     
    -- **变更操作约定只支持文本**:文本读取和两个变更操作都以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;`readBytes` 是唯一的原始字节原语,二进制安全的变更操作仍延期。
    +- **变更操作约定只支持文本**:文本读取和两个变更操作都以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;`readBytes` 与 `readByteRange` 是原始字节原语,二进制安全的变更操作仍延期。
     - **只有十三个原语**:没有删除、重命名、复制或监视;`listDir` 只列出一层,递归、glob、分页与搜索不在范围内(见[目录列出笔记](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md))。
     - **没有 I/O deadline**:该 seam 不启动超时;取消只是每个原语上尽力而为的可选 `AbortSignal`(见[fs 能力族立场](../README.zh.md))。
     - **先解析后操作使远程后端每次工具调用需要两次往返**:折叠或缓存解析由这种后端自行决定。
    diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json
    index 831d5161a2..0db2f5623f 100644
    --- a/packages/fs/fs/package.json
    +++ b/packages/fs/fs/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-fs",
       "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts
    index e32890d732..bfa05bf257 100644
    --- a/packages/fs/fs/src/index.ts
    +++ b/packages/fs/fs/src/index.ts
    @@ -211,6 +211,21 @@ export abstract class FileSystem extends Service {
        */
       abstract readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise
     
    +  /**
    +   * Read one byte window of the regular file as raw bytes with no decoding or
    +   * binary rejection: the bytes at `[offset, offset + length)`, shorter when
    +   * the file ends inside the window and empty when `offset` lies at or past
    +   * its end. The window is the bound here, not the file: a backend transfers
    +   * at most `length` bytes of content beyond the prefix it skips to reach
    +   * `offset` and never buffers the whole file, so the caller's cap on `length`
    +   * is the guard against unbounded buffering.
    +   * @param target - the resolved target to read.
    +   * @param range - `offset`, the 0-based first byte, and `length`, the largest byte count; both non-negative integers.
    +   * @param signal - aborts the read.
    +   * @returns the window's bytes, at most `length` long.
    +   */
    +  abstract readByteRange(target: FsTarget, range: { offset: number; length: number }, signal?: AbortSignal): Promise
    +
       /**
        * List direct children of a directory in stable name order. Returns resolved
        * child targets plus cheap metadata only; never reads file contents.
    diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts
    index 6fbe1268af..f1b4800042 100644
    --- a/packages/fs/fs/tests/service.spec.ts
    +++ b/packages/fs/fs/tests/service.spec.ts
    @@ -57,6 +57,9 @@ class FakeFileSystem extends FileSystem {
         }
         return bytes
       }
    +  override async readByteRange(target: FsTarget, range: { offset: number; length: number }): Promise {
    +    return new TextEncoder().encode(await this.readText(target)).subarray(range.offset, range.offset + range.length)
    +  }
       override async listDir(target: FsTarget): Promise {
         if (target.targetKey !== 'skills') throw new FsError(`not a directory: ${target.displayPath}`, 'FS_NOT_DIRECTORY')
         return [
    @@ -120,6 +123,17 @@ describe('FileSystem provider seam', () => {
         expect(streamed).toBe(await fs.readText(target))
       })
     
    +  it('readByteRange returns the window, shorter at the end and empty past it', async () => {
    +    const ctx = new Context()
    +    await ctx.plugin(FakeFileSystem)
    +    const fs = ctx.fs as FakeFileSystem
    +    fs.files.set('a.bin', 'hello')
    +    const target = await fs.resolve('a.bin')
    +    expect(await fs.readByteRange(target, { offset: 1, length: 3 })).toEqual(new TextEncoder().encode('ell'))
    +    expect(await fs.readByteRange(target, { offset: 3, length: 10 })).toEqual(new TextEncoder().encode('lo'))
    +    expect(await fs.readByteRange(target, { offset: 9, length: 2 })).toHaveLength(0)
    +  })
    +
       it('readBytes returns raw content and enforces the byte cap with FS_TOO_LARGE', async () => {
         const ctx = new Context()
         await ctx.plugin(FakeFileSystem)
    diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json
    index ddcb0f65e7..225be718f9 100644
    --- a/packages/fs/tool-fs-search/package.json
    +++ b/packages/fs/tool-fs-search/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-fs-search",
       "description": "Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep)",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json
    index 8573dc403a..d2e42f6750 100644
    --- a/packages/fs/tool-fs/package.json
    +++ b/packages/fs/tool-fs/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-fs",
       "description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts
    index 184e74f099..b556ec9270 100644
    --- a/packages/fs/tool-fs/tests/harness.ts
    +++ b/packages/fs/tool-fs/tests/harness.ts
    @@ -1,7 +1,6 @@
     import { Context } from '@deepseek-ai/cordis'
     import type { Agent } from '@deepseek-ai/dsh-agent'
     import AgentLoop from '@deepseek-ai/dsh-agent-loop'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
     import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
     import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy'
    @@ -15,7 +14,6 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
      */
     export async function fsHarness(fsCwd: string, persona = ''): Promise {
       const ctx = new Context()
    -  await ctx.plugin(SessionProjectionRegistry)
       await mountAgentLoopTestDependencies(ctx, { systemPrompt: { personaPrefix: persona } })
       await ctx.plugin(AgentLoop, { agents: [] })
       await ctx.plugin(LlmDeepSeek)
    diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts
    index ebb9cf18de..46d0e7dcc3 100644
    --- a/packages/fs/tool-fs/tests/tools.spec.ts
    +++ b/packages/fs/tool-fs/tests/tools.spec.ts
    @@ -81,6 +81,9 @@ class FakeFs extends FileSystem {
         }
         return bytes
       }
    +  override async readByteRange(target: FsTarget, range: { offset: number; length: number }): Promise {
    +    return new TextEncoder().encode(this.files.get(target.targetKey) ?? '').subarray(range.offset, range.offset + range.length)
    +  }
       override async listDir(_target: FsTarget): Promise {
         return []
       }
    diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json
    index 5181e1408e..f2e438074b 100644
    --- a/packages/fs/tool-str-replace-editor/package.json
    +++ b/packages/fs/tool-str-replace-editor/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-str-replace-editor",
       "description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -37,6 +37,7 @@
       },
       "devDependencies": {
         "@deepseek-ai/dsh-agent": "workspace:^",
    +    "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
         "@deepseek-ai/dsh-fs": "workspace:^",
         "@deepseek-ai/dsh-fs-local": "workspace:^",
         "@deepseek-ai/dsh-fs-observation-policy": "workspace:^",
    diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts
    index 7d32b26d72..e50f87f3a3 100644
    --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts
    +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts
    @@ -6,7 +6,7 @@ import { Context } from '@deepseek-ai/cordis'
     import { FsVersion } from '@deepseek-ai/dsh-fs'
     import { ToolCallId } from '@deepseek-ai/dsh-llm'
     import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
    -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry from '@deepseek-ai/dsh-agent'
     import type { Agent } from '@deepseek-ai/dsh-agent'
     import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
     import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy'
    @@ -16,6 +16,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
     import ToolRuntime from '@deepseek-ai/dsh-tools'
     import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     const contexts: Context[] = []
     const roots: string[] = []
    @@ -36,7 +37,7 @@ function agent(ctx: Context, cwd: string): Agent {
         id,
         options: {},
         session,
    -    inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +    inbox: unsupportedInbox(),
         status: 'idle',
         ctx: scope.ctx,
         send: () => {},
    diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json
    index f0a7f052b2..8f4439dbb8 100644
    --- a/packages/goal/command-goal/package.json
    +++ b/packages/goal/command-goal/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-command-goal",
       "description": "Human-facing slash command for persisted same-session goals",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -35,6 +35,8 @@
       "devDependencies": {
         "@deepseek-ai/cordis-plugin-loader": "workspace:^",
         "@deepseek-ai/dsh-agent": "workspace:^",
    +    "@deepseek-ai/dsh-agent-loop": "workspace:^",
    +    "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
         "@deepseek-ai/dsh-commands": "workspace:^",
         "@deepseek-ai/dsh-goal": "workspace:^",
         "@deepseek-ai/dsh-llm": "workspace:^",
    diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts
    index ed53189f7d..1c1761cf74 100644
    --- a/packages/goal/command-goal/tests/command-goal.spec.ts
    +++ b/packages/goal/command-goal/tests/command-goal.spec.ts
    @@ -1,7 +1,7 @@
     import { describe, expect, it, vi } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
     import Loader from '@deepseek-ai/cordis-plugin-loader'
    -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry from '@deepseek-ai/dsh-agent'
     import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
     import CommandRuntime from '@deepseek-ai/dsh-commands'
     import GoalService from '@deepseek-ai/dsh-goal'
    @@ -9,6 +9,7 @@ import type { GoalRef } from '@deepseek-ai/dsh-goal'
     import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
     import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import * as commandGoal from '@deepseek-ai/dsh-command-goal'
    +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     interface Harness {
       readonly ctx: Context
    @@ -21,7 +22,7 @@ interface Harness {
     function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
       // Store-created: the command executor durably logs lifecycle events on it.
       const session = ctx.sessions.create(SessionId(id))
    -  const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
    +  const inbox = createInboxStub()
       let status: AgentStatus = 'idle'
       const agent: Agent = {
         id: session.id,
    @@ -33,7 +34,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
         send: () => {},
         followup: () => {},
         steer: () => {},
    -    inject(input) { inbox.append('next-step', input) },
    +    inject(input) { this.inbox.append('next-step', input) },
         cancel() { status = 'idle' },
         runMaintenance: task => task(new AbortController().signal),
         whenIdle() { return Promise.resolve() },
    @@ -45,9 +46,9 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
     async function harness(): Promise {
       const ctx = new Context()
       await ctx.plugin(SessionStore)
    +  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(CommandRuntime)
       await ctx.plugin(AgentRegistry)
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(GoalService)
       const plugin = await ctx.plugin(commandGoal)
       const { agent, session } = stubAgent(ctx, `command-goal-${Math.random()}`)
    diff --git a/packages/goal/goal-round-driver/package.json b/packages/goal/goal-round-driver/package.json
    index 5d92983dfa..a883f3c271 100644
    --- a/packages/goal/goal-round-driver/package.json
    +++ b/packages/goal/goal-round-driver/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-goal-round-driver",
       "description": "Race-fenced same-session goal-round driver",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts
    index 5cef3e75de..2ec805d71b 100644
    --- a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts
    +++ b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts
    @@ -10,7 +10,6 @@ import { createUserMessage, LlmAdapter, LlmError  } from '@deepseek-ai/dsh-llm'
     import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
     import { SessionId } from '@deepseek-ai/dsh-session'
     import type { UserMessage } from '@deepseek-ai/dsh-session'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import * as goalSession from '../src/index.ts'
     
     type ScriptEntry = StreamChunk[] | Error | 'hang' | ((options: GenerateOptions) => StreamChunk[])
    @@ -90,7 +89,6 @@ async function harness(script: ScriptEntry[]): Promise {
       const ctx = new Context()
       contexts.push(ctx)
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(GoalService)
       const driver = await ctx.plugin(goalSession)
       await ctx.plugin(AgentLoop, { agents: [] })
    @@ -217,7 +215,6 @@ describe('same-session goal driving', () => {
         const ctx = new Context()
         contexts.push(ctx)
         await mountAgentLoopTestDependencies(ctx)
    -    await ctx.plugin(SessionProjectionRegistry)
         await ctx.plugin(GoalService)
         await ctx.plugin(AgentLoop, { agents: [] })
         const adapter = new ScriptedAdapter([textResponse('after resume')])
    diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json
    index 4fea0e0ea4..6db9f2da2b 100644
    --- a/packages/goal/goal/package.json
    +++ b/packages/goal/goal/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-goal",
       "description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -69,6 +69,8 @@
       },
       "devDependencies": {
         "@deepseek-ai/dsh-agent": "workspace:^",
    +    "@deepseek-ai/dsh-agent-loop": "workspace:^",
    +    "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
         "@deepseek-ai/dsh-bash-local": "workspace:^",
         "@deepseek-ai/dsh-session-projection": "workspace:^",
         "@deepseek-ai/dsh-brand": "workspace:^",
    diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts
    index d72ac7c751..e3140e9231 100644
    --- a/packages/goal/goal/tests/goal.spec.ts
    +++ b/packages/goal/goal/tests/goal.spec.ts
    @@ -1,6 +1,6 @@
     import { describe, expect, it, vi } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
    -import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
     import type { Agent } from '@deepseek-ai/dsh-agent'
     import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm'
     import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
    @@ -12,12 +12,19 @@ import GoalService, {
       foldGoal,
     } from '@deepseek-ai/dsh-goal'
     import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
    +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     interface StubAgent {
       agent: Agent
       session: Session
     }
     
    +const isolatedInboxCtx = new Context()
    +await isolatedInboxCtx.plugin(SessionStore)
    +await isolatedInboxCtx.plugin(SessionProjectionRegistry)
    +await isolatedInboxCtx.plugin(AgentRegistry)
    +const sessionStubs = new WeakMap()
    +
     /** Number the next balanced test-fixture turn. */
     function nextTurn(session: Session): number {
       return session.snapshotEvents().reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1
    @@ -25,46 +32,61 @@ function nextTurn(session: Session): number {
     
     /** Mirror the public Agent.inject contract for domain tests. */
     function appendInjection(session: Session, input: UserMessage): void {
    -  new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }).append('next-step', input)
    +  stubAgentForSession(session).agent.inbox.append('next-step', input)
     }
     
     /** Build a registry-compatible agent around one concrete session. */
    -function stubAgentForSession(session: Session): StubAgent {
    +function stubAgentForSession(session: Session, suppliedCtx?: Context): StubAgent {
    +  const existing = sessionStubs.get(session)
    +  if (existing !== undefined) return existing
       const id = session.id
    -  const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
    +  const agentCtx = suppliedCtx ?? isolatedInboxCtx
    +  if (suppliedCtx === undefined) {
    +    agentCtx.sessions.enter(session)
    +  }
    +  const inbox = createInboxStub()
       const agent: Agent = {
         id,
         options: {},
         session,
         inbox,
    -    ctx: new Context(),
    +    ctx: agentCtx,
         status: 'idle',
         send: () => {},
         followup: () => {},
         steer: () => {},
    -    inject(input) { inbox.append('next-step', input) },
    +    inject(input) { this.inbox.append('next-step', input) },
         cancel() {},
         runMaintenance: task => task(new AbortController().signal),
         whenIdle() { return Promise.resolve() },
       }
    -  return {
    +  const stub = {
         agent,
         session,
       }
    +  sessionStubs.set(session, stub)
    +  return stub
     }
     
     /** Build a registry-compatible agent around a fresh session. */
    -function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[]): StubAgent {
    -  return stubAgentForSession(Session.create(SessionId(rawId), seed))
    +function stubAgent(
    +  rawId: string,
    +  seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[],
    +  ctx?: Context,
    +): StubAgent {
    +  const session = ctx === undefined
    +    ? Session.create(SessionId(rawId), seed)
    +    : ctx.sessions.create(SessionId(rawId), { ...(seed === undefined ? {} : { seed }) })
    +  return stubAgentForSession(session, ctx)
     }
     
     async function harness(config: { defaultMaxGoalRounds?: number } = {}) {
       const ctx = new Context()
       await ctx.plugin(SessionStore)
    -  await ctx.plugin(AgentRegistry)
       await ctx.plugin(SessionProjectionRegistry)
    +  await ctx.plugin(AgentRegistry)
       await ctx.plugin(GoalService, config)
    -  const stub = stubAgentForSession(ctx.sessions.create(SessionId(`goal-test-${Math.random()}`)))
    +  const stub = stubAgent(`goal-test-${Math.random()}`, undefined, ctx)
       ctx.agents.register(stub.agent)
       return { ctx, ...stub }
     }
    @@ -182,15 +204,15 @@ describe('GoalService creation and replay', () => {
       it('inherits the completed-turn goal prefix through SessionStore.fork with child activation disarmed', async () => {
         const ctx = new Context()
         await ctx.plugin(SessionStore)
    -    await ctx.plugin(AgentRegistry)
         await ctx.plugin(SessionProjectionRegistry)
    +    await ctx.plugin(AgentRegistry)
         await ctx.plugin(GoalService)
    -    const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent')))
    +    const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent')), ctx)
         ctx.agents.register(parent.agent)
         const goal = ctx.goals.create(parent.agent, { objective: 'inherit through fork', maxGoalRounds: 5 })
         appendRound(parent.session, goal, 1)
     
    -    const child = stubAgentForSession(ctx.sessions.fork(parent.session))
    +    const child = stubAgentForSession(ctx.sessions.fork(parent.session), ctx)
         ctx.agents.register(child.agent)
         expect(ctx.goals.get(child.agent)).toMatchObject({
           id: goal.id,
    @@ -252,7 +274,7 @@ describe('GoalService creation and replay', () => {
         const { ctx, agent } = await harness()
         // A same-id agent backed by a different session object — the live-instance
         // check must reject it even though the ids match.
    -    const impostor = stubAgentForSession(Session.create(agent.id)).agent
    +    const impostor = { ...agent, session: Session.create(agent.id) } as Agent
         expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' }))
         expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({
           code: 'GOAL_AGENT_NOT_LIVE',
    @@ -439,10 +461,10 @@ describe('GoalService mutations', () => {
       it('publishes a mutation consistently to a reentrant session observer', async () => {
         const ctx = new Context()
         await ctx.plugin(SessionStore)
    -    await ctx.plugin(AgentRegistry)
         await ctx.plugin(SessionProjectionRegistry)
    +    await ctx.plugin(AgentRegistry)
         await ctx.plugin(GoalService)
    -    const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer')))
    +    const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer')), ctx)
         ctx.agents.register(stub.agent)
         let observed: ReturnType
         ctx.on('session/event', (session, event) => {
    @@ -501,6 +523,31 @@ describe('GoalService mutations', () => {
         })
       })
     
    +  it('rejects a corrupt append while preserving the valid prefix', async () => {
    +    const { ctx, agent, session } = await harness()
    +    expect(ctx.goals.get(agent)).toBeUndefined()
    +    const change: GoalSnapshotChangeMeta = {
    +      kind: 'goal/change',
    +      version: 1,
    +      operation: 'create',
    +      goal: {
    +        id: GoalId('goal-valid-prefix'),
    +        revision: 1,
    +        objective: 'valid prefix',
    +        phase: 'active',
    +        maxGoalRounds: 4,
    +      },
    +      roundsStarted: 0,
    +      createdAt: 12,
    +      updatedAt: 12,
    +    }
    +    session.append('goal/change', change)
    +    expect(() => {
    +      session.append('goal/change', { ...change, operation: 'edit', extra: true } as never)
    +    }).toThrow('snapshot change must have exactly')
    +
    +    expect(ctx.goals.get(agent)).toMatchObject({ id: change.goal.id, objective: 'valid prefix' })
    +  })
     })
     
     describe('goal replay validation', () => {
    @@ -566,7 +613,7 @@ describe('goal replay validation', () => {
           content: [{ type: 'text', text: 'unrelated pending context' }],
           source: { kind: 'plugin', plugin: 'test' },
         })
    -    const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
    +    const inbox = stubAgentForSession(session).agent.inbox
         inbox.append('next-step', message)
         expect(inbox.remove(message.id)).toBe(true)
         expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: change.goal.id, revision: 1 } })
    diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts
    index 4dd473db31..840141a1da 100644
    --- a/packages/goal/goal/tests/projection.spec.ts
    +++ b/packages/goal/goal/tests/projection.spec.ts
    @@ -10,15 +10,15 @@
     
     import { describe, expect, it, vi } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
    -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry from '@deepseek-ai/dsh-agent'
     import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
     import { createUserMessage } from '@deepseek-ai/dsh-llm'
    -import type { UserMessage } from '@deepseek-ai/dsh-session'
     import SessionStore from '@deepseek-ai/dsh-session'
     import type { Session } from '@deepseek-ai/dsh-session'
     import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import GoalService, { GoalId, applyGoalProjection, foldGoal, goalProjectionDefinition } from '@deepseek-ai/dsh-goal'
     import type { GoalProjection, GoalProjectionState, GoalRef } from '@deepseek-ai/dsh-goal'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     interface Bench {
       ctx: Context
    @@ -31,20 +31,17 @@ interface Bench {
     /** Register a minimal registry-compatible live agent over a store session. */
     function liveAgent(ctx: Context, session: Session): Agent {
       const status: AgentStatus = 'idle'
    -  const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
       const agent: Agent = {
         id: session.id,
         options: {},
         session,
    -    inbox,
    +    inbox: unsupportedInbox(),
         ctx,
         get status() { return status },
         send: () => {},
         followup: () => {},
         steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
    -    inject(input: UserMessage) {
    -      inbox.append('next-step', input)
    -    },
    +    inject: () => { throw new Error('goal projection tests do not inject model context') },
         cancel() {},
         runMaintenance: task => task(new AbortController().signal),
         whenIdle() { return Promise.resolve() },
    @@ -82,7 +79,7 @@ describe('goal projection unit', () => {
       it('serves null before the first create', async () => {
         const bench = await harness(true)
         seedMessage(bench.session)
    -    expect(bench.tailValues()).toEqual({ goal: null })
    +    expect(bench.tailValues().goal).toBeNull()
         expect(bench.tailAsOfSeq()).toBe(bench.session.seq - 1)
       })
     
    @@ -130,10 +127,14 @@ describe('goal projection unit', () => {
         const created = bench.ctx.goals.create(bench.agent, { objective: 'stay cleared' })
         bench.ctx.goals.clear(bench.agent, created)
     
    -    bench.agent.inbox.prepend('next-step', createUserMessage({
    -      content: [{ type: 'text', text: 'unrelated pending context' }],
    -      source: { kind: 'plugin', plugin: 'test' },
    -    }))
    +    bench.session.append('agent/inbox/spliced', {
    +      target: 'next-step',
    +      start: 0,
    +      inserted: [createUserMessage({
    +        content: [{ type: 'text', text: 'unrelated pending context' }],
    +        source: { kind: 'plugin', plugin: 'test' },
    +      })],
    +    })
     
         expect(bench.tailValues().goal).toBeNull()
         expect(foldGoal(bench.session.snapshotEvents()).goal).toBeUndefined()
    @@ -240,7 +241,7 @@ describe('goal projection unit', () => {
         const bench = await harness(false)
         seedMessage(bench.session)
         const fiber = await bench.ctx.plugin(GoalService)
    -    expect(bench.tailValues()).toEqual({ goal: null })
    +    expect(bench.tailValues().goal).toBeNull()
         await fiber.dispose()
         expect('goal' in (bench.tailValues() ?? {})).toBe(false)
       })
    diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json
    index 7728e2e7d5..5ba86bc1ac 100644
    --- a/packages/goal/tool-goal/package.json
    +++ b/packages/goal/tool-goal/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-goal",
       "description": "Model-facing same-session goal tools with execution-time authority checks",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -42,6 +42,7 @@
       "devDependencies": {
         "@deepseek-ai/cordis-plugin-loader": "workspace:^",
         "@deepseek-ai/dsh-agent": "workspace:^",
    +    "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
         "@deepseek-ai/dsh-agent-loop": "workspace:^",
         "@deepseek-ai/dsh-goal": "workspace:^",
         "@deepseek-ai/dsh-llm": "workspace:^",
    diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts
    index 1839a9ecdf..533b6aa6c6 100644
    --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts
    +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts
    @@ -1,44 +1,58 @@
     import { describe, expect, it } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
     import Loader from '@deepseek-ai/cordis-plugin-loader'
    -import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent'
    -import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
    +import type { Agent, AgentStatus, Inbox } from '@deepseek-ai/dsh-agent'
     import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
     import GoalService, { GoalId } from '@deepseek-ai/dsh-goal'
     import type { GoalRef } from '@deepseek-ai/dsh-goal'
     import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
     import type { MessageSource } from '@deepseek-ai/dsh-llm'
    -import {
    +import SessionStore, {
       SESSION_FORMAT_VERSION,
       Session,
       SessionId,
       SessionLogOffset,
     } from '@deepseek-ai/dsh-session'
    +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
     import ToolRuntime from '@deepseek-ai/dsh-tools'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
     import * as toolGoal from '@deepseek-ai/dsh-tool-goal'
    +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     const testToolSignal = new AbortController().signal
     
     interface StubAgent {
       readonly agent: Agent
       readonly session: Session
    +  readonly inbox: Inbox
       setStatus(status: AgentStatus): void
     }
     
    -/** Build one registry-compatible live agent whose injections enter the durable inbox. */
    -function stubAgent(rawId: string, supplied?: Session): StubAgent {
    -  const session = supplied ?? Session.create(SessionId(rawId))
    +const isolatedInboxCtx = new Context()
    +await isolatedInboxCtx.plugin(SessionStore)
    +await isolatedInboxCtx.plugin(SessionProjectionRegistry)
    +await isolatedInboxCtx.plugin(AgentRegistry)
    +
    +/** Build one registry-compatible live agent whose injections enter its test Inbox. */
    +function stubAgent(rawId: string, supplied?: Session, suppliedCtx?: Context): StubAgent {
    +  const agentCtx = suppliedCtx ?? isolatedInboxCtx
    +  const session = supplied ?? (suppliedCtx === undefined
    +    ? agentCtx.sessions.create(SessionId(rawId))
    +    : suppliedCtx.sessions.create(SessionId(rawId)))
    +  if (suppliedCtx === undefined) {
    +    if (agentCtx.sessions.get(session.id) !== session) agentCtx.sessions.enter(session)
    +  }
    +  const inbox = createInboxStub()
       let status: AgentStatus = 'running'
       const agent: Agent = {
         id: session.id,
         options: {},
         session,
    -    inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +    inbox,
         get status() { return status },
    -    ctx: new Context(),
    +    ctx: agentCtx,
         send: () => {},
         followup: () => {},
         steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
    @@ -49,7 +63,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
         runMaintenance: task => task(new AbortController().signal),
         whenIdle() { return Promise.resolve() },
       }
    -  return { agent, session, setStatus(value) { status = value } }
    +  return { agent, session, inbox, setStatus(value) { status = value } }
     }
     
     /** Open one message-triggered turn with its accepted model-visible input. */
    @@ -62,7 +76,7 @@ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): numb
         source,
       })
       stub.agent.inbox.append('next-turn', message)
    -  const claimed = stub.agent.inbox.claim('next-turn', turn)
    +  const claimed = stub.inbox.splice('next-turn', 0, 1, [])
       if (claimed.length === 0) throw new Error('expected queued turn input')
       stub.session.append('turn/start', { turn })
       for (const admitted of claimed) {
    @@ -78,14 +92,15 @@ function closeTurn(stub: StubAgent, turn: number): void {
     
     async function harness(config: toolGoal.Config = {}) {
       const ctx = new Context()
    +  await ctx.plugin(SessionStore)
    +  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(SystemPrompt)
       await ctx.plugin(AgentRegistry)
       await ctx.plugin(ToolRuntime)
    -  await ctx.plugin(SessionProjectionRegistry)
       ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
       await ctx.plugin(GoalService)
       const fiber = await ctx.plugin(toolGoal, config)
    -  const root = stubAgent(`goal-tool-root-${Math.random()}`)
    +  const root = stubAgent(`goal-tool-root-${Math.random()}`, undefined, ctx)
       ctx.agents.register(root.agent)
       return { ctx, fiber, root }
     }
    @@ -252,7 +267,7 @@ describe('goal tool execution authority', () => {
         openTurn(root, { kind: 'user' })
         // A distinct agent object over root's exact session: same id, not the live
         // registered instance, so the executor must reject it.
    -    const stale = stubAgent('goal-tool-stale', root.agent.session).agent
    +    const stale = stubAgent('goal-tool-stale', root.agent.session, ctx).agent
         const staleResult = await execute(ctx, 'get_goal', {}, stale, stale)
         expect(staleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
     
    diff --git a/packages/guard/repeat-tool-reminder/package.json b/packages/guard/repeat-tool-reminder/package.json
    index 0d78b4ebca..5ea2131972 100644
    --- a/packages/guard/repeat-tool-reminder/package.json
    +++ b/packages/guard/repeat-tool-reminder/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-repeat-tool-reminder",
       "description": "Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts
    index b8a32f2370..4c505d6cf0 100644
    --- a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts
    +++ b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts
    @@ -6,7 +6,6 @@ import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
     import type { Agent } from '@deepseek-ai/dsh-agent'
     import AgentLoop from '@deepseek-ai/dsh-agent-loop'
     import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-reminder'
     import type { Config } from '@deepseek-ai/dsh-repeat-tool-reminder'
     import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
    @@ -25,8 +24,6 @@ const testToolSignal = new AbortController().signal
     async function harness(config: Config = {}): Promise {
       const ctx = new Context()
       await mountAgentLoopTestDependencies(ctx)
    -  // AgentLoop declares the registry as a required injection.
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(AgentLoop, { agents: [] })
       await ctx.plugin(RepeatToolGuard, config)
       ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
    @@ -373,7 +370,6 @@ describe('config validation fails loud', () => {
       async function spine(): Promise {
         const ctx = new Context()
         await mountAgentLoopTestDependencies(ctx)
    -    await ctx.plugin(SessionProjectionRegistry)
         await ctx.plugin(AgentLoop, { agents: [] })
         return ctx
       }
    diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json
    index 5894cdb73a..aa65289feb 100644
    --- a/packages/guard/timeout-policy/package.json
    +++ b/packages/guard/timeout-policy/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-call-timeout-policy",
       "description": "Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json
    index b6d7f06eda..88debd2afd 100644
    --- a/packages/hooks/hook-protocol/package.json
    +++ b/packages/hooks/hook-protocol/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-hook-protocol",
       "description": "Shared Claude Code / Codex hook wire protocol: matcher engine, stdin/exit-code/stdout codec, multi-hook merge, and hook/* session events",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json
    index ab21dca419..ae647ae65c 100644
    --- a/packages/hooks/hooks-claude-code/package.json
    +++ b/packages/hooks/hooks-claude-code/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-hooks-claude-code",
       "description": "Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts
    index 7f5facfa65..68f6c8319d 100644
    --- a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts
    +++ b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts
    @@ -15,7 +15,6 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
     import { scopeTarget } from '@deepseek-ai/dsh-scope'
     import SubagentRuntime, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
     import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude-code'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
     
     /**
    @@ -58,7 +57,6 @@ async function harnessWithFiber(
     ): Promise<{ ctx: Context; hooks: Fiber }> {
       const ctx = new Context()
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(AgentLoop, { agents: [] })
       await ctx.plugin(LocalSubprocessRuntime)
       await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
    @@ -356,7 +354,6 @@ describe('hooks-claude-code bridge — load resilience', () => {
         const adapter = new MockAdapter([textResponse('fine')])
         const ctx = new Context()
         await mountAgentLoopTestDependencies(ctx)
    -    await ctx.plugin(SessionProjectionRegistry)
         await ctx.plugin(AgentLoop, { agents: [] })
         await ctx.plugin(LocalSubprocessRuntime)
         await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
    @@ -417,7 +414,6 @@ describe('hooks-claude-code bridge — load resilience', () => {
         const adapter = new MockAdapter([textResponse('ok')])
         const ctx = new Context()
         await mountAgentLoopTestDependencies(ctx)
    -    await ctx.plugin(SessionProjectionRegistry)
         await ctx.plugin(AgentLoop, { agents: [] })
         await ctx.plugin(LocalSubprocessRuntime)
         await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
    diff --git a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts
    index 0f90281364..8627a970cd 100644
    --- a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts
    +++ b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts
    @@ -15,7 +15,6 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
     import { scopeTarget } from '@deepseek-ai/dsh-scope'
     import SubagentRuntime, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
     import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude-code'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
     
     const testToolSignal = new AbortController().signal
    @@ -42,7 +41,6 @@ type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxC
     async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise {
       const ctx = new Context()
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
       if (opts.sessionRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: opts.sessionRoot })
       await ctx.plugin(AgentLoop, { agents: [] })
       await ctx.plugin(LocalSubprocessRuntime)
    @@ -363,7 +361,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
           const adapter = new MockAdapter([textResponse('ok')])
           const ctx = new Context()
           await mountAgentLoopTestDependencies(ctx)
    -      await ctx.plugin(SessionProjectionRegistry)
           await ctx.plugin(AgentLoop, { agents: [] })
           await ctx.plugin(LocalSubprocessRuntime)
           await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
    @@ -663,7 +660,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
           const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
           const ctx = new Context()
           await mountAgentLoopTestDependencies(ctx)
    -      await ctx.plugin(SessionProjectionRegistry)
           await ctx.plugin(AgentLoop, { agents: [] })
           // Executor default cwd = serverDir (deliberately NOT the session cwd).
           await ctx.plugin(LocalSubprocessRuntime)
    @@ -693,7 +689,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
           hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'cat > stoppayload.tmp; mv stoppayload.tmp stoppayload; pwd > stopwhere' }] }] })
           const ctx = new Context()
           await mountAgentLoopTestDependencies(ctx)
    -      await ctx.plugin(SessionProjectionRegistry)
           await ctx.plugin(AgentLoop, { agents: [] })
           // Executor default cwd = serverDir (deliberately NOT the child session cwd).
           await ctx.plugin(LocalSubprocessRuntime)
    diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json
    index b8780bcead..6571eb6840 100644
    --- a/packages/hooks/hooks-codex/package.json
    +++ b/packages/hooks/hooks-codex/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-hooks-codex",
       "description": "Bridge plugin: run a Codex hooks.json hook config on the DeepSeek Harness interception seams",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts
    index 511ec5e12a..1ef87de7c1 100644
    --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts
    +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts
    @@ -13,7 +13,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
     import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
     import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
     import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
     
     /**
    @@ -43,7 +42,6 @@ function writeHooks(dir: string, hooks: unknown): void {
     async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise {
       const ctx = new Context()
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(AgentLoop, { agents: [] })
       await ctx.plugin(LocalSubprocessRuntime)
       await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
    @@ -184,7 +182,6 @@ describe('hooks-codex bridge', () => {
         const adapter = new MockAdapter([textResponse('ok')])
         const ctx = new Context()
         await mountAgentLoopTestDependencies(ctx)
    -    await ctx.plugin(SessionProjectionRegistry)
         await ctx.plugin(AgentLoop, { agents: [] })
         await ctx.plugin(LocalSubprocessRuntime)
         await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
    @@ -208,7 +205,6 @@ describe('hooks-codex bridge', () => {
         writeHooks(dir, { SessionStart: [{ hooks: [{ type: 'command', command: slow }] }] })
         const ctx = new Context()
         await mountAgentLoopTestDependencies(ctx)
    -    await ctx.plugin(SessionProjectionRegistry)
         await ctx.plugin(AgentLoop, { agents: [] })
         await ctx.plugin(LocalSubprocessRuntime)
         await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
    diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts
    index 85ab765f7e..12a3c370ab 100644
    --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts
    +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts
    @@ -13,7 +13,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
     import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
     import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
     import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
     
     const testToolSignal = new AbortController().signal
    @@ -32,7 +31,6 @@ type HarnessOpts = { stderrSummaryMaxChars?: number; sessionRoot?: string }
     async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise {
       const ctx = new Context()
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
       if (opts.sessionRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: opts.sessionRoot })
       await ctx.plugin(AgentLoop, { agents: [] })
       await ctx.plugin(LocalSubprocessRuntime)
    @@ -309,7 +307,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
           const adapter = new MockAdapter([textResponse('ok')])
           const ctx = new Context()
           await mountAgentLoopTestDependencies(ctx)
    -      await ctx.plugin(SessionProjectionRegistry)
           await ctx.plugin(AgentLoop, { agents: [] })
           await ctx.plugin(LocalSubprocessRuntime)
           await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
    @@ -620,7 +617,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
           const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
           const ctx = new Context()
           await mountAgentLoopTestDependencies(ctx)
    -      await ctx.plugin(SessionProjectionRegistry)
           await ctx.plugin(AgentLoop, { agents: [] })
           await ctx.plugin(LocalSubprocessRuntime)
           await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
    diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json
    index 1d0ef7641e..d7396473f6 100644
    --- a/packages/host/directory-picker-auto/package.json
    +++ b/packages/host/directory-picker-auto/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-host-directory-picker-auto",
       "description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json
    index 3372f690ff..379e7d890e 100644
    --- a/packages/host/directory-picker-browse/package.json
    +++ b/packages/host/directory-picker-browse/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-host-directory-picker-browse",
       "description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json
    index f855f8805c..961162d31b 100644
    --- a/packages/host/directory-picker-native/package.json
    +++ b/packages/host/directory-picker-native/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-host-directory-picker-native",
       "description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json
    index 3cc0d9a1b5..992811adcc 100644
    --- a/packages/host/directory-picker/package.json
    +++ b/packages/host/directory-picker/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-host-directory-picker",
       "description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json
    index c4d48497c6..80b0cb87e9 100644
    --- a/packages/host/frontend-static/package.json
    +++ b/packages/host/frontend-static/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-host-frontend-static",
       "description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving explicit index entries and static assets with traversal rejection and 404 misses",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/host/open-in-app/package.json b/packages/host/open-in-app/package.json
    index 4c54a75dd8..06ea232508 100644
    --- a/packages/host/open-in-app/package.json
    +++ b/packages/host/open-in-app/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-host-open-in-app",
       "description": "Host half of open-in-app: resolved application catalog, icons, and the launch endpoint as three webServer routes",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json
    index 1e74c9f84b..fee131f474 100644
    --- a/packages/host/plugin-inventory/package.json
    +++ b/packages/host/plugin-inventory/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-host-plugin-inventory",
       "description": "Read-only Remote projection of current Cordis Loader plugin state",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json
    index d69146aeec..00405c5719 100644
    --- a/packages/host/webserver/package.json
    +++ b/packages/host/webserver/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-host-webserver",
       "description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/identity/anonymous-user-id/package.json b/packages/identity/anonymous-user-id/package.json
    index 2303d203bb..4378dc9fde 100644
    --- a/packages/identity/anonymous-user-id/package.json
    +++ b/packages/identity/anonymous-user-id/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-anonymous-user-id",
       "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json
    index 4d1b3783ca..fb93894ac1 100644
    --- a/packages/interaction/commands/package.json
    +++ b/packages/interaction/commands/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-commands",
       "description": "Plugin-owned human command registry for DeepSeek Harness UIs",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/interaction/permission-presets/package.json b/packages/interaction/permission-presets/package.json
    index 70e3c59926..33efc18cec 100644
    --- a/packages/interaction/permission-presets/package.json
    +++ b/packages/interaction/permission-presets/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-permission-presets",
       "description": "User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json
    index 11e819ac64..f079f11b54 100644
    --- a/packages/interaction/tool-ask-user/package.json
    +++ b/packages/interaction/tool-ask-user/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-ask-user",
       "description": "Model-facing ask_user_question tool over the ctx.userQuestions seam",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json
    index 1991d47d4a..80307e9f5f 100644
    --- a/packages/interaction/user-approval/package.json
    +++ b/packages/interaction/user-approval/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-user-approval",
       "description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json
    index a924cca0a7..68ac6f50a3 100644
    --- a/packages/interaction/user-questions/package.json
    +++ b/packages/interaction/user-questions/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-user-questions",
       "description": "Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json
    index e4d5075c07..4124f176fd 100644
    --- a/packages/jobs/jobs-local/package.json
    +++ b/packages/jobs/jobs-local/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-jobs-local",
       "description": "Process-local implementation of the DeepSeek Harness background job registry seam",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -40,6 +40,7 @@
         "@deepseek-ai/cordis-plugin-include": "workspace:^",
         "@deepseek-ai/cordis-plugin-loader": "workspace:^",
         "@deepseek-ai/dsh-agent": "workspace:^",
    +    "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
         "@deepseek-ai/dsh-brand": "workspace:^",
         "@deepseek-ai/dsh-scope": "workspace:^",
         "@deepseek-ai/dsh-session": "workspace:^",
    diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts
    index 33ea29ba68..14f4ecd847 100644
    --- a/packages/jobs/jobs-local/tests/jobs.spec.ts
    +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts
    @@ -1,13 +1,14 @@
     import { describe, expect, expectTypeOf, it, vi } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
     import { Session, SessionId } from '@deepseek-ai/dsh-session'
    -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry from '@deepseek-ai/dsh-agent'
     import type { Agent } from '@deepseek-ai/dsh-agent'
     import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
     import type { ScopeKey } from '@deepseek-ai/dsh-scope'
     import { JobId } from '@deepseek-ai/dsh-jobs'
     import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart } from '@deepseek-ai/dsh-jobs'
     import LocalJobRegistry, { type Config as JobsConfig } from '@deepseek-ai/dsh-jobs-local'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     declare module '@deepseek-ai/dsh-jobs' {
       interface JobKindMap {
    @@ -34,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent {
         id,
         options: {},
         session,
    -    inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +    inbox: unsupportedInbox(),
         status: 'idle' as const,
         ctx: agentCtx,
         send: () => {},
    @@ -44,7 +45,7 @@ function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent {
         cancel() {},
         runMaintenance: (job: (signal: AbortSignal) => Promise) => job(new AbortController().signal),
         whenIdle() { return Promise.resolve() },
    -  }
    +  } satisfies Agent
       agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() })
       return agent
     }
    diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json
    index 0bc224a1b2..8351c5a749 100644
    --- a/packages/jobs/jobs/package.json
    +++ b/packages/jobs/jobs/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-jobs",
       "description": "Background job registry (ctx.jobs) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/jobs/tool-jobs/package.json b/packages/jobs/tool-jobs/package.json
    index fe7190afae..da3c2a8190 100644
    --- a/packages/jobs/tool-jobs/package.json
    +++ b/packages/jobs/tool-jobs/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-jobs",
       "description": "Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/llm/deepseek-llm-api-extensions/package.json b/packages/llm/deepseek-llm-api-extensions/package.json
    index 6010efb1c6..b084017863 100644
    --- a/packages/llm/deepseek-llm-api-extensions/package.json
    +++ b/packages/llm/deepseek-llm-api-extensions/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-deepseek-llm-api-extensions",
       "description": "Additive request-field registry for the official DeepSeek LLM API adapter",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json
    index 7a8be276ca..ff410e249b 100644
    --- a/packages/llm/llm-deepseek/package.json
    +++ b/packages/llm/llm-deepseek/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-llm-deepseek",
       "description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json
    index bdd739e21f..977a6563ef 100644
    --- a/packages/llm/llm-pi-ai/package.json
    +++ b/packages/llm/llm-pi-ai/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-llm-pi-ai",
       "description": "pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek)",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json
    index f08d3dd767..02ef57fda0 100644
    --- a/packages/llm/llm-retry/package.json
    +++ b/packages/llm/llm-retry/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-llm-retry",
       "description": "Provider-routed LLM request retry policy for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -61,6 +61,7 @@
         "@deepseek-ai/dsh-llm-deepseek": "workspace:^",
         "@deepseek-ai/dsh-llm-mock-server": "workspace:^",
         "@deepseek-ai/dsh-session": "workspace:^",
    +    "@deepseek-ai/dsh-session-projection": "workspace:^",
         "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
         "@deepseek-ai/dsh-system-prompt": "workspace:^",
         "@deepseek-ai/dsh-timeout": "workspace:^",
    diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts
    index fcf19574e3..ba218842de 100644
    --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts
    +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts
    @@ -11,7 +11,6 @@ import type { MockLlmBehavior, MockLlmServer } from '@deepseek-ai/dsh-llm-mock-s
     import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
     import { SessionId } from '@deepseek-ai/dsh-session'
     import type { SessionEvent } from '@deepseek-ai/dsh-session'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import * as Retry from '../src/index.ts'
     
     let context: Context | undefined
    @@ -39,7 +38,6 @@ async function harness(
       vi.stubEnv('DEEPSEEK_API_KEY', 'mock-key')
       const ctx = new Context()
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(LlmDeepSeek, {
         baseURL,
         streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000,
    diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json
    index a266355538..5838e66b98 100644
    --- a/packages/llm/llm/package.json
    +++ b/packages/llm/llm/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-llm",
       "description": "Provider-neutral LLM service interface for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/llm/plugin-package-inventory-deepseek/package.json b/packages/llm/plugin-package-inventory-deepseek/package.json
    index 104737603a..c78f8d048a 100644
    --- a/packages/llm/plugin-package-inventory-deepseek/package.json
    +++ b/packages/llm/plugin-package-inventory-deepseek/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-plugin-package-inventory-deepseek",
       "description": "Active Loader-backed plugin package inventory for official DeepSeek LLM API requests",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json
    index 13d5b370e6..54956a1ecb 100644
    --- a/packages/llm/token-meter/package.json
    +++ b/packages/llm/token-meter/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-token-meter",
       "description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json
    index 249887b1fc..7fdda3c8b5 100644
    --- a/packages/lsp/lsp-stdio/package.json
    +++ b/packages/lsp/lsp-stdio/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-lsp-stdio",
       "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json
    index 69829139ff..5b01391b50 100644
    --- a/packages/lsp/lsp/package.json
    +++ b/packages/lsp/lsp/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-lsp",
       "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json
    index debf423646..eaec6c7e8d 100644
    --- a/packages/lsp/tool-lsp/package.json
    +++ b/packages/lsp/tool-lsp/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-lsp",
       "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json
    index 88e27ee2d6..1bebeb83ed 100644
    --- a/packages/mcp/mcp-client/package.json
    +++ b/packages/mcp/mcp-client/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-mcp-client",
       "description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json
    index f11cb9605f..8f8f63a5b5 100644
    --- a/packages/plan/plan-mode/package.json
    +++ b/packages/plan/plan-mode/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-plan-mode",
       "description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts
    index 5e4240e05b..a814b5d9af 100644
    --- a/packages/plan/plan-mode/tests/integration.spec.ts
    +++ b/packages/plan/plan-mode/tests/integration.spec.ts
    @@ -2,11 +2,11 @@ import { describe, expect, it } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
     import LlmRuntime, { createUserMessage, type StreamChunk  } from '@deepseek-ai/dsh-llm'
     import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
    +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
     import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
     import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
     import AgentLoop from '@deepseek-ai/dsh-agent-loop'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import PlanModeController from '@deepseek-ai/dsh-plan-mode'
     import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
     
    diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts
    index 24ba27c41d..f4d46d5b62 100644
    --- a/packages/plan/plan-mode/tests/projection.spec.ts
    +++ b/packages/plan/plan-mode/tests/projection.spec.ts
    @@ -64,7 +64,7 @@ function commitPlanMode(session: Session, active: boolean, turn: number): void {
     describe('plan projection unit', () => {
       it('serves inactive/not-pending for the empty log', async () => {
         const bench = await harness(true)
    -    expect(bench.values()).toEqual({ plan: { active: false, pending: false } })
    +    expect(bench.values().plan).toEqual({ active: false, pending: false })
       })
     
       it('a logged /plan selection reads pending until plan/mode records it', async () => {
    diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json
    index 6a28925fb8..d9b24f77f7 100644
    --- a/packages/preset/agent-presets/package.json
    +++ b/packages/preset/agent-presets/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-agent-presets",
       "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts
    index aebccc9b7f..e1fb150b5c 100644
    --- a/packages/preset/agent-presets/tests/invariant.spec.ts
    +++ b/packages/preset/agent-presets/tests/invariant.spec.ts
    @@ -5,9 +5,9 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
     import Include from '@deepseek-ai/cordis-plugin-include'
     import LlmRuntime from '@deepseek-ai/dsh-llm'
     import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
    +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
     import ToolRuntime from '@deepseek-ai/dsh-tools'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent'
     import AgentLoop from '@deepseek-ai/dsh-agent-loop'
     import InvariantRegistry from '@deepseek-ai/dsh-invariants'
    @@ -28,10 +28,10 @@ async function harness(roster: Partial = {}): Promise {
       ctx.loader.builtins.include = Include
       await ctx.plugin(LlmRuntime)
       await ctx.plugin(SessionStore)
    +  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(SystemPrompt, { personaPrefix: '' })
       await ctx.plugin(ToolRuntime)
       await ctx.plugin(AgentRegistry)
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(AgentLoop, { agents: [] })
       await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false, ...roster })
       await ctx.plugin(InvariantRegistry)
    diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts
    index 664e4c1c02..6c43c2192c 100644
    --- a/packages/preset/agent-presets/tests/mount.spec.ts
    +++ b/packages/preset/agent-presets/tests/mount.spec.ts
    @@ -8,9 +8,9 @@ import Include from '@deepseek-ai/cordis-plugin-include'
     import Group from '@deepseek-ai/cordis-plugin-group'
     import LlmRuntime from '@deepseek-ai/dsh-llm'
     import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
    +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
     import ToolRuntime from '@deepseek-ai/dsh-tools'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
     import AgentLoop from '@deepseek-ai/dsh-agent-loop'
     import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
    @@ -51,10 +51,10 @@ async function harness(roster: Config = { default: 'standard', roots: ROOTS, inc
       ctx.loader.builtins.group = Group
       await ctx.plugin(LlmRuntime)
       await ctx.plugin(SessionStore)
    +  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(SystemPrompt, { personaPrefix: '' })
       await ctx.plugin(ToolRuntime)
       await ctx.plugin(AgentRegistry)
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(AgentLoop, { agents: [] })
       await ctx.plugin(AgentPresets, roster)
       return ctx
    @@ -459,10 +459,10 @@ describe('the preset file is an input, never a persistence target', () => {
         scoped.loader.builtins.group = Group
         await scoped.plugin(LlmRuntime)
         await scoped.plugin(SessionStore)
    +    await scoped.plugin(SessionProjectionRegistry)
         await scoped.plugin(SystemPrompt, { personaPrefix: '' })
         await scoped.plugin(ToolRuntime)
         await scoped.plugin(AgentRegistry)
    -    await scoped.plugin(SessionProjectionRegistry)
         await scoped.plugin(AgentLoop, { agents: [] })
         await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false })
     
    @@ -648,10 +648,10 @@ describe('replacing a composition', () => {
         scoped.loader.builtins.group = Group
         await scoped.plugin(LlmRuntime)
         await scoped.plugin(SessionStore)
    +    await scoped.plugin(SessionProjectionRegistry)
         await scoped.plugin(SystemPrompt, { personaPrefix: '' })
         await scoped.plugin(ToolRuntime)
         await scoped.plugin(AgentRegistry)
    -    await scoped.plugin(SessionProjectionRegistry)
         await scoped.plugin(AgentLoop, { agents: [] })
         await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false })
         const handle = await scoped.agents.create({
    diff --git a/packages/preset/agent-presets/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts
    index b7511cdbab..b1a685d019 100644
    --- a/packages/preset/agent-presets/tests/remote.spec.ts
    +++ b/packages/preset/agent-presets/tests/remote.spec.ts
    @@ -79,10 +79,10 @@ async function harness(
       ctx.loader.builtins.include = Include
       await ctx.plugin(LlmRuntime)
       await ctx.plugin(SessionStore)
    +  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(SystemPrompt, { personaPrefix: '' })
       await ctx.plugin(ToolRuntime)
       await ctx.plugin(AgentRegistry)
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(AgentLoop, { agents: [] })
       await ctx.plugin(AgentPresets, roster)
       return ctx
    diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json
    index f1ba3d3974..b4ca419f37 100644
    --- a/packages/preset/persona/package.json
    +++ b/packages/preset/persona/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-persona",
       "description": "Composition-authored deployment persona section for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/runtime-diagnostics/invariants/package.json b/packages/runtime-diagnostics/invariants/package.json
    index 6764c65134..0fa0dca7f0 100644
    --- a/packages/runtime-diagnostics/invariants/package.json
    +++ b/packages/runtime-diagnostics/invariants/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-invariants",
       "description": "Registry service for package-owned DeepSeek Harness runtime invariants",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json
    index b6d0107805..12657291d0 100644
    --- a/packages/sandbox/sandbox-local/package.json
    +++ b/packages/sandbox/sandbox-local/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-sandbox-local",
       "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json
    index 25020b5118..7cc8d707d9 100644
    --- a/packages/sandbox/sandbox-policy/package.json
    +++ b/packages/sandbox/sandbox-policy/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-sandbox-policy",
       "description": "Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json
    index ebc22692da..08cb605564 100644
    --- a/packages/sandbox/sandbox-windows-acl/package.json
    +++ b/packages/sandbox/sandbox-windows-acl/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-sandbox-windows-acl",
       "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json
    index 320f14fe55..e1022e7ff3 100644
    --- a/packages/sandbox/sandbox/package.json
    +++ b/packages/sandbox/sandbox/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-sandbox",
       "description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json
    index 7712d91118..34c8f7e83e 100644
    --- a/packages/schedule/schedule/package.json
    +++ b/packages/schedule/schedule/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-schedule",
       "description": "Agent-scoped durable after, at, and fixed-rate reminders over the session event log",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/schedule/schedule/tests/jsonl-restart.spec.ts b/packages/schedule/schedule/tests/jsonl-restart.spec.ts
    index dccc95d137..baf02e4702 100644
    --- a/packages/schedule/schedule/tests/jsonl-restart.spec.ts
    +++ b/packages/schedule/schedule/tests/jsonl-restart.spec.ts
    @@ -7,7 +7,6 @@ import { afterEach, describe, expect, it } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
     import AgentLoop from '@deepseek-ai/dsh-agent-loop'
     import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
     import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
     import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
    @@ -52,7 +51,6 @@ async function mountRuntime(root: string, adapter: RecordingAdapter): Promise {
       const ctx = new Context()
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(PersistenceProbe)
       ctx.on('session/flush', () => {})
       await ctx.plugin(AgentLoop, { agents: [] })
    diff --git a/packages/schedule/schedule/tests/runtime.spec.ts b/packages/schedule/schedule/tests/runtime.spec.ts
    index 86b8246c32..f216babd7d 100644
    --- a/packages/schedule/schedule/tests/runtime.spec.ts
    +++ b/packages/schedule/schedule/tests/runtime.spec.ts
    @@ -1,6 +1,6 @@
     import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
    -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry from '@deepseek-ai/dsh-agent'
     import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent'
     import type { UserMessage } from '@deepseek-ai/dsh-llm'
     import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
    @@ -11,6 +11,7 @@ import {
       foldScheduleEvents,
     } from '../src/domain.ts'
     import { MAX_TIMER_DELAY_MS, ScheduleRuntime } from '../src/runtime.ts'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     const contexts: Context[] = []
     const runtimes: ScheduleRuntime[] = []
    @@ -57,12 +58,11 @@ async function harness(): Promise {
         onFollowup: undefined as (() => void) | undefined,
         idle: Promise.withResolvers(),
       }
    -  const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
       const agent: Agent = {
         id: session.id,
         options: {},
         session,
    -    inbox,
    +    inbox: unsupportedInbox(),
         status: 'idle',
         ctx: new Context(),
         send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {},
    diff --git a/packages/schedule/schedule/tests/tools.spec.ts b/packages/schedule/schedule/tests/tools.spec.ts
    index 4ac5d91717..35e4dc1724 100644
    --- a/packages/schedule/schedule/tests/tools.spec.ts
    +++ b/packages/schedule/schedule/tests/tools.spec.ts
    @@ -1,6 +1,6 @@
     import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
    -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry from '@deepseek-ai/dsh-agent'
     import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent'
     import { ToolCallId } from '@deepseek-ai/dsh-llm'
     import type { UserMessage } from '@deepseek-ai/dsh-llm'
    @@ -10,6 +10,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
     import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
     import { registerScheduleTools } from '../src/tools.ts'
     import { runScheduleTransaction } from '../src/transaction.ts'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     const signal = new AbortController().signal
     const contexts: Context[] = []
    @@ -24,12 +25,11 @@ interface ToolHarness {
     
     function stubAgent(ctx: Context, id: string): Agent {
       const session = ctx.sessions.create(SessionId(id))
    -  const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
    -  return {
    +  const agent: Agent = {
         id: session.id,
         options: {},
         session,
    -    inbox,
    +    inbox: unsupportedInbox(),
         status: 'idle',
         ctx: new Context(),
         send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {},
    @@ -40,6 +40,7 @@ function stubAgent(ctx: Context, id: string): Agent {
         steer(_message: UserMessage) {},
         inject(_message: UserMessage) {},
       }
    +  return agent
     }
     
     async function harness(withPersistence = true): Promise {
    diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json
    index 72a226ea9a..3e3bcb9a9c 100644
    --- a/packages/sdk/client/package.json
    +++ b/packages/sdk/client/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-sdk-client",
       "description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json
    index 0faa32b0ed..027a8c5555 100644
    --- a/packages/sdk/protocol/package.json
    +++ b/packages/sdk/protocol/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-sdk-protocol",
       "description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json
    index 029ac79a74..ed720a41fd 100644
    --- a/packages/sdk/server/package.json
    +++ b/packages/sdk/server/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-sdk-jsonrpc-server",
       "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/sdk/server/tests/built-scope-carrier.e2e.ts b/packages/sdk/server/tests/built-scope-carrier.e2e.ts
    index 803198ab32..e55bf76bb2 100644
    --- a/packages/sdk/server/tests/built-scope-carrier.e2e.ts
    +++ b/packages/sdk/server/tests/built-scope-carrier.e2e.ts
    @@ -28,7 +28,6 @@ const [
       { Context },
       { default: AgentLoop },
       { mountAgentLoopTestDependencies },
    -  { default: SessionProjectionRegistry },
       { default: SubagentRuntime },
       { default: JsonlSessionPersistence },
       { HarnessSdkJsonRpcServer },
    @@ -37,7 +36,6 @@ const [
       load("vendor/cordis/lib/index.js"),
       load("packages/core/agent-loop/lib/index.js"),
       load("packages/test-support/agent-loop-testkit/lib/index.js"),
    -  load("packages/session/session-projection/lib/index.js"),
       load("packages/subagent/subagent/lib/index.js"),
       load("packages/session/session-persistence-jsonl/lib/index.js"),
       load("packages/sdk/server/lib/index.js"),
    @@ -48,7 +46,6 @@ const storageRoot = await mkdtemp(join(tmpdir(), "jsonrpc-built-scope-"));
     const ctx = new Context();
     try {
       await mountAgentLoopTestDependencies(ctx);
    -  await ctx.plugin(SessionProjectionRegistry);
       await ctx.plugin(AgentLoop, { agents: [] });
       await ctx.plugin(SubagentRuntime);
       await ctx.plugin(JsonlSessionPersistence, { root: storageRoot });
    diff --git a/packages/sdk/server/tests/plugin-apply.spec.ts b/packages/sdk/server/tests/plugin-apply.spec.ts
    index 8190a25eb3..e51324c019 100644
    --- a/packages/sdk/server/tests/plugin-apply.spec.ts
    +++ b/packages/sdk/server/tests/plugin-apply.spec.ts
    @@ -11,7 +11,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
     import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
     import { LlmAdapter } from '@deepseek-ai/dsh-llm'
     import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
     import * as jsonrpc from '../src/index.ts'
     
    @@ -77,7 +76,6 @@ async function mountPlugin(
     ): Promise {
       const ctx = new Context()
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(AgentLoop, { agents: [] })
       await ctx.plugin(JsonlSessionPersistence, { root: storageDir })
       await new Promise(resolve => setTimeout(resolve, 50))
    diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts
    index ac3af4f47b..d0bb7e1c8d 100644
    --- a/packages/sdk/server/tests/server.spec.ts
    +++ b/packages/sdk/server/tests/server.spec.ts
    @@ -12,7 +12,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
     import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
     import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
     import SubagentRuntime, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
    @@ -65,7 +64,6 @@ async function mockCompletionServer(): Promise<{ url: string; requests: unknown[
     async function makeHarness(storageDir: string) {
       const ctx = new Context()
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(AgentLoop, { agents: [] })
       await ctx.plugin(SubagentRuntime)
       await ctx.plugin(JsonlSessionPersistence, { root: storageDir })
    diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json
    index ce12787db0..805452960f 100644
    --- a/packages/session-query/session-log-export/package.json
    +++ b/packages/session-query/session-log-export/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-log-export",
       "description": "Web Session-log export command and shared download dialog",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json
    index dc887ddcda..a6c78ee4a7 100644
    --- a/packages/session-query/session-query-sqlite/package.json
    +++ b/packages/session-query/session-query-sqlite/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-query-sqlite",
       "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json
    index 0d42d6c5bc..59032afbda 100644
    --- a/packages/session-query/session-query/package.json
    +++ b/packages/session-query/session-query/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-query",
       "description": "Combined session query service contract with concrete reads, traces, and filters",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json
    index 841f23c1ca..a7d7b3a388 100644
    --- a/packages/session-query/tool-session-query/package.json
    +++ b/packages/session-query/tool-session-query/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-session-query",
       "description": "Workspace-authorized model-facing session history search, trace, and event read tools",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json
    index 87029993e0..6752eaea0a 100644
    --- a/packages/session/session-checkpoint-policy/package.json
    +++ b/packages/session/session-checkpoint-policy/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-checkpoint-policy",
       "description": "Semantic session durability checkpoints before model requests and tool side effects",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts b/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts
    index 37caecb8dc..83826090dd 100644
    --- a/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts
    +++ b/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts
    @@ -2,7 +2,6 @@ import { writeFile } from 'node:fs/promises'
     import { Context } from '@deepseek-ai/cordis'
     import AgentLoop from '@deepseek-ai/dsh-agent-loop'
     import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import { createUserMessage, ToolCallId, type GenerateOptions, LlmAdapter, type StreamChunk  } from '@deepseek-ai/dsh-llm'
     import { SessionId } from '@deepseek-ai/dsh-session'
     import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
    @@ -38,7 +37,6 @@ class CrashAdapter extends LlmAdapter {
     
     const ctx = new Context()
     await mountAgentLoopTestDependencies(ctx)
    -await ctx.plugin(SessionProjectionRegistry)
     await ctx.plugin(AgentLoop, { agents: [] })
     await ctx.plugin(JsonlSessionPersistence, { root: persistenceRoot, compression: 'none' })
     await ctx.plugin(checkpointPolicy)
    diff --git a/packages/session/session-format-catalog/package.json b/packages/session/session-format-catalog/package.json
    index 2fff7672ad..3c062061a4 100644
    --- a/packages/session/session-format-catalog/package.json
    +++ b/packages/session/session-format-catalog/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-format-catalog",
       "description": "Build-static first-party Session format codec and migration catalog",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-format-v0-to-v1/package.json b/packages/session/session-format-v0-to-v1/package.json
    index 729167146e..3267b3c16a 100644
    --- a/packages/session/session-format-v0-to-v1/package.json
    +++ b/packages/session/session-format-v0-to-v1/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-format-v0-to-v1",
       "description": "Frozen released-v0 Session codec and identity migration to v1",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-format-v1-to-v2/package.json b/packages/session/session-format-v1-to-v2/package.json
    index d0a2e61bce..4ecd4c1b02 100644
    --- a/packages/session/session-format-v1-to-v2/package.json
    +++ b/packages/session/session-format-v1-to-v2/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-format-v1-to-v2",
       "description": "Frozen released-v1 Session codec and assistant-stream migration to v2",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-format/package.json b/packages/session/session-format/package.json
    index 10579016f4..0d0be6f154 100644
    --- a/packages/session/session-format/package.json
    +++ b/packages/session/session-format/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-format",
       "description": "Streaming adjacent Session format migration machinery",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-log-deepseek/package.json b/packages/session/session-log-deepseek/package.json
    index 5e2d9ebba0..d43e32477c 100644
    --- a/packages/session/session-log-deepseek/package.json
    +++ b/packages/session/session-log-deepseek/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-log-deepseek",
       "description": "Incremental lossless session-log request extension for the official DeepSeek LLM API",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json
    index bd99d447c6..9a322762a7 100644
    --- a/packages/session/session-persistence-jsonl/package.json
    +++ b/packages/session/session-persistence-jsonl/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-persistence-jsonl",
       "description": "JSONL durable session persistence backend for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json
    index e97be74057..625a7ffbcf 100644
    --- a/packages/session/session-persistence/package.json
    +++ b/packages/session/session-persistence/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-persistence",
       "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json
    index 92f227e4a3..9d13a918fa 100644
    --- a/packages/session/session-projection-cache/package.json
    +++ b/packages/session/session-projection-cache/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-projection-cache",
       "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session checkpoint records on the session_projcache storage domain (per-record layout), throttled write-behind, and the cached listing read",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml
    index b740065e58..1762a3a480 100644
    --- a/packages/session/session-projection/README.i18n.yaml
    +++ b/packages/session/session-projection/README.i18n.yaml
    @@ -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/session/session-projection/README.md
    -README.md: 79902cca815da1ee93916ece82cf89bb3624a3a8
    -README.zh.md: 749934419cfc94a83abcb59013776fbb8b55ac19
    +README.md: a837f41db31dd7db5abf721acfcb7970950056ef
    +README.zh.md: fa4cdf32f5b2a9ce4944502363cca5788609372f
    diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md
    index 79902cca81..a837f41db3 100644
    --- a/packages/session/session-projection/README.md
    +++ b/packages/session/session-projection/README.md
    @@ -55,13 +55,15 @@ const definition = {
     
     ### Register and read
     
    -`register(definition)` installs the unit; the registration is an effect on the calling fiber, so unloading the domain removes its key. Carriers read a consistent synchronous cut over every client-visible unit with `snapshot(session)` — `{ asOfSeq, values }`, where `asOfSeq` is the seq of the last event every value reflects — and subscribe to per-change notifications with `onChanged(listener)`. `stateOf(session, key)` reads one unit's host state without computing unrelated views.
    +`register(definition)` installs the unit; registrants with the same key and `stateVersion` share its cells, while an incompatible version or invalid `stateVersion` throws. Registration is an effect on the calling fiber, so the last unload removes the key and its cached cells. Carriers read a consistent synchronous cut over every client-visible unit with `snapshot(session)` — `{ asOfSeq, values }`, where `asOfSeq` is the seq of the last event every value reflects — and subscribe to per-change notifications with `onChanged(listener)`. `stateOf(session, key)` reads one unit's live read-only host state without computing unrelated views.
     
     ```text
     const dispose = ctx.sessionProjections.register(definition)
     const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)
     ```
     
    +A domain that requires projected state declares `sessionProjections` as a Cordis service dependency; optional contributors may register under `ctx.inject(['sessionProjections'], …)`. Carriers use `ctx.get('sessionProjections')` and omit their block or frames when the registry is absent.
    +
     ### Persisted checkpoints
     
     Every unit's state is checkpointed — client-visible and host-only alike — through `checkpoint(session)`, and the sibling [session-projection-cache](../session-projection-cache/README.md) persists those checkpoints so cold reads skip full log loads. Checkpoint watermarks use `SessionSeqCursor` (`-1` for an empty log), while replay starts use `SessionLogOffset`; `restoreFloor` and `restore` implement the read recipe without conflating an existing event with a log gap.
    diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md
    index 749934419c..fa4cdf32f5 100644
    --- a/packages/session/session-projection/README.zh.md
    +++ b/packages/session/session-projection/README.zh.md
    @@ -55,13 +55,15 @@ const definition = {
     
     ### 注册与读取
     
    -`register(definition)` 安装单元;注册是挂在调用方 fiber 上的 effect,因此卸载领域即移除其 key。载体用 `snapshot(session)` 对每个客户端可见单元读取一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` 是所有值共同反映到的最后一个事件的 seq——并用 `onChanged(listener)` 订阅逐变更通知。`stateOf(session, key)` 读取一个单元的主机状态,不计算无关视图。
    +`register(definition)` 安装单元;具有相同 key 和 `stateVersion` 的注册方共享其 cell,版本不兼容或 `stateVersion` 非法时会 throw。注册是挂在调用方 fiber 上的 effect,因此最后一个注册方卸载后会移除 key 及其缓存 cell。载体用 `snapshot(session)` 对每个客户端可见单元读取一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` 是所有值共同反映到的最后一个事件的 seq——并用 `onChanged(listener)` 订阅逐变更通知。`stateOf(session, key)` 读取一个单元的实时只读 host 状态,不计算无关视图。
     
     ```text
     const dispose = ctx.sessionProjections.register(definition)
     const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)
     ```
     
    +必须使用投影状态的领域把 `sessionProjections` 声明为 Cordis 服务依赖;可选贡献方可以在 `ctx.inject(['sessionProjections'], …)` 下注册。载体使用 `ctx.get('sessionProjections')`,注册表缺席时省略自己的块或帧。
    +
     ### 持久检查点
     
     每个单元的状态都会被检查点化——client-visible 与 host-only 一视同仁——通过 `checkpoint(session)`,同级包 [session-projection-cache](../session-projection-cache/README.zh.md) 持久化这些检查点,使冷读跳过全量日志加载。检查点水位使用 `SessionSeqCursor`(空日志为 `-1`),回放起点使用 `SessionLogOffset`;`restoreFloor` 与 `restore` 在无活动会话的情况下实现读取配方,且不会混淆已有事件与日志间隙。
    diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json
    index f18f871e53..8032503307 100644
    --- a/packages/session/session-projection/package.json
    +++ b/packages/session/session-projection/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-projection",
       "description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-stats/package.json b/packages/session/session-stats/package.json
    index 96fb41d82b..4a33b21746 100644
    --- a/packages/session/session-stats/package.json
    +++ b/packages/session/session-stats/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-stats",
       "description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json
    index 0903a371b3..c699a1a449 100644
    --- a/packages/session/session-telemetry-otel/package.json
    +++ b/packages/session/session-telemetry-otel/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-telemetry-otel",
       "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json
    index ccf76b600d..8692375d5d 100644
    --- a/packages/session/session-telemetry/package.json
    +++ b/packages/session/session-telemetry/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-telemetry",
       "description": "SessionTelemetryBackend seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-title-all-prompts-llm/package.json b/packages/session/session-title-all-prompts-llm/package.json
    index 2bf029b651..71278cc11e 100644
    --- a/packages/session/session-title-all-prompts-llm/package.json
    +++ b/packages/session/session-title-all-prompts-llm/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-title-all-prompts-llm",
       "description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json
    index ab8b8f5356..eb7781cd9c 100644
    --- a/packages/session/session-title-first-prompt-llm/package.json
    +++ b/packages/session/session-title-first-prompt-llm/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-title-first-prompt-llm",
       "description": "First-message LLM provider plugin for DeepSeek Harness session titles",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json
    index a9e43cce45..fd8c5f95b6 100644
    --- a/packages/session/session-title-llm/package.json
    +++ b/packages/session/session-title-llm/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-title-llm",
       "description": "Shared LLM generation policy for DeepSeek Harness session-title providers",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json
    index b31538b5af..3d62e9e73a 100644
    --- a/packages/session/session-title/package.json
    +++ b/packages/session/session-title/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-title",
       "description": "Log-backed session title service and provider registry for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/session/session-turn-outline/package.json b/packages/session/session-turn-outline/package.json
    index a7aead8369..28de5570a0 100644
    --- a/packages/session/session-turn-outline/package.json
    +++ b/packages/session/session-turn-outline/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-session-turn-outline",
       "description": "Whole-log turn outline projection (turnOutline) for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/settings/settings-file/package.json b/packages/settings/settings-file/package.json
    index 69052c474b..fc0bef2ce3 100644
    --- a/packages/settings/settings-file/package.json
    +++ b/packages/settings/settings-file/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-settings-file",
       "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json
    index 903ce3a5ef..c2bf343542 100644
    --- a/packages/settings/settings/package.json
    +++ b/packages/settings/settings/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-settings",
       "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/shell/bash-local/package.json b/packages/shell/bash-local/package.json
    index 841bafdf2d..3d6c9de35b 100644
    --- a/packages/shell/bash-local/package.json
    +++ b/packages/shell/bash-local/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-bash-local",
       "description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json
    index 6d543e6f41..381aff48f5 100644
    --- a/packages/shell/bash-sandbox/package.json
    +++ b/packages/shell/bash-sandbox/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-bash-sandbox",
       "description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/shell/pwsh-local/package.json b/packages/shell/pwsh-local/package.json
    index a3f1fef031..b34d9e88f5 100644
    --- a/packages/shell/pwsh-local/package.json
    +++ b/packages/shell/pwsh-local/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-pwsh-local",
       "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json
    index fa722d97af..c4937c2d18 100644
    --- a/packages/shell/pwsh-sandbox/package.json
    +++ b/packages/shell/pwsh-sandbox/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-pwsh-sandbox",
       "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json
    index bf3beecb81..a6b10d8a56 100644
    --- a/packages/shell/shell-env/package.json
    +++ b/packages/shell/shell-env/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-shell-env",
       "description": "Tool-independent managed DSH_* shell environment registry",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/shell/shell/package.json b/packages/shell/shell/package.json
    index 89a575aca8..f8f88fd330 100644
    --- a/packages/shell/shell/package.json
    +++ b/packages/shell/shell/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-shell",
       "description": "Abstract bash executor seam (ctx.shell) for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json
    index 3f4ad336e5..8eae8597a3 100644
    --- a/packages/shell/tool-bash-persistent/package.json
    +++ b/packages/shell/tool-bash-persistent/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-bash-persistent",
       "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -39,6 +39,7 @@
         "@deepseek-ai/cordis-plugin-include": "workspace:^",
         "@deepseek-ai/cordis-plugin-loader": "workspace:^",
         "@deepseek-ai/dsh-agent": "workspace:^",
    +    "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
         "@deepseek-ai/dsh-llm": "workspace:^",
         "@deepseek-ai/dsh-terminal": "workspace:^",
         "@deepseek-ai/dsh-terminal-bash": "workspace:^",
    diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts
    index a9f4d613ca..82895a008c 100644
    --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts
    +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts
    @@ -8,7 +8,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
     import Include from '@deepseek-ai/cordis-plugin-include'
     import { ToolCallId } from '@deepseek-ai/dsh-llm'
     import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
    -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry from '@deepseek-ai/dsh-agent'
     import type { Agent } from '@deepseek-ai/dsh-agent'
     import TerminalSessionService from '@deepseek-ai/dsh-terminal'
     import * as TerminalLocal from '@deepseek-ai/dsh-terminal-bash'
    @@ -20,6 +20,7 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
     import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
     import ToolRuntime from '@deepseek-ai/dsh-tools'
     import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     let root: string | undefined
     let context: Context | undefined
    @@ -47,7 +48,7 @@ function agent(ctx: Context, cwd: string): Agent {
         id,
         options: {},
         session,
    -    inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +    inbox: unsupportedInbox(),
         status: 'idle',
         ctx: scope.ctx,
         send: () => {},
    diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts
    index c7a406663f..3afbecfec3 100644
    --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts
    +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts
    @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
     import { ToolCallId } from '@deepseek-ai/dsh-llm'
     import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
    -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry from '@deepseek-ai/dsh-agent'
     import type { Agent } from '@deepseek-ai/dsh-agent'
     import TerminalSessionService from '@deepseek-ai/dsh-terminal'
     import type {
    @@ -18,6 +18,7 @@ import type {
     import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
     import ToolRuntime from '@deepseek-ai/dsh-tools'
     import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     const contexts: Context[] = []
     let callNumber = 0
    @@ -40,7 +41,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent {
         id,
         options: {},
         session,
    -    inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +    inbox: unsupportedInbox(),
         status: 'idle',
         ctx: scope.ctx,
         send: () => {},
    diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json
    index 4828a3188a..f532508de3 100644
    --- a/packages/shell/tool-bash/package.json
    +++ b/packages/shell/tool-bash/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-bash",
       "description": "Model-facing bash tool with optional generic background-job and sandbox-escalation support",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/shell/tool-bash/tests/integration.spec.ts b/packages/shell/tool-bash/tests/integration.spec.ts
    index f06f4d22f3..cf2a4e1d2e 100644
    --- a/packages/shell/tool-bash/tests/integration.spec.ts
    +++ b/packages/shell/tool-bash/tests/integration.spec.ts
    @@ -6,7 +6,6 @@ import { tmpdir } from 'node:os'
     import { join } from 'node:path'
     import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
     import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import type { Agent } from '@deepseek-ai/dsh-agent'
     import AgentLoop from '@deepseek-ai/dsh-agent-loop'
     import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
    @@ -27,8 +26,6 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
     async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: string) {
       const ctx = new Context()
       await mountAgentLoopTestDependencies(ctx)
    -  // AgentLoop declares the registry as a required injection.
    -  await ctx.plugin(SessionProjectionRegistry)
       if (sessionRoot !== undefined) {
         await ctx.plugin(JsonlSessionPersistence, { root: sessionRoot, compression: 'none' })
       }
    diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json
    index 643c295de0..5089ec937b 100644
    --- a/packages/shell/tool-pwsh-persistent/package.json
    +++ b/packages/shell/tool-pwsh-persistent/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-pwsh-persistent",
       "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -39,6 +39,7 @@
         "@deepseek-ai/cordis-plugin-include": "workspace:^",
         "@deepseek-ai/cordis-plugin-loader": "workspace:^",
         "@deepseek-ai/dsh-agent": "workspace:^",
    +    "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
         "@deepseek-ai/dsh-llm": "workspace:^",
         "@deepseek-ai/dsh-terminal": "workspace:^",
         "@deepseek-ai/dsh-terminal-bash": "workspace:^",
    diff --git a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts
    index 74bc8c0514..05116a6b5c 100644
    --- a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts
    +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts
    @@ -9,8 +9,8 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
     import Include from '@deepseek-ai/cordis-plugin-include'
     import { ToolCallId } from '@deepseek-ai/dsh-llm'
     import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
    +import AgentRegistry from '@deepseek-ai/dsh-agent'
     import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
    -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
     import type { Agent } from '@deepseek-ai/dsh-agent'
     import TerminalSessionService from '@deepseek-ai/dsh-terminal'
     import * as TerminalBash from '@deepseek-ai/dsh-terminal-bash'
    @@ -22,6 +22,7 @@ import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts'
     import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
     import ToolRegistry from '@deepseek-ai/dsh-tools'
     import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     const hasPwsh = spawnSync(
       resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'],
    @@ -54,7 +55,7 @@ function agent(ctx: Context, cwd: string): Agent {
         id,
         options: {},
         session,
    -    inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +    inbox: unsupportedInbox(),
         status: 'idle',
         ctx: scope.ctx,
         send: () => {},
    diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts
    index 4190bdb604..e65859e4c8 100644
    --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts
    +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts
    @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
     import { ToolCallId } from '@deepseek-ai/dsh-llm'
     import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
    -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry from '@deepseek-ai/dsh-agent'
     import type { Agent } from '@deepseek-ai/dsh-agent'
     import TerminalSessionService from '@deepseek-ai/dsh-terminal'
     import type {
    @@ -18,6 +18,7 @@ import type {
     import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
     import ToolRegistry from '@deepseek-ai/dsh-tools'
     import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     const contexts: Context[] = []
     let callNumber = 0
    @@ -40,7 +41,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent {
         id,
         options: {},
         session,
    -    inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +    inbox: unsupportedInbox(),
         status: 'idle',
         ctx: scope.ctx,
         send: () => {},
    diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json
    index df1ee7c707..9a952d225f 100644
    --- a/packages/shell/tool-pwsh/package.json
    +++ b/packages/shell/tool-pwsh/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-pwsh",
       "description": "Model-facing pwsh tool over the bash executor seam",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json
    index 3161965091..2861ea9d8d 100644
    --- a/packages/skill/skill-badge/package.json
    +++ b/packages/skill/skill-badge/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-skill-badge",
       "description": "Bundled dsh badge skill provider for DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/skill/skill-filesystem/package.json b/packages/skill/skill-filesystem/package.json
    index b8490fedb1..c1d7bd84e2 100644
    --- a/packages/skill/skill-filesystem/package.json
    +++ b/packages/skill/skill-filesystem/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-skill-filesystem",
       "description": "Local filesystem skill provider for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts b/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts
    index 054535d7e7..005c89b10c 100644
    --- a/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts
    +++ b/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts
    @@ -108,6 +108,10 @@ class TestFileSystem extends FileSystem {
         throw new Error('not needed in skill tests')
       }
     
    +  override async readByteRange(_target: FsTarget, _range: { offset: number; length: number }, _signal?: AbortSignal): Promise {
    +    throw new Error('not needed in skill tests')
    +  }
    +
       override async listDir(target: FsTarget): Promise {
         this.listDirCalls += 1
         if (this.failListDirPaths.has(target.displayPath)) throw new Error('list temporarily failed')
    diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json
    index e1c2f1d06e..6674f17164 100644
    --- a/packages/skill/skill/package.json
    +++ b/packages/skill/skill/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-skill",
       "description": "Agent skill provider registry for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json
    index 031a651239..49be90ea89 100644
    --- a/packages/skill/tool-skill/package.json
    +++ b/packages/skill/tool-skill/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-skill",
       "description": "Model-facing skill loading tool for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -38,6 +38,7 @@
       },
       "devDependencies": {
         "@deepseek-ai/dsh-agent": "workspace:^",
    +    "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
         "@deepseek-ai/dsh-llm": "workspace:^",
         "@deepseek-ai/dsh-scope": "workspace:^",
         "@deepseek-ai/dsh-session": "workspace:^",
    diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts
    index ffcb54268b..29de7d0bd7 100644
    --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts
    +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts
    @@ -10,10 +10,11 @@ import {
     } from '@deepseek-ai/dsh-session'
     import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
     import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
    -import AgentRegistry, { agentEvents, Inbox, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry, { agentEvents, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent'
     import SkillRegistry from '@deepseek-ai/dsh-skill'
     import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem'
     import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     const testToolSignal = new AbortController().signal
     
    @@ -56,7 +57,7 @@ function agentForCwd(cwd: string): Agent {
         id,
         options: {},
         session,
    -    inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +    inbox: unsupportedInbox(),
         status: 'idle',
         send: () => {},
         followup: () => {},
    @@ -69,11 +70,11 @@ function agentForCwd(cwd: string): Agent {
     }
     
     function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
    -  return {
    +  const agent: Agent = {
         id: SessionId(id),
         options: {},
         session,
    -    inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +    inbox: unsupportedInbox(),
         status: 'running',
         ctx: new Context(),
         send: () => {},
    @@ -84,6 +85,7 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
         runMaintenance: task => task(new AbortController().signal),
         whenIdle: () => Promise.resolve(),
       }
    +  return agent
     }
     
     function openMessageTurn(session: Session, turn = 1): void {
    diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json
    index 465da9045d..c9dd80e27f 100644
    --- a/packages/spill/spill-local/package.json
    +++ b/packages/spill/spill-local/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-spill-local",
       "description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json
    index 4227560baa..68e8944442 100644
    --- a/packages/spill/spill-policy/package.json
    +++ b/packages/spill/spill-policy/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-spill-policy",
       "description": "Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service API)",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json
    index 9cba6b38a3..41b1a33a8d 100644
    --- a/packages/spill/spill/package.json
    +++ b/packages/spill/spill/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-spill",
       "description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json
    index 4090385679..3991c87433 100644
    --- a/packages/storage/storage-domain/package.json
    +++ b/packages/storage/storage-domain/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-storage-domain",
       "description": "Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json
    index 8377879175..8a3113f556 100644
    --- a/packages/storage/storage-json/package.json
    +++ b/packages/storage/storage-json/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-storage-json",
       "description": "JSON file KV storage backend for the DeepSeek Harness storage hub",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json
    index c792dc793e..c780c29aee 100644
    --- a/packages/storage/storage-sqlite/package.json
    +++ b/packages/storage/storage-sqlite/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-storage-sqlite",
       "description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json
    index 6b58a75b91..9808ce0178 100644
    --- a/packages/storage/storage/package.json
    +++ b/packages/storage/storage/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-storage",
       "description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json
    index a6af312abd..25b205c1d1 100644
    --- a/packages/subagent/subagent-acp/package.json
    +++ b/packages/subagent/subagent-acp/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-subagent-acp",
       "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json
    index c6a13ed6a2..4d86ac89d3 100644
    --- a/packages/subagent/subagent-claude-code/package.json
    +++ b/packages/subagent/subagent-claude-code/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-subagent-claude-code",
       "description": "One-shot Claude Code subagent provider over the official Agent SDK",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json
    index 1abf92f843..9a4a2984e3 100644
    --- a/packages/subagent/subagent-codex/package.json
    +++ b/packages/subagent/subagent-codex/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-subagent-codex",
       "description": "One-shot Codex subagent provider over the official app-server protocol",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json
    index c0351818ab..37b8cba65a 100644
    --- a/packages/subagent/subagent-dsh-sdk/package.json
    +++ b/packages/subagent/subagent-dsh-sdk/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-subagent-dsh-sdk",
       "description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json
    index cd934b57f7..cdf0f83719 100644
    --- a/packages/subagent/subagent-fork-in-process/package.json
    +++ b/packages/subagent/subagent-fork-in-process/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-subagent-fork-in-process",
       "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts
    index e761f070af..35c8af4df3 100644
    --- a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts
    +++ b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts
    @@ -9,7 +9,6 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
     import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
     import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
     import SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import * as Spawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
     import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
     import * as fork from '../src/index.ts'
    @@ -38,7 +37,6 @@ async function setup(script: Script) {
       await mountAgentLoopTestDependencies(ctx)
       await mountInvariants(ctx)
       await ctx.plugin(AgentLoop, { agents: [] })
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(SubagentRuntime)
       await ctx.plugin(Spawn, { providerName: 'spawn' })
       await ctx.plugin(fork, { providerName: 'fork' })
    diff --git a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts
    index ac9a994c29..6f7080cd31 100644
    --- a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts
    +++ b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts
    @@ -45,7 +45,6 @@ async function setup(script: Script) {
       await mountAgentLoopTestDependencies(ctx)
       await mountInvariants(ctx)
       await ctx.plugin(AgentLoop, { agents: [] })
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(SubagentRuntime)
       await ctx.plugin(fork, { providerName: 'fork' })
       ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
    diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json
    index f72a597127..f9b889114a 100644
    --- a/packages/subagent/subagent-in-process-driver/package.json
    +++ b/packages/subagent/subagent-in-process-driver/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-subagent-in-process-driver",
       "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts
    index 4d86cb3705..abc09ff53d 100644
    --- a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts
    +++ b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts
    @@ -18,7 +18,6 @@ import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
     import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
     import ApprovalService from '@deepseek-ai/dsh-user-approval'
     import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
     import { startInProcessRun } from '../src/index.ts'
     
    @@ -41,7 +40,6 @@ async function setupWalled(script: Script): Promise<{ ctx: Context; parent: Agen
       const ctx = new Context()
       contexts.push(ctx)
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace })
       await ctx.plugin(SandboxedFileSystem, { cwd: workspace })
       await ctx.plugin(ToolFs)
    diff --git a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts
    index d34729037c..d3a88e5572 100644
    --- a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts
    +++ b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts
    @@ -18,7 +18,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
     import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
     import AgentPresets from '@deepseek-ai/dsh-agent-presets'
     import { SessionId } from '@deepseek-ai/dsh-session'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
     import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
     import { startInProcessRun } from '../src/index.ts'
    @@ -40,7 +39,6 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter;
       await ctx.plugin(Loader)
       ctx.loader.builtins.include = Include
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(AgentLoop, { agents: [] })
       await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false })
       const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')])
    diff --git a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts
    index 2b5350d951..c529459b82 100644
    --- a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts
    +++ b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts
    @@ -13,7 +13,6 @@ import SubagentRuntime, {
       type ResolvedSubagentStartRequest,
       type SubagentStartRequest,
     } from '@deepseek-ai/dsh-subagent'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
     import { defineContentToolFixture, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
     import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
    @@ -69,7 +68,6 @@ async function setup(script: Script, options: SetupOptions = {}) {
       }
       await mountInvariants(ctx)
       await ctx.plugin(AgentLoop, { agents: [] })
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(SubagentRuntime)
       const disposeProvider = ctx.subagents.registerProvider({
         name: 'spawn',
    diff --git a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts
    index c669e614b6..d257c3d442 100644
    --- a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts
    +++ b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts
    @@ -10,7 +10,6 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
     import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
     import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
     import SubagentRuntime, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
     import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
     import { startInProcessRun } from '../src/index.ts'
    @@ -29,7 +28,6 @@ async function setup(script: Script, parentOptions: Partial = {})
       await mountAgentLoopTestDependencies(ctx)
       await mountInvariants(ctx)
       await ctx.plugin(AgentLoop, { agents: [] })
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(SubagentRuntime)
       const adapter = new MockAdapter(script)
       ctx.llm.registerAdapter(['mock'], adapter)
    diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json
    index 6c95b96103..353541f976 100644
    --- a/packages/subagent/subagent-spawn-in-process/package.json
    +++ b/packages/subagent/subagent-spawn-in-process/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-subagent-spawn-in-process",
       "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/subagent/subagent-spawn-in-process/tests/harness.ts b/packages/subagent/subagent-spawn-in-process/tests/harness.ts
    index 89606e923b..03442bfb11 100644
    --- a/packages/subagent/subagent-spawn-in-process/tests/harness.ts
    +++ b/packages/subagent/subagent-spawn-in-process/tests/harness.ts
    @@ -1,7 +1,6 @@
     import { Context } from '@deepseek-ai/cordis'
     import type { Agent } from '@deepseek-ai/dsh-agent'
     import AgentLoop from '@deepseek-ai/dsh-agent-loop'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
     import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
     import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
    @@ -25,7 +24,6 @@ export async function spawnHarness(workdir: string): Promise {
       // spawned children render it. It stays neutral for both roles; the
       // delegation nudge lives in the e2e's user prompt and the subagent tool's
       // own description.
    -  await ctx.plugin(SessionProjectionRegistry)
       await mountAgentLoopTestDependencies(ctx, {
         systemPrompt: { personaPrefix: 'You are a coding agent. Report only when the requested work is done.' },
       })
    diff --git a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts
    index dc9d3ed4d1..21081b417c 100644
    --- a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts
    +++ b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts
    @@ -39,7 +39,6 @@ async function setup(script: Script) {
       await mountAgentLoopTestDependencies(ctx)
       await mountInvariants(ctx)
       await ctx.plugin(AgentLoop, { agents: [] })
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(SubagentRuntime)
       await ctx.plugin(spawn, { providerName: 'spawn' })
       ctx.llm.registerAdapter(['mock'], adapter)
    @@ -330,7 +329,6 @@ describe('dsh-subagent-spawn-in-process', () => {
         await mountAgentLoopTestDependencies(ctx)
         await mountInvariants(ctx)
         await ctx.plugin(AgentLoop, { agents: [] })
    -    await ctx.plugin(SessionProjectionRegistry)
         await ctx.plugin(SubagentRuntime)
         const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
         ctx.llm.registerAdapter(['mock'], adapter)
    @@ -359,7 +357,6 @@ describe('dsh-subagent-spawn-in-process', () => {
         const ctx = new Context()
         await mountAgentLoopTestDependencies(ctx)
         await ctx.plugin(AgentLoop, { agents: [] })
    -    await ctx.plugin(SessionProjectionRegistry)
         await ctx.plugin(SubagentRuntime)
         const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
         const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
    diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json
    index 24c699d436..18e118e6cc 100644
    --- a/packages/subagent/subagent/package.json
    +++ b/packages/subagent/subagent/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-subagent",
       "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/subagent/subagent/src/inbox.ts b/packages/subagent/subagent/src/inbox.ts
    index 8800e3ce2b..e610db2014 100644
    --- a/packages/subagent/subagent/src/inbox.ts
    +++ b/packages/subagent/subagent/src/inbox.ts
    @@ -35,7 +35,7 @@ export class SubagentInbox {
        * @returns whether either Agent inbox destination is non-empty.
        */
       get hasPending(): boolean {
    -    return this.agent.inbox.hasPending
    +    return this.agent.inbox.nextTurn.length > 0 || this.agent.inbox.nextStep.length > 0
       }
     
       /**
    diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts
    index d1dde79a41..9e3a6989c6 100644
    --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts
    +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts
    @@ -19,7 +19,6 @@ import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-p
     import { Session, SessionId } from '@deepseek-ai/dsh-session'
     import type { SessionEvent } from '@deepseek-ai/dsh-session'
     import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import { queueHostSubagentPrompt } from '@deepseek-ai/dsh-subagent/internal'
     import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process'
     import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
    @@ -43,7 +42,6 @@ async function setup(script: Script) {
       const ctx = new Context()
       contexts.push(ctx)
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
       const root = mkdtempSync(join(tmpdir(), 'dsh-continuation-inherit-'))
       roots.push(root)
       await ctx.plugin(JsonlSessionPersistence, { root })
    diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts
    index 8e082debe4..a99ac93e71 100644
    --- a/packages/subagent/subagent/tests/continuation.spec.ts
    +++ b/packages/subagent/subagent/tests/continuation.spec.ts
    @@ -9,7 +9,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
     import { SessionId } from '@deepseek-ai/dsh-session'
     import type { SessionEvent } from '@deepseek-ai/dsh-session'
     import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
     import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process'
     import type { ContentBlock, GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm'
    @@ -80,9 +79,6 @@ async function setupWith(
     ) {
       const ctx = new Context()
       await mountAgentLoopTestDependencies(ctx)
    -  // The registry is a required injection of AgentLoop and SubagentRuntime
    -  // (both register projection units on activation).
    -  await ctx.plugin(SessionProjectionRegistry)
       let disposePersistence: (() => Promise) | undefined
       let root: string | undefined
       if (options.persistence !== false) {
    @@ -527,7 +523,6 @@ describe('SubagentRuntime.startContinuable', () => {
     
         const fresh = new Context()
         await mountAgentLoopTestDependencies(fresh)
    -    await fresh.plugin(SessionProjectionRegistry)
         const freshPersistence = await fresh.plugin(JsonlSessionPersistence, { root: root! })
         // This context opened a second handle on the same root; register it so
         // afterEach closes it before removing the root (even on a failure path).
    @@ -3235,7 +3230,6 @@ describe('continuable errors', () => {
         const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }])
         const ctx = new Context()
         await mountAgentLoopTestDependencies(ctx)
    -    await ctx.plugin(SessionProjectionRegistry)
         const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-'))
         const persistenceFiber = await ctx.plugin(JsonlSessionPersistence, { root })
         cleanups.push(async () => {
    diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts
    index c8b526920d..a9e34f9ffc 100644
    --- a/packages/subagent/subagent/tests/list-children.spec.ts
    +++ b/packages/subagent/subagent/tests/list-children.spec.ts
    @@ -47,7 +47,7 @@ afterEach(async () => {
     /** Boot the continuable stack with real JSONL session persistence. */
     async function setup(
       script: Script,
    -  options: { sessionProjections?: boolean; projectionCache?: boolean } = {},
    +  options: { projectionCache?: boolean } = {},
     ) {
       const ctx = new Context()
       await mountAgentLoopTestDependencies(ctx)
    @@ -56,7 +56,6 @@ async function setup(
       const persistence = await ctx.plugin(JsonlSessionPersistence, { root })
       persistenceDisposers.push(() => persistence.dispose())
       await ctx.plugin(AgentLoop, { agents: [] })
    -  if (options.sessionProjections !== false) await ctx.plugin(SessionProjectionRegistry)
       if (options.projectionCache === true) {
         const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-projcache-'))
         projCacheRoots.push(root)
    @@ -82,6 +81,13 @@ async function setup(
       return { ctx, parent }
     }
     
    +async function setupWithoutProjections(): Promise {
    +  const ctx = new Context()
    +  await ctx.plugin(SessionStore)
    +  await ctx.plugin(SubagentRuntime)
    +  return ctx
    +}
    +
     const testSignal = new AbortController().signal
     
     /** Start one continuable child through the real service path and await Activation release. */
    @@ -246,8 +252,8 @@ describe('SubagentRuntime.listChildren', () => {
       })
     
       it('fails loud when the projection registry is not mounted, even with no children', async () => {
    -    const { ctx, parent } = await setup([], { sessionProjections: false })
    -    await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
    +    const ctx = await setupWithoutProjections()
    +    await expect(ctx.subagents.listChildren(SessionId('no-projections-parent'))).rejects.toThrow(
           expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error,
         )
       })
    @@ -1108,8 +1114,9 @@ describe('SubagentRuntime.listChildren', () => {
       })
     
       it('SubagentError from listChildren is typed with its stable code', async () => {
    -    const { ctx, parent } = await setup([], { sessionProjections: false })
    -    const caught: unknown = await ctx.subagents.listChildren(parent.id).catch((error: unknown) => error)
    +    const ctx = await setupWithoutProjections()
    +    const caught: unknown = await ctx.subagents.listChildren(SessionId('typed-no-projections-parent'))
    +      .catch((error: unknown) => error)
         expect(caught).toBeInstanceOf(SubagentError)
         expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE')
       })
    @@ -1344,8 +1351,8 @@ describe('SubagentRuntime.listDescendants', () => {
       })
     
       it('fails loud when the projection registry is not mounted', async () => {
    -    const { ctx, parent } = await setup([], { sessionProjections: false })
    -    await expect(ctx.subagents.listDescendants(parent.id)).rejects.toThrow(
    +    const ctx = await setupWithoutProjections()
    +    await expect(ctx.subagents.listDescendants(SessionId('no-projections-root'))).rejects.toThrow(
           expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error,
         )
       })
    diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json
    index 333e5c0f64..e1b6f27766 100644
    --- a/packages/subagent/tool-subagent-control/package.json
    +++ b/packages/subagent/tool-subagent-control/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-subagent-control",
       "description": "Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts
    index 8b190d8092..b21de72a90 100644
    --- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts
    +++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts
    @@ -8,7 +8,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
     import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
     import { SessionId } from '@deepseek-ai/dsh-session'
     import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import SubagentRuntime from '@deepseek-ai/dsh-subagent'
     import type { SubagentListEntry } from '@deepseek-ai/dsh-subagent'
     import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
    @@ -60,7 +59,6 @@ async function setupWith(adapter: MockAdapter | GatedAdapter) {
       await ctx.plugin(JsonlSessionPersistence, { root })
       await ctx.plugin(TestSessionQuery)
       await ctx.plugin(AgentLoop, { agents: [] })
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(SubagentRuntime)
       await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
       await ctx.plugin(tool)
    @@ -251,7 +249,6 @@ describe('dsh-tool-subagent-control/list-agents', () => {
         const ctx = new Context()
         await mountAgentLoopTestDependencies(ctx)
         await ctx.plugin(AgentLoop, { agents: [] })
    -    await ctx.plugin(SessionProjectionRegistry)
         await ctx.plugin(SubagentRuntime)
         const fiber = await ctx.plugin(tool)
         expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(true)
    diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts
    index da7fe7a51b..c1cb00e114 100644
    --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts
    +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts
    @@ -9,7 +9,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
     import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
     import { SessionId } from '@deepseek-ai/dsh-session'
     import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
    -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import SubagentRuntime from '@deepseek-ai/dsh-subagent'
     import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process'
     import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
    @@ -62,7 +61,6 @@ async function setupWith(adapter: MockAdapter | GatedAdapter, park = true) {
       await ctx.plugin(JsonlSessionPersistence, { root })
       await ctx.plugin(TestSessionQuery)
       await ctx.plugin(AgentLoop, { agents: [] })
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(SubagentRuntime)
       await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
       await ctx.plugin(SubagentFork, { providerName: 'fork' })
    @@ -333,7 +331,6 @@ describe('dsh-tool-subagent-control', () => {
         const ctx = new Context()
         await mountAgentLoopTestDependencies(ctx)
         await ctx.plugin(AgentLoop, { agents: [] })
    -    await ctx.plugin(SessionProjectionRegistry)
         await ctx.plugin(SubagentRuntime)
         const fiber = await ctx.plugin(tool)
         expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true)
    diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json
    index 628ce10bd4..340d80437d 100644
    --- a/packages/subagent/tool-subagent/package.json
    +++ b/packages/subagent/tool-subagent/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-subagent",
       "description": "Model-facing subagent delegation tool over the ctx.subagents seam",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/subagent/tool-subagent/tests/harness.ts b/packages/subagent/tool-subagent/tests/harness.ts
    index acd1b02c7c..bda0a5ae63 100644
    --- a/packages/subagent/tool-subagent/tests/harness.ts
    +++ b/packages/subagent/tool-subagent/tests/harness.ts
    @@ -50,7 +50,6 @@ export async function setup(toolConfig: SetupConfig, mockConfig: Partial {
       await ctx.plugin(MemorySettings)
       await ctx.plugin(SubagentModelSelectionConfig)
       await mountAgentLoopTestDependencies(ctx)
    -  await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(AgentLoop, { agents: [] })
       await ctx.plugin(SubagentRuntime)
       await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
    @@ -327,7 +326,6 @@ describe('SubagentModelSelectionConfig', () => {
       it('requires both the Host setting owner and a composition scope', async () => {
         const withoutSettings = new Context()
         await mountAgentLoopTestDependencies(withoutSettings)
    -    await withoutSettings.plugin(SessionProjectionRegistry)
         await withoutSettings.plugin(SubagentRuntime)
         expect(() => {
           tool.apply(withoutSettings, {
    diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
    index e5f9502a43..1e79190deb 100644
    --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
    +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
    @@ -1182,7 +1182,7 @@ describe('dsh-tool-subagent continuable background mode', () => {
     
       /** Boot the real continuable stack without any model-facing follow-up adapter. */
       async function continuableSetup() {
    -    const ctx = await projectedContext()
    +    const ctx = new Context()
         await mountAgentLoopTestDependencies(ctx)
         const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-'))
         roots.push(root)
    diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json
    index e8797463c1..59fd51755b 100644
    --- a/packages/subprocess/subprocess-local/package.json
    +++ b/packages/subprocess/subprocess-local/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-subprocess-local",
       "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json
    index bb5f574ccb..c1d7daa014 100644
    --- a/packages/subprocess/subprocess/package.json
    +++ b/packages/subprocess/subprocess/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-subprocess",
       "description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json
    index ac7e548d56..31f4efb947 100644
    --- a/packages/subprocess/win32-process/package.json
    +++ b/packages/subprocess/win32-process/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-win32-process",
       "description": "Shared low-level Win32 process, stdio, and Job Object primitives",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json
    index 34950b167d..e378385dc5 100644
    --- a/packages/terminal/terminal-bash/package.json
    +++ b/packages/terminal/terminal-bash/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-terminal-bash",
       "description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -43,6 +43,7 @@
       },
       "devDependencies": {
         "@deepseek-ai/dsh-agent": "workspace:^",
    +    "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
         "@deepseek-ai/dsh-terminal": "workspace:^",
         "@deepseek-ai/dsh-sandbox": "workspace:^",
         "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
    diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts
    index ff9bb91afb..af707d8269 100644
    --- a/packages/terminal/terminal-bash/tests/index.spec.ts
    +++ b/packages/terminal/terminal-bash/tests/index.spec.ts
    @@ -4,7 +4,7 @@ import { resolve } from 'node:path'
     import { Context } from '@deepseek-ai/cordis'
     import Loader from '@deepseek-ai/cordis-plugin-loader'
     import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
    -import AgentRegistry, { Inbox, type Agent } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
     import SandboxProvider from '@deepseek-ai/dsh-sandbox'
     import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
     import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
    @@ -23,6 +23,7 @@ import type {
       SubprocessTerminalHandle,
       SubprocessTerminalSpawnSpec,
     } from '@deepseek-ai/dsh-subprocess'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     class EmptySandbox extends SandboxProvider {
       confine(_argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
    @@ -54,7 +55,7 @@ function agent(ctx: Context, cwd?: string): Agent {
         version: SESSION_FORMAT_VERSION, id, createdAt: 0, isSeeded: false, ...cwd === undefined ? {} : { cwd },
       })
       return {
    -    id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +    id, options: {}, session, inbox: unsupportedInbox(),
         status: 'idle',
         ctx,
         send: () => {},
    @@ -593,7 +594,7 @@ describe('terminal-bash plugin shape', () => {
         const session = ctx.sessions.create(SessionId('mode-owner'))
         const ownerFiber = await ctx.plugin(() => {})
         const owner: Agent = {
    -      id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +      id: session.id, options: {}, session, inbox: unsupportedInbox(),
           status: 'idle',
           ctx: ownerFiber.ctx,
           send: () => {},
    @@ -643,7 +644,7 @@ describe('terminal-bash plugin shape', () => {
         const session = ctx.sessions.create(SessionId('pending-mode-owner'))
         const ownerFiber = await ctx.plugin(() => {})
         const owner: Agent = {
    -      id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +      id: session.id, options: {}, session, inbox: unsupportedInbox(),
           status: 'idle',
           ctx: ownerFiber.ctx,
           send: () => {},
    diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts
    index 1df9b7cb9e..9800a79d54 100644
    --- a/packages/terminal/terminal-bash/tests/local.spec.ts
    +++ b/packages/terminal/terminal-bash/tests/local.spec.ts
    @@ -5,7 +5,7 @@ import { join } from 'node:path'
     import { afterEach, describe, expect, it } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
     import { Session, SessionId } from '@deepseek-ai/dsh-session'
    -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry from '@deepseek-ai/dsh-agent'
     import type { Agent } from '@deepseek-ai/dsh-agent'
     import TerminalSessionService from '@deepseek-ai/dsh-terminal'
     import type { TerminalSendOperation } from '@deepseek-ai/dsh-terminal'
    @@ -16,6 +16,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
     import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts'
     import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     const roots: string[] = []
     const contexts: Context[] = []
    @@ -38,8 +39,8 @@ function stubAgent(ctx: Context, rawId: string): Agent {
       const id = SessionId(rawId)
       const scope = ctx.plugin(() => {})
       const session = Session.create(id)
    -  return {
    -    id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +  const agent: Agent = {
    +    id, options: {}, session, inbox: unsupportedInbox(),
         status: 'idle',
         ctx: scope.ctx,
         send: () => {},
    @@ -47,6 +48,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
         runMaintenance: task => task(new AbortController().signal),
         whenIdle: () => Promise.resolve(),
       }
    +  return agent
     }
     
     async function harness(
    diff --git a/packages/terminal/terminal/package.json b/packages/terminal/terminal/package.json
    index 334b16c864..48ca98a847 100644
    --- a/packages/terminal/terminal/package.json
    +++ b/packages/terminal/terminal/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-terminal",
       "description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -33,6 +33,7 @@
       },
       "devDependencies": {
         "@deepseek-ai/dsh-agent": "workspace:^",
    +    "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
         "@deepseek-ai/dsh-brand": "workspace:^",
         "@deepseek-ai/dsh-session": "workspace:^",
         "@deepseek-ai/cordis": "workspace:^"
    diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts
    index 10484d3c5c..c5c552d18f 100644
    --- a/packages/terminal/terminal/tests/service.spec.ts
    +++ b/packages/terminal/terminal/tests/service.spec.ts
    @@ -1,7 +1,7 @@
     import { describe, expect, expectTypeOf, it } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
     import { Session, SessionId } from '@deepseek-ai/dsh-session'
    -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry from '@deepseek-ai/dsh-agent'
     import type { Agent } from '@deepseek-ai/dsh-agent'
     import TerminalSessionService, { TerminalBackendCleanupError, TerminalError, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
     import type {
    @@ -14,6 +14,7 @@ import type {
       TerminalSessionStatus,
       TerminalSignal,
     } from '@deepseek-ai/dsh-terminal'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     const agentScopeDisposers = new WeakMap Promise>()
     const ptyServiceDisposers = new WeakMap Promise>()
    @@ -26,7 +27,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
         id,
         options: {},
         session,
    -    inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +    inbox: unsupportedInbox(),
         status: 'idle',
         ctx: scopeFiber.ctx,
         send: () => {},
    diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json
    index e9a179124b..dd09568343 100644
    --- a/packages/terminal/tool-terminal/package.json
    +++ b/packages/terminal/tool-terminal/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "@deepseek-ai/dsh-tool-terminal",
       "description": "Six model-facing persistent PTY tools with owner isolation and generic background-job integration",
    -  "version": "0.1.3-alpha.1",
    +  "version": "0.1.3-alpha.2",
       "publishConfig": {
         "access": "public"
       },
    @@ -43,6 +43,7 @@
         "@deepseek-ai/cordis-plugin-include": "workspace:^",
         "@deepseek-ai/cordis-plugin-loader": "workspace:^",
         "@deepseek-ai/dsh-agent": "workspace:^",
    +    "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
         "@deepseek-ai/dsh-llm": "workspace:^",
         "@deepseek-ai/dsh-terminal": "workspace:^",
         "@deepseek-ai/dsh-terminal-bash": "workspace:^",
    diff --git a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts
    index 6c4fe65e6a..c6f8f494de 100644
    --- a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts
    +++ b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts
    @@ -8,7 +8,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
     import Include from '@deepseek-ai/cordis-plugin-include'
     import { ToolCallId } from '@deepseek-ai/dsh-llm'
     import { Session, SessionId } from '@deepseek-ai/dsh-session'
    -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry from '@deepseek-ai/dsh-agent'
     import type { Agent } from '@deepseek-ai/dsh-agent'
     import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
     import ToolRuntime from '@deepseek-ai/dsh-tools'
    @@ -20,6 +20,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
     import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
     import * as TerminalLocal from '@deepseek-ai/dsh-terminal-bash'
     import * as ToolPty from '@deepseek-ai/dsh-tool-terminal'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     let root: string | undefined
     let context: Context | undefined
    @@ -42,7 +43,7 @@ function agent(ctx: Context): Agent {
       const id = SessionId('pty-loader-agent')
       const session = Session.create(id)
       const value: Agent = {
    -    id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +    id, options: {}, session, inbox: unsupportedInbox(),
         status: 'idle',
         ctx: scope.ctx,
         send: () => {},
    diff --git a/packages/terminal/tool-terminal/tests/tools.spec.ts b/packages/terminal/tool-terminal/tests/tools.spec.ts
    index 15151f7bd2..8c0c7f6292 100644
    --- a/packages/terminal/tool-terminal/tests/tools.spec.ts
    +++ b/packages/terminal/tool-terminal/tests/tools.spec.ts
    @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
     import { Context } from '@deepseek-ai/cordis'
     import { ToolCallId } from '@deepseek-ai/dsh-llm'
     import { Session, SessionId } from '@deepseek-ai/dsh-session'
    -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
    +import AgentRegistry from '@deepseek-ai/dsh-agent'
     import type { Agent } from '@deepseek-ai/dsh-agent'
     import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
     import ToolRuntime, { renderToolsSdk } from '@deepseek-ai/dsh-tools'
    @@ -12,13 +12,14 @@ import type { TerminalBackend, TerminalBackendSession, TerminalSendOperation, Te
     import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
     import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
     import * as ToolPty from '@deepseek-ai/dsh-tool-terminal'
    +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
     
     function fakeAgent(ctx: Context, rawId: string): Agent {
       const scope = ctx.plugin(() => {})
       const id = SessionId(rawId)
       const session = Session.create(id)
       const agent: Agent = {
    -    id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
    +    id, options: {}, session, inbox: unsupportedInbox(),
         status: 'idle',
         ctx: scope.ctx,
         send: () => {},
    diff --git a/packages/test-support/agent-loop-testkit/README.i18n.yaml b/packages/test-support/agent-loop-testkit/README.i18n.yaml
    index 7a4efa8491..3f0298285b 100644
    --- a/packages/test-support/agent-loop-testkit/README.i18n.yaml
    +++ b/packages/test-support/agent-loop-testkit/README.i18n.yaml
    @@ -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/test-support/agent-loop-testkit/README.md
    -README.md: 7b935594636e265bf4f808f692bba2fae0f83ddf
    -README.zh.md: fc9dc688a1e7d4303f7744ed47c7da2d0fbbf67f
    +README.md: 7c77cf77cf20817795f43c3493bff8e5c7ccd900
    +README.zh.md: 0862c60e7b40a8f63925b87bc41377b12ac2d788
    diff --git a/packages/test-support/agent-loop-testkit/README.md b/packages/test-support/agent-loop-testkit/README.md
    index 7b93559463..7c77cf77cf 100644
    --- a/packages/test-support/agent-loop-testkit/README.md
    +++ b/packages/test-support/agent-loop-testkit/README.md
    @@ -1,5 +1,5 @@
     ---
    -description: "Shared service mounting for tests that exercise the concrete AgentLoop, for test authors wiring real loop prerequisites."
    +description: "Prerequisite mounting, production AgentLoop drivers, and explicit Inbox stubs for agent-loop tests."
     kind: "package-library"
     ---
     
    @@ -9,7 +9,7 @@ English | [中文](README.zh.md)
     
     ## Summary
     
    -`dsh-agent-loop-testkit` mounts the standard prerequisite services a test needs before loading the concrete `AgentLoop` — the LLM runtime, session store, system-prompt registry, tool registry, and agent registry — in dependency order, with one call. The loop itself, adapters, optional plugins, agents, and teardown stay in the test's hands, so each scenario keeps its own load order and topology. Use it when a test's subject is loop behavior rather than service wiring; tests that probe injection failures or partial topologies mount their dependencies directly. It registers no model-facing behavior of its own.
    +`dsh-agent-loop-testkit` mounts the standard prerequisite services a test needs before loading the concrete `AgentLoop` — the LLM runtime, session store, session-projection registry, system-prompt registry, tool registry, and agent registry — in dependency order, with one call. A second helper mounts the production loop and returns a narrow driver for creating real Agents and claiming their real Inbox input. Consumer tests that need only the public queue operations can instead use an explicitly process-local Inbox stub, while tests with no pending-input behavior can use a fail-fast unsupported Inbox. Adapters, optional plugins, load order, and teardown stay in the test's hands. The package registers no model-facing behavior of its own.
     
     ## Table of Contents
     
    @@ -25,31 +25,54 @@ English | [中文](README.zh.md)
     
     ## Use this package
     
    -This package gives an AgentLoop test a working service topology before the loop is mounted: call the helper on your test context, then mount `AgentLoop` with the configuration under test and register your adapter and optional plugins.
    +This package gives an AgentLoop test a working service topology and keeps the choice between production Inbox behavior and a structural stub explicit.
     
    -### Minimal example
    +### Drive a production Agent
    +
    +Use `mountAgentLoopTestHarness()` when the test covers durable Inbox events, projection recovery or validation, live Inbox notifications, or loop-driver claims. Mount any load-order-sensitive consumers after the prerequisites and before creating the Agent. The context owns the loop and every Agent returned by the harness.
     
     ```ts
     import { Context } from '@deepseek-ai/cordis'
    -import AgentLoop from '@deepseek-ai/dsh-agent-loop'
    -import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
    +import { SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
    +import {
    +  mountAgentLoopTestDependencies,
    +  mountAgentLoopTestHarness,
    +} from '@deepseek-ai/dsh-agent-loop-testkit'
     
     const ctx = new Context()
     
     await mountAgentLoopTestDependencies(ctx)
    -// Register the test adapter and any optional plugins here.
    -await ctx.plugin(AgentLoop, { agents: [] })
    +// Register the test adapter and any load-order-sensitive plugins here.
    +const harness = await mountAgentLoopTestHarness(ctx)
    +const agent = await harness.create(SessionId('test-agent'))
    +declare const message: UserMessage
    +
    +agent.inbox.append('next-turn', message)
    +const admitted = harness.claim(agent, 'next-turn', 1)
     ```
     
    -The helper activates the LLM, session, system-prompt, tool, and agent services in dependency order and returns before the loop is mounted. System-prompt and tool-registry configuration can be forwarded through `options`; the helper provides no test defaults beyond those the services own.
    +The dependency helper forwards system-prompt and tool-registry configuration through `options` and provides no test defaults beyond those services' own defaults. A plugin-load failure rejects the helper call; services activated earlier in the sequence remain context-owned and unwind when the context is disposed.
    +
    +### Build a structural Agent stub
    +
    +Use `createInboxStub()` when the test subject needs mutable pending lists but does not exercise durability, projection validation, live Inbox notifications, or the driver's claim policy. The stub implements the public queue operations with two process-local arrays and never writes to a Session. Use `unsupportedInbox()` when the test subject must not touch pending input; every mutation throws at the first unexpected dependency.
    +
    +```ts
    +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
    +
    +const agent = {
    +  // ...
    +  inbox: createInboxStub(),
    +}
    +```
     
     ### When to use it
     
    -Use the helper for tests whose subject is the loop: load order, retries, tool execution, or session behavior on a real prerequisite stack. Mount dependencies directly when a test probes service load order, injection failures, partial topologies, or teardown — the helper hides exactly the wiring such tests must control.
    +Use the dependency and loop helpers for tests whose subject is production loop or durable Inbox behavior. Use the structural stub for consumer-domain tests that only need queue editing. Mount dependencies directly when a test probes service injection failures or partial topologies, because the helper hides exactly the wiring those tests must control.
     
     ### What can go wrong
     
    -A plugin-load failure rejects the helper call; services activated earlier in the sequence remain owned by your context and unwind with it. The context owns every mounted service, so dispose it after the test.
    +The harness mounts no LLM adapter. Register an adapter before sending work that would start a model request. Dispose the owning context after every test so Agents reach quiescence and their scoped registrations unwind.
     
     -----
     
    @@ -59,11 +82,11 @@ A plugin-load failure rejects the helper call; services activated earlier in the
     
    Implementation internals — click to expand -This section explains the design of the helper; the observable behavior is fully covered in [Use this package](#use-this-package). +This section explains the design of the test utilities; the observable behavior is fully covered in [Use this package](#use-this-package). ### Design -**Runtime invariant:** No companion is published. This test-support package owns no production event stream or mutable data; consuming test suites exercise its behavior. +`mountAgentLoopTestDependencies` mounts six service plugins in a fixed dependency order — LLM, session, session-projection registry, system-prompt registry, tool registry, then agent registry — and stops before `AgentLoop`, so the caller controls loop load order. `mountAgentLoopTestHarness` mounts the public production plugin, creates Agents through its service, and exposes the production driver's claim operation without exporting the loop's concrete Inbox class or projection definition. [`src/inbox.ts`](src/inbox.ts) contains only the process-local mutable stub and the fail-fast unsupported placeholder; it owns no projection or durable event implementation. The mounting and driver implementation lives in [`src/index.ts`](src/index.ts). No invariant companion is published because the package owns only test helpers and has no independent production observations that can diverge.
    @@ -72,11 +95,11 @@ This section explains the design of the helper; the observable behavior is fully ## Further Exploration -Read these pages when the package-level contract is not enough. They move from the loop to the services the helper mounts and the tests that use it. +Read these pages when the package-level behavior is not enough. They move from the loop to the services the helper mounts and the tests that use it. -- [Agent loop package](../../core/agent-loop/README.md) — the concrete loop this helper prepares tests for. -- [Session package](../../core/session/README.md) — the session store the helper mounts. -- [LLM package](../../llm/llm/README.md) — the LLM runtime and adapter contract the helper mounts. +- [Agent loop package](../../core/agent-loop/README.md) — the concrete loop this helper mounts for production behavior. +- [Session package](../../core/session/README.md) — the durable event log used by production Inbox behavior. +- [LLM package](../../llm/llm/README.md) — the LLM runtime and adapter interface the helper prepares. - [Testing policy](../../../docs/testing.md) — the coverage tiers these tests serve. - [Test-support group map](../README.md) — sibling harnesses and support packages. @@ -85,20 +108,22 @@ Read these pages when the package-level contract is not enough. They move from t ## Model Experience -None, as this test-only composition helper neither drives nor modifies model requests. +None, as these test-only utilities neither assemble nor modify model requests. #### KV Cache effect -None; this package neither assembles nor sends a provider request. +None; the package itself sends no provider request. ## Known Limitations and Deferred Work +These limits define what the utilities do not share. They are current package constraints, not a task backlog. -These limits define what the helper does not share. They are current package constraints, not a task backlog. - -- **Only the mandatory prerequisite spine is shared** — adapters, optional plugins, `AgentLoop`, agents, and context teardown remain caller-owned so scenario-specific ordering stays visible. +- **Only the mandatory prerequisite spine is shared** — adapters, optional plugins, scenario-specific load order, and context teardown remain caller-owned. +- **The production harness has no adapter default** — tests that start the loop must register the route they exercise. +- **The mutable Inbox stub is process-local only** — use a harness-created Agent whenever durable events, projection recovery or validation, live notifications, or claim policy matter. +- **The unsupported Inbox accepts no mutations** — use the mutable stub or a harness-created Agent whenever pending input is part of the test subject. ### Dev Note diff --git a/packages/test-support/agent-loop-testkit/README.zh.md b/packages/test-support/agent-loop-testkit/README.zh.md index fc9dc688a1..0862c60e7b 100644 --- a/packages/test-support/agent-loop-testkit/README.zh.md +++ b/packages/test-support/agent-loop-testkit/README.zh.md @@ -1,5 +1,5 @@ --- -description: "为运行具体 AgentLoop 的测试挂载共享服务先决依赖,面向接线真实循环前置依赖的测试作者。" +description: "为 agent-loop 测试提供先决依赖挂载、生产 AgentLoop 驱动与职责明确的 Inbox 桩。" kind: "package-library" --- @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-agent-loop-testkit` 为测试在加载具体 `AgentLoop` 之前所需的全部标准先决服务——LLM(大语言模型)运行时、会话存储、系统提示词注册表、工具注册表与 agent(智能体)注册表——按依赖顺序一键挂载。loop 本身、适配器、可选插件、agent 与清理仍由测试掌控,因此每个场景都保持自己的加载顺序与拓扑。当测试对象是 loop 行为而非服务接线时使用它;针对注入失败或部分拓扑的测试会直接挂载其依赖。它自身不注册任何模型可见行为。 +`dsh-agent-loop-testkit` 为测试在加载具体 `AgentLoop` 之前所需的标准先决服务——LLM(大语言模型)运行时、会话存储、会话投影注册表、系统提示词注册表、工具注册表与 agent(智能体)注册表——按依赖顺序一键挂载。另一个辅助函数会挂载生产 loop,并返回一个精简驱动,用于创建真实 Agent 和通过真实 Inbox 认领输入。只需要公开队列操作的消费方测试可以改用明确标记为进程内实现的 Inbox 桩;不涉及待处理输入的测试则可以使用快速失败且不支持操作的 Inbox。适配器、可选插件、加载顺序与清理由测试掌控。本包自身不注册任何模型可见行为。 ## 目录 @@ -25,31 +25,54 @@ kind: "package-library" ## 使用本包 -本包在 loop 挂载前为 AgentLoop 测试提供可用的服务拓扑:在测试上下文上调用此辅助函数,然后用待测配置挂载 `AgentLoop`,并注册你的适配器与可选插件。 +本包为 AgentLoop 测试提供可用的服务拓扑,并要求测试明确选择生产 Inbox 行为或结构化桩。 -### 最小示例 +### 驱动生产 Agent + +当测试覆盖持久 Inbox 事件、投影恢复或校验、实时 Inbox 通知,或 loop 驱动的认领策略时,使用 `mountAgentLoopTestHarness()`。应在挂载先决依赖后、创建 Agent 前挂载所有对加载顺序敏感的消费方。上下文拥有 loop 以及该 harness 返回的每个 Agent。 ```ts import { Context } from '@deepseek-ai/cordis' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { SessionId, type UserMessage } from '@deepseek-ai/dsh-session' +import { + mountAgentLoopTestDependencies, + mountAgentLoopTestHarness, +} from '@deepseek-ai/dsh-agent-loop-testkit' const ctx = new Context() await mountAgentLoopTestDependencies(ctx) -// Register the test adapter and any optional plugins here. -await ctx.plugin(AgentLoop, { agents: [] }) +// Register the test adapter and any load-order-sensitive plugins here. +const harness = await mountAgentLoopTestHarness(ctx) +const agent = await harness.create(SessionId('test-agent')) +declare const message: UserMessage + +agent.inbox.append('next-turn', message) +const admitted = harness.claim(agent, 'next-turn', 1) ``` -该辅助函数按依赖顺序激活 LLM、会话、系统提示词、工具与 agent 服务,并在 loop 挂载前返回。系统提示词与工具注册表配置可通过 `options` 转发;除服务自有的默认值外,本辅助函数不提供测试默认值。 +依赖辅助函数通过 `options` 转发系统提示词与工具注册表配置,除这些服务自有的默认值外不提供测试默认值。插件加载失败会使辅助函数调用被拒绝;顺序中较早激活的服务仍归上下文所有,并在上下文释放时一并解除。 + +### 构造结构化 Agent 桩 + +当测试对象需要可变的待处理列表,但不测试持久性、投影校验、实时 Inbox 通知或驱动的认领策略时,使用 `createInboxStub()`。该桩通过两个进程内数组实现公开队列操作,且绝不会写入 Session。当测试对象不应访问待处理输入时,使用 `unsupportedInbox()`;每次变更都会在首个意外依赖处抛错。 + +```ts +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' + +const agent = { + // ... + inbox: createInboxStub(), +} +``` ### 何时使用 -当测试对象是 loop 本身——在真实先决依赖栈上的加载顺序、重试、工具执行或会话行为——时使用此辅助函数。当测试要探测服务加载顺序、注入失败、部分拓扑或清理时,请直接挂载依赖——辅助函数隐藏的正是这类测试必须控制的接线。 +当测试对象是生产 loop 或持久 Inbox 行为时,使用依赖与 loop 辅助函数。只需要编辑队列的消费方领域测试使用结构化桩。当测试探测服务注入失败或部分拓扑时,请直接挂载依赖,因为辅助函数隐藏的正是这类测试必须控制的接线。 ### 可能出什么问题 -插件加载失败会使辅助函数调用被拒绝;顺序中较早激活的服务仍归你的上下文所有,并随上下文一起解除。上下文拥有所有已挂载服务,因此测试结束后请 dispose(资源释放)它。 +harness 不会挂载任何 LLM 适配器。若测试发送的任务会启动模型请求,请先注册被测路由的适配器。每个测试结束后都应释放所属上下文,使 Agent 达到静止状态并解除其作用域注册。 ----- @@ -59,11 +82,11 @@ await ctx.plugin(AgentLoop, { agents: [] })
    实现细节——点击展开 -本节解释辅助函数的设计;可观察行为已在[使用本包](#use-this-package)中完整说明。 +本节解释测试辅助工具的设计;可观察行为已在[使用本包](#use-this-package)中完整说明。 ### 设计 -**运行时不变式:** 不发布伴生入口。本包不持有生产事件流或可变数据;消费它的测试套件会直接检验 harness 行为。 +`mountAgentLoopTestDependencies` 按固定依赖顺序——LLM、会话、会话投影注册表、系统提示词注册表、工具注册表、agent 注册表——挂载六个服务插件,并在 `AgentLoop` 之前停下,使调用方控制 loop 加载顺序。`mountAgentLoopTestHarness` 挂载公开的生产插件,通过其服务创建 Agent,并公开生产驱动的认领操作,而不导出 loop 的具体 Inbox 类或投影定义。[`src/inbox.ts`](src/inbox.ts) 仅包含进程内可变桩和快速失败且不支持操作的占位值;它不持有投影或持久事件实现。挂载与驱动实现位于 [`src/index.ts`](src/index.ts)。本包不发布 invariant companion,因为它只持有测试辅助工具,不存在可能相互偏离的独立生产观测。
    @@ -72,11 +95,11 @@ await ctx.plugin(AgentLoop, { agents: [] }) ## 进一步探索 -当包级约定不够用时阅读以下页面。它们从 loop 逐步进入辅助函数挂载的服务以及使用它的测试。 +当包级行为不够用时阅读以下页面。它们从 loop 逐步进入辅助函数挂载的服务以及使用它的测试。 -- [Agent loop 包](../../core/agent-loop/README.zh.md)——本辅助函数为之准备测试的具体 loop。 -- [会话包](../../core/session/README.zh.md)——辅助函数挂载的会话存储。 -- [LLM 包](../../llm/llm/README.zh.md)——辅助函数挂载的 LLM 运行时与适配器约定。 +- [Agent loop 包](../../core/agent-loop/README.zh.md)——本辅助函数为生产行为挂载的具体 loop。 +- [会话包](../../core/session/README.zh.md)——生产 Inbox 行为使用的持久事件日志。 +- [LLM 包](../../llm/llm/README.zh.md)——本辅助函数准备的 LLM 运行时与适配器接口。 - [测试策略](../../../docs/testing.zh.md)——这些测试所服务的覆盖层级。 - [test-support 组地图](../README.zh.md)——兄弟 harness 与支持包。 @@ -85,20 +108,22 @@ await ctx.plugin(AgentLoop, { agents: [] }) ## 模型体验 -无。该测试专用组合辅助函数既不驱动也不修改模型请求。 +无。这些测试专用辅助工具既不组装也不修改模型请求。 #### KV Cache 影响 -无;本包既不组装也不发送提供方请求。 +无;本包自身不发送提供方请求。 ## 已知限制与延期工作 +这些限制说明辅助工具不共享什么。它们是当前包约束,不是任务积压。 -这些限制说明辅助函数不共享什么。它们是当前包约束,不是任务积压。 - -- **只共享必需的先决主干**——适配器、可选插件、`AgentLoop`、agent 与上下文清理仍由调用方负责,以使特定场景的挂载顺序清晰可见。 +- **只共享必需的先决主干**——适配器、可选插件、场景特定的加载顺序与上下文清理仍由调用方负责。 +- **生产 harness 没有适配器默认值**——启动 loop 的测试必须注册其实际使用的路由。 +- **可变 Inbox 桩仅存在于进程内**——只要持久事件、投影恢复或校验、实时通知或认领策略属于测试对象,就应使用 harness 创建的 Agent。 +- **不支持操作的 Inbox 不接受变更**——只要待处理输入属于测试对象,就应使用可变桩或 harness 创建的 Agent。 ### 开发备注 diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index 0a166134f6..fc123b8bac 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/packages/test-support/agent-loop-testkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop-testkit", - "description": "Shared prerequisite mounting for tests that exercise the concrete agent loop", - "version": "0.1.3-alpha.1", + "description": "Prerequisite mounting, production AgentLoop drivers, and Inbox stubs for tests", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, @@ -28,17 +28,21 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "dependencies": {}, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/test-support/agent-loop-testkit/src/inbox.ts b/packages/test-support/agent-loop-testkit/src/inbox.ts new file mode 100644 index 0000000000..dbb1dbff57 --- /dev/null +++ b/packages/test-support/agent-loop-testkit/src/inbox.ts @@ -0,0 +1,74 @@ +import type { Inbox, InboxTarget } from '@deepseek-ai/dsh-agent' +import type { MessageId } from '@deepseek-ai/dsh-llm' +import type { UserMessage } from '@deepseek-ai/dsh-session' + +/** + * Create a mutable in-memory Inbox stub for tests that exercise only the public + * queue operations. Durable events, projection validation, and live Inbox + * notifications require a real Agent created by the AgentLoop test harness. + * @returns an Inbox backed by two process-local arrays. + */ +export function createInboxStub(): Inbox { + const pending: Record = { + 'next-turn': [], + 'next-step': [], + } + + const locate = (messageId: MessageId): { target: InboxTarget; index: number } | undefined => { + for (const target of ['next-turn', 'next-step'] as const) { + const index = pending[target].findIndex(message => message.id === messageId) + if (index >= 0) return { target, index } + } + return undefined + } + + return { + get nextTurn() { return pending['next-turn'] }, + get nextStep() { return pending['next-step'] }, + clear() { + pending['next-step'].splice(0) + pending['next-turn'].splice(0) + }, + append(target, message) { + pending[target].push(message) + }, + prepend(target, message) { + pending[target].unshift(message) + }, + replace(messageId, message) { + const location = locate(messageId) + if (location === undefined) return false + pending[location.target].splice(location.index, 1, message) + return true + }, + remove(messageId) { + const location = locate(messageId) + if (location === undefined) return false + pending[location.target].splice(location.index, 1) + return true + }, + splice(target, start, deleteCount, inserted) { + return pending[target].splice(start, deleteCount, ...inserted) + }, + } +} + +/** + * Create an unsupported Inbox placeholder for Agent stubs whose tests do not exercise Inbox behavior. + * @returns an Inbox whose pending lists are empty and whose mutation methods throw. + */ +export function unsupportedInbox(): Inbox { + const rejectMutation = (): never => { + throw new Error('this test Agent does not support Inbox mutations') + } + return { + nextTurn: [], + nextStep: [], + clear: rejectMutation, + append: rejectMutation, + prepend: rejectMutation, + replace: rejectMutation, + remove: rejectMutation, + splice: rejectMutation, + } +} diff --git a/packages/test-support/agent-loop-testkit/src/index.ts b/packages/test-support/agent-loop-testkit/src/index.ts index e2ae19653d..0e9b57e666 100644 --- a/packages/test-support/agent-loop-testkit/src/index.ts +++ b/packages/test-support/agent-loop-testkit/src/index.ts @@ -1,19 +1,49 @@ /** - * Shared mounting for the services required before tests load the concrete - * agent loop. The caller retains ownership of the context, loop, adapters, - * optional plugins, and teardown. + * Shared service mounting, real AgentLoop drivers, and structural Inbox stubs + * for agent-loop tests. Callers retain ownership of their contexts, adapters, + * optional plugins, agents, and teardown. * @module @deepseek-ai/dsh-agent-loop-testkit */ import type { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, AgentOptions, Inbox, InboxTarget } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' +import type { SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import type { Config as ToolRuntimeConfig } from '@deepseek-ai/dsh-tools' +export { createInboxStub, unsupportedInbox } from './inbox.ts' + +interface DriverInbox extends Inbox { + claim(target: InboxTarget, turn: number): UserMessage[] +} + +/** Test driver for production Agents created by a mounted AgentLoop. */ +export interface AgentLoopTestHarness { + /** + * Create a production Agent and fresh Session owned by the harness context. + * @param id - shared Agent and Session identity. + * @param options - concrete loop options. + * @param meta - optional fresh-session workspace metadata. + * @returns the published production Agent after creation completes. + */ + create(id: SessionId, options?: AgentOptions, meta?: Pick): Promise + /** + * Admit pending messages through the production loop driver's claim operation. + * @param agent - Agent returned by this harness's `create` method. + * @param target - boundary whose pending input is admitted. + * @param turn - turn that owns the admitted messages. + * @returns next-step messages followed by one next-turn message when requested. + */ + claim(agent: Agent, target: InboxTarget, turn: number): UserMessage[] +} + /** Configuration forwarded to the prerequisite service plugins. */ export interface AgentLoopTestDependenciesOptions { /** Configuration for the system-prompt registry. */ @@ -40,7 +70,24 @@ export async function mountAgentLoopTestDependencies( ): Promise { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, options.systemPrompt ?? {}) await ctx.plugin(ToolRuntime, options.tools ?? {}) await ctx.plugin(AgentRegistry) } + +/** + * Mount the production AgentLoop and expose its narrow test-driver operations. + * Mount {@link mountAgentLoopTestDependencies} and any load-order-sensitive + * consumers before calling this helper. The context owns the loop and every + * Agent returned by the harness. + * @param ctx - test context with the AgentLoop prerequisite services active. + * @returns a driver that creates production Agents and claims their real Inbox. + */ +export async function mountAgentLoopTestHarness(ctx: Context): Promise { + await ctx.plugin(AgentLoop, { agents: [] }) + return { + create: async (id, options = {}, meta = {}) => ctx.agentLoop.create(id, options, meta), + claim: (agent, target, turn) => (agent.inbox as DriverInbox).claim(target, turn), + } +} diff --git a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts index c715ac03cc..f4235aad18 100644 --- a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts +++ b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts @@ -1,11 +1,29 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import { mountAgentLoopTestDependencies } from '../src/index.ts' +import { + createInboxStub, + mountAgentLoopTestDependencies, + mountAgentLoopTestHarness, + unsupportedInbox, +} from '../src/index.ts' + +function message(text: string) { + return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }) +} describe('dsh-agent-loop-testkit', () => { - it('mounts a configurable prerequisite spine that can activate AgentLoop', async () => { + it('rejects mutations through an unsupported Agent stub Inbox', () => { + const inbox = unsupportedInbox() + + expect(inbox.nextTurn).toEqual([]) + expect(inbox.nextStep).toEqual([]) + expect(() => { inbox.clear() }).toThrow('this test Agent does not support Inbox mutations') + }) + + it('mounts a configurable prerequisite spine and the production AgentLoop', async () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx, { systemPrompt: { personaPrefix: 'Test persona.' }, @@ -13,7 +31,77 @@ describe('dsh-agent-loop-testkit', () => { }) expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain('Test persona.') - await expect(ctx.plugin(AgentLoop, { agents: [] })).resolves.toBeDefined() + await expect(mountAgentLoopTestHarness(ctx)).resolves.toBeDefined() + + await ctx.fiber.dispose() + }) + + it('provides a mutable in-memory Inbox stub for structural Agent tests', () => { + const inbox = createInboxStub() + const firstTurn = message('first turn') + const secondTurn = message('second turn') + const firstStep = message('first step') + const editedTurn = message('edited turn') + const editedStep = message('edited step') + + inbox.append('next-turn', firstTurn) + inbox.prepend('next-turn', secondTurn) + inbox.append('next-step', firstStep) + expect(inbox.nextTurn).toEqual([secondTurn, firstTurn]) + expect(inbox.nextStep).toEqual([firstStep]) + + expect(inbox.replace(firstTurn.id, editedTurn)).toBe(true) + expect(inbox.replace(firstStep.id, editedStep)).toBe(true) + expect(inbox.replace(firstTurn.id, message('missing replacement'))).toBe(false) + expect(inbox.remove(firstTurn.id)).toBe(false) + expect(inbox.splice('next-turn', -1, 1, [])).toEqual([editedTurn]) + expect(inbox.remove(editedStep.id)).toBe(true) + + inbox.clear() + expect(inbox.nextTurn).toEqual([]) + expect(inbox.nextStep).toEqual([]) + }) + + it('drives durable Inbox behavior through a production Agent', async () => { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + const harness = await mountAgentLoopTestHarness(ctx) + const agent = await harness.create(SessionId('agent-loop-testkit-inbox')) + const turn = message('turn') + const step = message('step') + const inserted: string[] = [] + const claimed: Array<{ id: string; turn: number }> = [] + ctx.on('agent/inbox/inserted', ({ agent: subject, message: pending }) => { + if (subject === agent) inserted.push(pending.id) + }) + ctx.on('agent/inbox/claimed', ({ agent: subject, message: pending, turn: ownerTurn }) => { + if (subject === agent) claimed.push({ id: pending.id, turn: ownerTurn }) + }) + + agent.inbox.append('next-turn', turn) + agent.inbox.append('next-step', step) + + expect(inserted).toEqual([turn.id, step.id]) + expect(() => { agent.inbox.append('next-step', turn) }).toThrow(`message "${turn.id}" is already pending`) + const invalid = Session.create(SessionId('invalid-persisted-inbox'), [{ + type: 'agent/inbox/spliced', + seq: SessionSeq(0), + time: 1, + data: { target: 'next-turn', start: 99, inserted: [] }, + }]) + expect(() => ctx.sessionProjections.stateOf(invalid, 'inbox')) + .toThrow(/invalid persisted inbox splice/) + expect(harness.claim(agent, 'next-turn', 3)).toEqual([step, turn]) + expect(claimed).toEqual([ + { id: step.id, turn: 3 }, + { id: turn.id, turn: 3 }, + ]) + expect(agent.session.snapshotEvents().map(event => event.type)).toEqual([ + 'agent/inbox/spliced', + 'agent/inbox/spliced', + 'agent/inbox/spliced', + 'agent/inbox/spliced', + ]) await ctx.fiber.dispose() }) diff --git a/packages/test-support/agent-loop-testkit/tsconfig.json b/packages/test-support/agent-loop-testkit/tsconfig.json index 5e5b3c47f2..1a07c30bd3 100644 --- a/packages/test-support/agent-loop-testkit/tsconfig.json +++ b/packages/test-support/agent-loop-testkit/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../core/agent" }, + { + "path": "../../core/agent-loop" + }, { "path": "../../llm/llm" }, @@ -28,6 +31,9 @@ }, { "path": "../../core/tools" + }, + { + "path": "../../session/session-projection" } ] } diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 2b66d23f5f..051b6d159c 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-test-runtime", "description": "jsdom slot test runtime: real Cordis Context + SlotRegistry + UI renderer with test-owned session/workspace doubles for feature specs", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-mock-server/package.json b/packages/test-support/llm-mock-server/package.json index 360791d82a..9c1f1da13e 100644 --- a/packages/test-support/llm-mock-server/package.json +++ b/packages/test-support/llm-mock-server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-mock-server", "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-replay/package.json b/packages/test-support/llm-replay/package.json index 0db1bcbf6d..86e7fd0fef 100644 --- a/packages/test-support/llm-replay/package.json +++ b/packages/test-support/llm-replay/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-replay", "description": "Replay LLM plugin: short-circuits llm/stream with model chunks reconstructed from a recorded session JSONL (keyless snapshot tests)", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/loader-smoke/package.json b/packages/test-support/loader-smoke/package.json index bd9a4a6f58..8d04de9290 100644 --- a/packages/test-support/loader-smoke/package.json +++ b/packages/test-support/loader-smoke/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-loader-smoke", "description": "Shared subprocess and direct-agent harness for keyless real-Loader example smoke tests", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/session-snapshot/package.json b/packages/test-support/session-snapshot/package.json index c864cc9920..f9d1ad3dee 100644 --- a/packages/test-support/session-snapshot/package.json +++ b/packages/test-support/session-snapshot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-snapshot", "description": "Session-log snapshot core with an ACP protocol adapter, expected-output normalization, and fixture invariants", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 59afa5ef3a..78e0af2b0d 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-todo", "description": "Model-facing todo_write tool over the DeepSeek Harness event-sourced session log", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 696a1ef99e..3384372a65 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -5,7 +5,6 @@ import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -18,7 +17,6 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index d25d02bba9..b99909fa2a 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -11,12 +11,13 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' let root: string | undefined let context: Context | undefined @@ -33,7 +34,7 @@ function agent(ctx: Context): Agent { const id = SessionId('todo-loader-agent') const session = Session.create(id) const value: Agent = { - id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + id, options: {}, session, inbox: unsupportedInbox(), status: 'idle', ctx: scope.ctx, followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, runMaintenance: task => task(new AbortController().signal), diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index be08df834a..4ea406225e 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-generator", "description": "TypeScript project analyzer and model-driven Typert artifact generator", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index bbe68f6b84..1fda2a5519 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-loader", "description": "Loader integration for generated Typert package contributions", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index d2e2b8c6c7..bac4be9b53 100644 --- a/packages/typert/protocol/package.json +++ b/packages/typert/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-protocol", "description": "Compiler-independent Remote metadata and Typert provider protocols", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index 3b2ed96b42..57336820cb 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-registry", "description": "Runtime registry for generated package reflection and Zod schemas", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index c58725a44d..002b680541 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-atomic-write", "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 8347b38484..418b2767b9 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-brand", "description": "Stateless branded primitive types for the DeepSeek Harness", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/crypto/package.json b/packages/util/crypto/package.json index ad3eb7bf2e..c85d874d6e 100644 --- a/packages/util/crypto/package.json +++ b/packages/util/crypto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-crypto", "description": "Zero-dependency browser-safe UUID and byte-encoding helpers", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/deque/package.json b/packages/util/deque/package.json index c3fbe1fb07..d0a44587e8 100644 --- a/packages/util/deque/package.json +++ b/packages/util/deque/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deque", "description": "Zero-dependency circular deque with amortized constant-time end operations and bounded vacant storage", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/home-paths/package.json b/packages/util/home-paths/package.json index c09a719497..7880572b9f 100644 --- a/packages/util/home-paths/package.json +++ b/packages/util/home-paths/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-home-paths", "description": "Shared filesystem path helpers for the DeepSeek Harness", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/http-proxy/package.json b/packages/util/http-proxy/package.json index 265425e0ab..4e0b26db95 100644 --- a/packages/util/http-proxy/package.json +++ b/packages/util/http-proxy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-http-proxy", "description": "Process-wide outbound HTTP proxy policy for DeepSeek Harness: resolve it from the launch environment and install it as undici's global dispatcher", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/launch-environment/package.json b/packages/util/launch-environment/package.json index d8e2461c33..7d5ec567ca 100644 --- a/packages/util/launch-environment/package.json +++ b/packages/util/launch-environment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-launch-environment", "description": "Immutable DeepSeek Harness launch environment that records which layer supplied each value", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index c70ba86c1b..e5a8800332 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-native-command", "description": "Host-native command and path-opening utilities with shell-free execution, cancellation, desktop detection, and WSL handoff", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/output-retention/package.json b/packages/util/output-retention/package.json index ed0ffc3158..a8c562f9c2 100644 --- a/packages/util/output-retention/package.json +++ b/packages/util/output-retention/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-output-retention", "description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/package-manifest/package.json b/packages/util/package-manifest/package.json index c8beff5baa..35cd38f646 100644 --- a/packages/util/package-manifest/package.json +++ b/packages/util/package-manifest/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-package-manifest", "description": "Shared type declarations for package.json.dsh configuration fields", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/time/package.json b/packages/util/time/package.json index 3dd79c779a..22bc8d25a5 100644 --- a/packages/util/time/package.json +++ b/packages/util/time/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-time", "description": "Zero-dependency time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization only, no formatting)", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 92b970473e..4d17f6f598 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-timeout", "description": "Zero-dependency timeout/deadline primitive: clampTimeout, deadline, timeoutOf, TimeoutReason (timing + classification only, no termination)", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/values/package.json b/packages/util/values/package.json index ada3e492cf..d9721aae3f 100644 --- a/packages/util/values/package.json +++ b/packages/util/values/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-values", "description": "Duplicate-install-safe value primitives for the DeepSeek Harness", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/workspace-path/README.i18n.yaml b/packages/util/workspace-path/README.i18n.yaml index bfff3060b8..e3c4c1a31d 100644 --- a/packages/util/workspace-path/README.i18n.yaml +++ b/packages/util/workspace-path/README.i18n.yaml @@ -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/util/workspace-path/README.md -README.md: 1b09bda74c8836655789c57fa05d03a2892c16b9 -README.zh.md: 25128e5fec504e1724f5cc7952fed976985912d5 +README.md: 0b820992dffe0ce52530f45b01fa73ad8f1c9a23 +README.zh.md: 5648d9e37f1fd6bde8a8c0237051c3f801b01a72 diff --git a/packages/util/workspace-path/README.md b/packages/util/workspace-path/README.md index 1b09bda74c..0b820992df 100644 --- a/packages/util/workspace-path/README.md +++ b/packages/util/workspace-path/README.md @@ -9,15 +9,25 @@ English | [中文](README.zh.md) ## Summary -Browser-safe path helpers shared by Workspace-facing client and controller packages. The package joins Workspace-relative paths, abbreviates POSIX home directories for display, and derives Workspace titles from POSIX or Windows paths. It has no Cordis service or runtime state. +Browser-safe path helpers shared by Workspace-facing client and controller packages. The package joins Workspace-relative paths, abbreviates POSIX home directories for display, derives Workspace titles from POSIX or Windows paths, and owns the `dsh-resource://file/…` address grammar that names a workspace file across the Sidebar and the resource model. It has no Cordis service or runtime state. ## Table of Contents +- [File addresses](#file-addresses) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) - [Dev Note](#dev-note) ----- + +## File addresses + +A resource address is `dsh-resource:///…`, and the type — the URI host — is the resource protocol key (`file`, or one a plugin declares in `ResourceProtocolMap`); any other scheme is a navigation protocol, defined elsewhere. A file address has one of two scopes. `dsh-resource://file/session//` names a file by its path relative to that Session's workspace root (`dsh-resource://file/session/abc123/src/notes.txt`), which the Host resolves against the root it holds for the Session. `dsh-resource://file/absolute/` names a file by its absolute path with the leading `/` dropped (`dsh-resource://file/absolute/home/ys/notes.txt` on POSIX, `dsh-resource://file/absolute/C:/x/y.txt` for a Windows drive, `dsh-resource://file/absolute//server/share/y.txt` for a UNC path, whose empty first segment keeps its identity); it carries no Session, so the reader's own Session resolves it, and the Host's workspace confinement still applies. The grammar lives in [`src/file-address.ts`](src/file-address.ts); the path helpers stay in [`src/index.ts`](src/index.ts), which re-exports it. + +`sessionFileAddress(sessionId, relativePath)` and `absoluteFileAddress(absolutePath)` build one: `\` becomes `/`, a leading `./` or `/` is dropped, and every id and path segment is component-encoded with `:` kept literal, so `#`, `?`, and spaces in a name survive while a drive letter reads as written. `fileAddressFor(sessionId, cwd, path)` chooses the scope for a path as a caller holds it: a relative path, or an absolute path inside `cwd`, becomes `session`-relative; any other absolute path becomes `absolute`. `parseFileAddress(address)` reads one back through `new URL()`: the scheme must be `dsh-resource` and the host exactly `file`; a `session` address yields `{ scope, sessionId, path }` with the workspace-relative path, an `absolute` address yields `{ scope, path }` with the leading `/` restored (`//` for a UNC path) unless the path starts with a drive letter. It returns `undefined` for another type or scheme, an unknown scope, a missing id or path, a non-URL, or a malformed escape — the caller decides whether that is a failure. + +----- + ## Known Limitations and Deferred Work diff --git a/packages/util/workspace-path/README.zh.md b/packages/util/workspace-path/README.zh.md index 25128e5fec..5648d9e37f 100644 --- a/packages/util/workspace-path/README.zh.md +++ b/packages/util/workspace-path/README.zh.md @@ -9,15 +9,25 @@ kind: "package-library" ## 概述 -供 Workspace 相关客户端和控制器包共享、可在浏览器使用的路径辅助函数。该包负责拼接 Workspace 相对路径、缩写用于展示的 POSIX 主目录,以及从 POSIX 或 Windows 路径提取 Workspace 标题;它不提供 Cordis service,也不持有运行时状态。 +供 Workspace 相关客户端和控制器包共享、可在浏览器使用的路径辅助函数。该包负责拼接 Workspace 相对路径、缩写用于展示的 POSIX 主目录、从 POSIX 或 Windows 路径提取 Workspace 标题,并拥有在 Sidebar 与资源模型之间命名工作区文件的 `dsh-resource://file/…` 地址语法;它不提供 Cordis service,也不持有运行时状态。 ## 目录 +- [文件地址](#file-addresses) - [已知限制与暂缓事项](#known-limitations-and-deferred-work) - [开发备注](#dev-note) ----- + +## 文件地址 + +资源地址 = `dsh-resource:///…`,type(URI 的 host)即资源协议键(`file`,或插件在 `ResourceProtocolMap` 中声明的键);其他 scheme 属导航协议,另行定义。文件地址有两种作用域。`dsh-resource://file/session//` 以相对该 Session 工作区根的路径命名文件(`dsh-resource://file/session/abc123/src/notes.txt`),由 Host 对它为该 Session 持有的根解析。`dsh-resource://file/absolute/` 以去掉前导 `/` 的绝对路径命名文件(POSIX 上为 `dsh-resource://file/absolute/home/ys/notes.txt`,Windows 盘符为 `dsh-resource://file/absolute/C:/x/y.txt`,UNC 路径为 `dsh-resource://file/absolute//server/share/y.txt`,其空的首段保留 UNC 身份);它不带 Session,由读者自己的 Session 解析,Host 的工作区限制照样适用。语法住在 [`src/file-address.ts`](src/file-address.ts);路径辅助函数留在 [`src/index.ts`](src/index.ts) 并再导出它。 + +`sessionFileAddress(sessionId, relativePath)` 与 `absoluteFileAddress(absolutePath)` 构造地址:`\` 归一为 `/`,去掉前导 `./` 或 `/`,id 与每个路径段做组件编码但 `:` 保持字面,因此名字里的 `#`、`?`、空格都能保留,盘符也照原样可读。`fileAddressFor(sessionId, cwd, path)` 按调用方手里的路径选作用域:相对路径或落在 `cwd` 内的绝对路径成为 `session` 相对地址,其他绝对路径成为 `absolute` 地址。`parseFileAddress(address)` 用 `new URL()` 读回:scheme 必须是 `dsh-resource`、host 必须恰为 `file`;`session` 地址得到 `{ scope, sessionId, path }`(path 为工作区相对路径),`absolute` 地址得到 `{ scope, path }` 并还原前导 `/`(UNC 路径还原为 `//`),以盘符开头者除外。对其他 type 或 scheme、未知作用域、缺 id 或路径、非 URL、或转义格式错误的输入返回 `undefined`,是否算失败由调用方决定。 + +----- + ## 已知限制与暂缓事项 diff --git a/packages/util/workspace-path/package.json b/packages/util/workspace-path/package.json index 39209a1017..a4b75b3067 100644 --- a/packages/util/workspace-path/package.json +++ b/packages/util/workspace-path/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-workspace-path", "description": "Browser-safe Workspace path and display helpers", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/workspace-path/src/file-address.ts b/packages/util/workspace-path/src/file-address.ts new file mode 100644 index 0000000000..34dfc5aceb --- /dev/null +++ b/packages/util/workspace-path/src/file-address.ts @@ -0,0 +1,113 @@ +/** + * The `dsh-resource://file/…` address grammar: how a file is named across the + * Sidebar and the resource model, built and parsed without touching a + * filesystem. + * @module + */ + +/** + * A file resource address, in one of two scopes. + * + * Every resource address is `dsh-resource:///…`, the URI host naming the + * resource protocol; for `file` the path opens with the scope: + * + * - `dsh-resource://file/session//` names a file by its path + * relative to that Session's workspace root (`src/a.ts`, no leading `/`); the + * Host resolves it against the root it holds for the Session. + * - `dsh-resource://file/absolute/` names a file by its absolute path with + * the leading `/` dropped (`dsh-resource://file/absolute/home/ys/notes.txt`; + * Windows `dsh-resource://file/absolute/C:/x/y.txt`; a UNC path keeps an empty + * first segment, `dsh-resource://file/absolute//server/share/x.txt`). It carries + * no Session: the reader's own Session resolves it, and the Host's workspace + * confinement still applies. + * + * Every id and path segment is component-encoded, so a name carrying `#`, `?`, + * or a space survives the round trip; `:` stays literal so a drive letter reads + * as written. + */ +export type FileAddress = + | { + readonly scope: 'session' + /** The Session whose workspace root the path is relative to. */ + readonly sessionId: string + /** Workspace-relative `/`-separated path, no leading `/`; empty for the root itself. */ + readonly path: string + } + | { + readonly scope: 'absolute' + /** Absolute `/`-separated path: `/a/b` on POSIX, `C:/a/b` for a Windows drive, `//server/share/a` for a UNC path. */ + readonly path: string + } + +/** The scheme and type every file address opens with. */ +const FILE_ADDRESS_PREFIX = 'dsh-resource://file/' + +/** Component-encode one id or path segment, keeping `:` literal for drive letters. */ +function encodeSegment(segment: string): string { + return encodeURIComponent(segment).replace(/%3A/gi, ':') +} + +/** Encode a `/`-separated path segment by segment. */ +function encodePath(path: string): string { + return path.split('/').map(encodeSegment).join('/') +} + +/** Whether a decoded first path segment is a Windows drive (`C:`). */ +function isDriveSegment(segment: string | undefined): boolean { + return segment !== undefined && /^[A-Za-z]:$/.test(segment) +} + +/** + * Build the address of a file inside one Session's workspace. + * @param sessionId - the Session whose workspace root the path is relative to. + * @param path - workspace-relative path; backslashes are normalized to `/`, and a leading `./` or `/` is dropped. + * @returns the `dsh-resource://file/session//` address. + */ +export function sessionFileAddress(sessionId: string, path: string): string { + const relative = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '').replace(/^\/+/, '') + return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(relative)}` +} + +/** + * Build the address of a file by its absolute path. + * @param path - absolute path; backslashes are normalized to `/` and the leading `/` is dropped, + * except that a UNC path (`\\server\share`) keeps one empty first segment. + * @returns the `dsh-resource://file/absolute/` address. + */ +export function absoluteFileAddress(path: string): string { + const normalized = path.replace(/\\/g, '/') + const unc = normalized.startsWith('//') + const absolute = normalized.replace(/^\/+/, '') + return `${FILE_ADDRESS_PREFIX}absolute/${unc ? '/' : ''}${encodePath(absolute)}` +} + +/** + * Read a file address back into its parts. + * @param address - a candidate address. + * @returns the parts, or `undefined` when the string is not a `dsh-resource://file/` URI in a known scope with a path, or a segment is not validly encoded. + */ +export function parseFileAddress(address: string): FileAddress | undefined { + try { + const url = new URL(address) + if (url.protocol !== 'dsh-resource:' || url.host !== 'file') return undefined + const [, scope, ...rest] = url.pathname.split('/') + if (scope === 'session') { + const [id, ...segments] = rest + if (id === undefined || id === '' || segments.length === 0) return undefined + return { scope, sessionId: decodeURIComponent(id), path: segments.map(decodeURIComponent).join('/') } + } + if (scope === 'absolute') { + // An empty first segment with more behind it is a UNC path's `//`; alone it is no path. + const unc = rest[0] === '' && rest.length > 1 + const segments = (unc ? rest.slice(1) : rest).map(decodeURIComponent) + if (segments.length === 0 || segments[0] === '') return undefined + if (unc) return { scope, path: `//${segments.join('/')}` } + return { scope, path: isDriveSegment(segments[0]) ? segments.join('/') : `/${segments.join('/')}` } + } + return undefined + } catch { + // `new URL` throws TypeError on a non-URL and `decodeURIComponent` throws + // URIError on a malformed escape; both mean "not a file address". + return undefined + } +} diff --git a/packages/util/workspace-path/src/index.ts b/packages/util/workspace-path/src/index.ts index 9fe3435573..6f859782e9 100644 --- a/packages/util/workspace-path/src/index.ts +++ b/packages/util/workspace-path/src/index.ts @@ -2,12 +2,22 @@ * Browser-safe Workspace path and display helpers. * @module @deepseek-ai/dsh-util-workspace-path */ +import { absoluteFileAddress, sessionFileAddress } from './file-address.ts' /** Whether a path uses a Windows drive or UNC prefix. */ function isWindowsStylePath(value: string): boolean { return /^[A-Za-z]:[/\\]/.test(value) || value.startsWith('\\\\') } +/** + * Whether a path is absolute in either spelling the Host accepts: POSIX (`/a/b`) or Windows drive or UNC. + * @param path - the path to classify. + * @returns `true` for an absolute path; `false` for a Workspace-relative one. + */ +export function isAbsoluteWorkspacePath(path: string): boolean { + return path.startsWith('/') || isWindowsStylePath(path) +} + /** * Resolve a Workspace-relative path into the Host-facing spelling used by path operations. * @param cwd - Session Workspace root, when known. @@ -15,7 +25,7 @@ function isWindowsStylePath(value: string): boolean { * @returns an absolute path when a Workspace root is available, otherwise the original path. */ export function resolveWorkspacePath(cwd: string | undefined, path: string): string { - if (path.startsWith('/') || isWindowsStylePath(path)) return path + if (isAbsoluteWorkspacePath(path)) return path if (cwd === undefined || cwd === '') return path const separator = isWindowsStylePath(cwd) && cwd.includes('\\') ? '\\' : '/' const base = cwd.replace(/[/\\]+$/, '') @@ -50,3 +60,24 @@ export function workspaceTitleOf(path: string): string { const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')) return trimmed.slice(separator + 1) } + +export * from './file-address.ts' + +/** + * The address for a path as a caller holds it: a relative path, or an absolute + * path inside the Session's workspace, becomes a `session`-scoped address; an + * absolute path outside it, or one whose workspace root is unknown, becomes an + * `absolute`-scoped address. + * @param sessionId - the Session the path is read in. + * @param cwd - that Session's workspace root, when known. + * @param path - absolute or workspace-relative path, in either separator spelling. + * @returns the `dsh-resource://file/…` address. + */ +export function fileAddressFor(sessionId: string, cwd: string | undefined, path: string): string { + const normalized = path.replace(/\\/g, '/') + if (!isAbsoluteWorkspacePath(normalized)) return sessionFileAddress(sessionId, normalized) + const root = cwd === undefined ? '' : cwd.replace(/\\/g, '/').replace(/\/+$/, '') + if (root !== '' && normalized === root) return sessionFileAddress(sessionId, '') + if (root !== '' && normalized.startsWith(`${root}/`)) return sessionFileAddress(sessionId, normalized.slice(root.length + 1)) + return absoluteFileAddress(normalized) +} diff --git a/packages/util/workspace-path/tests/file-address.spec.ts b/packages/util/workspace-path/tests/file-address.spec.ts new file mode 100644 index 0000000000..3021cb8f58 --- /dev/null +++ b/packages/util/workspace-path/tests/file-address.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { absoluteFileAddress, parseFileAddress, sessionFileAddress } from '../src/file-address.ts' + +describe('file addresses', () => { + it('round-trips a session-relative path with encoded segments', () => { + const address = sessionFileAddress('s 1', 'w/a b#c?.txt') + expect(address).toBe('dsh-resource://file/session/s%201/w/a%20b%23c%3F.txt') + expect(parseFileAddress(address)).toEqual({ scope: 'session', sessionId: 's 1', path: 'w/a b#c?.txt' }) + }) + + it('drops a leading ./ or / from a session-relative path and names the root with an empty path', () => { + expect(sessionFileAddress('s', './src/a.ts')).toBe('dsh-resource://file/session/s/src/a.ts') + expect(sessionFileAddress('s', '/src/a.ts')).toBe('dsh-resource://file/session/s/src/a.ts') + expect(sessionFileAddress('s', 'src\\a.ts')).toBe('dsh-resource://file/session/s/src/a.ts') + expect(sessionFileAddress('s', '')).toBe('dsh-resource://file/session/s/') + expect(parseFileAddress('dsh-resource://file/session/s/')).toEqual({ scope: 'session', sessionId: 's', path: '' }) + }) + + it('round-trips an absolute POSIX path with the leading slash dropped', () => { + const address = absoluteFileAddress('/home/me/notes.md') + expect(address).toBe('dsh-resource://file/absolute/home/me/notes.md') + expect(parseFileAddress(address)).toEqual({ scope: 'absolute', path: '/home/me/notes.md' }) + }) + + it('round-trips an absolute Windows drive path with backslashes normalized and the colon literal', () => { + const address = absoluteFileAddress('C:\\w\\x.ts') + expect(address).toBe('dsh-resource://file/absolute/C:/w/x.ts') + expect(parseFileAddress(address)).toEqual({ scope: 'absolute', path: 'C:/w/x.ts' }) + }) + + it('keeps a UNC path\'s identity in an absolute address', () => { + const address = absoluteFileAddress('\\\\server\\share\\x.ts') + expect(address).toBe('dsh-resource://file/absolute//server/share/x.ts') + expect(parseFileAddress(address)).toEqual({ scope: 'absolute', path: '//server/share/x.ts' }) + }) + + it('decodes either spelling of a path segment', () => { + expect(parseFileAddress('dsh-resource://file/session/s/w/a+b.txt')?.path).toBe('w/a+b.txt') + expect(parseFileAddress('dsh-resource://file/session/s/w/a%2Bb.txt')?.path).toBe('w/a+b.txt') + expect(parseFileAddress('dsh-resource://file/absolute/w/a%2Bb.txt')?.path).toBe('/w/a+b.txt') + }) + + it.each([ + ['another resource type', 'dsh-resource://terminal/session/s/1'], + ['a type spelled with another case', 'dsh-resource://File/session/s/w/x.ts'], + ['an unknown scope', 'dsh-resource://file/shared/s/w/x'], + ['the retired file:// grammar', 'file://sessions/s/w/x.ts'], + ['a host-less file URL', 'file:///w/x.ts'], + ['a session address with no path', 'dsh-resource://file/session/s'], + ['a session address with no id', 'dsh-resource://file/session'], + ['an absolute address with no path', 'dsh-resource://file/absolute'], + ['an absolute address with an empty path', 'dsh-resource://file/absolute/'], + ['a UNC marker with no host behind it', 'dsh-resource://file/absolute//'], + ['no scope', 'dsh-resource://file'], + ['another scheme', 'sidebar://files'], + ['not a URL', 'notes.txt'], + ['a malformed escape', 'dsh-resource://file/session/s/%E0%A4%A'], + ])('rejects %s', (_, address) => { + expect(parseFileAddress(address)).toBeUndefined() + }) +}) diff --git a/packages/util/workspace-path/tests/index.spec.ts b/packages/util/workspace-path/tests/index.spec.ts index 0044e4b088..5695187336 100644 --- a/packages/util/workspace-path/tests/index.spec.ts +++ b/packages/util/workspace-path/tests/index.spec.ts @@ -1,9 +1,31 @@ import { describe, expect, it } from 'vitest' import { - abbreviateHomePath, resolveWorkspacePath, workspaceTitleOf, + abbreviateHomePath, fileAddressFor, isAbsoluteWorkspacePath, resolveWorkspacePath, workspaceTitleOf, } from '@deepseek-ai/dsh-util-workspace-path' describe('Workspace path helpers', () => { + it('addresses a relative or in-workspace path by session and any other absolute path by itself', () => { + expect(fileAddressFor('s', '/w', 'src/a.ts')).toBe('dsh-resource://file/session/s/src/a.ts') + expect(fileAddressFor('s', undefined, 'src/a.ts')).toBe('dsh-resource://file/session/s/src/a.ts') + expect(fileAddressFor('s', '/w/', '/w/src/a.ts')).toBe('dsh-resource://file/session/s/src/a.ts') + expect(fileAddressFor('s', '/w', '/w')).toBe('dsh-resource://file/session/s/') + expect(fileAddressFor('s', '/w', '/work/a.ts')).toBe('dsh-resource://file/absolute/work/a.ts') + expect(fileAddressFor('s', undefined, '/etc/hosts')).toBe('dsh-resource://file/absolute/etc/hosts') + expect(fileAddressFor('s', 'C:\\w', 'C:\\w\\x.ts')).toBe('dsh-resource://file/session/s/x.ts') + expect(fileAddressFor('s', 'C:\\w', 'D:\\x.ts')).toBe('dsh-resource://file/absolute/D:/x.ts') + expect(fileAddressFor('s', undefined, '\\\\server\\share\\x.ts')).toBe('dsh-resource://file/absolute//server/share/x.ts') + expect(fileAddressFor('s', '\\\\server\\share', '\\\\server\\share\\x.ts')).toBe('dsh-resource://file/session/s/x.ts') + }) + + it('classifies POSIX, Windows drive, and UNC paths as absolute and everything else as relative', () => { + expect(isAbsoluteWorkspacePath('/a/b')).toBe(true) + expect(isAbsoluteWorkspacePath('C:\\x\\a.ts')).toBe(true) + expect(isAbsoluteWorkspacePath('C:/x/a.ts')).toBe(true) + expect(isAbsoluteWorkspacePath('\\\\server\\share')).toBe(true) + expect(isAbsoluteWorkspacePath('src/a.ts')).toBe(false) + expect(isAbsoluteWorkspacePath('')).toBe(false) + }) + it('resolves relative paths without changing absolute paths', () => { expect(resolveWorkspacePath('/w', 'src/a.ts')).toBe('/w/src/a.ts') expect(resolveWorkspacePath('/w/', '/abs/a.ts')).toBe('/abs/a.ts') diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index ab2fa043fc..11cb6b4efb 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-web", "description": "Model-facing web tools (web_search, web_fetch) over the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-fetch-http/package.json b/packages/web/web-fetch-http/package.json index 672dd4d6a2..83d34f912c 100644 --- a/packages/web/web-fetch-http/package.json +++ b/packages/web/web-fetch-http/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-fetch-http", "description": "Anonymous public HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 988473a8d7..2e9c4095fd 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-deepseek", "description": "DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index bfe7f733f4..acf7b03618 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-exa", "description": "Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index eec160192f..b4dd685fec 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-perplexity", "description": "Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 40433f155f..fda44ae0b7 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web", "description": "Abstract web access capability seam (ctx.web) for the DeepSeek Harness — search/fetch provider registry, registration-order-independent selection, request/result vocabulary, and the WebError taxonomy", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook-github/package.json b/packages/webhook/webhook-github/package.json index 93a1c5a539..b767b0cd86 100644 --- a/packages/webhook/webhook-github/package.json +++ b/packages/webhook/webhook-github/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook-github", "description": "Signed GitHub HTTP webhook adapter for the DeepSeek Harness webhook runtime", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook/package.json b/packages/webhook/webhook/package.json index 24b7b6d7a7..16e8d0e969 100644 --- a/packages/webhook/webhook/package.json +++ b/packages/webhook/webhook/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook", "description": "Fire-and-forget webhook rule runtime that creates Workspace-backed DeepSeek Harness Sessions", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index 7ae876e2d3..fe6a3beacd 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ralph", "description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts index 6d14949aba..6293e0ff55 100644 --- a/packages/workflow/tool-ralph/tests/integration.spec.ts +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -5,7 +5,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver' import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -21,7 +20,6 @@ async function mountRalph(script: MockScript, config: toolRalph.Config) { const ctx = new Context() const adapter = new MockAdapter(script) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(spawn, { providerName: 'spawn' }) @@ -59,7 +57,6 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { toolCallResponse('round-2', STRUCTURED_OUTPUT_TOOL, finalReport), ]) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(spawn, { providerName: 'spawn' }) diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 352c801952..0ac119b02c 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-workflow", "description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflowEngine", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json index 4ef415b2ac..ce36f4b65c 100644 --- a/packages/workflow/workflow-worker-thread/package.json +++ b/packages/workflow/workflow-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow-worker-thread", "description": "worker-thread workflow engine: executes model-written orchestration scripts off the host event loop, bridging agent() calls back to ctx.subagents", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow-worker-thread/tests/integration.spec.ts b/packages/workflow/workflow-worker-thread/tests/integration.spec.ts index ed67dccc0a..06bbdd277e 100644 --- a/packages/workflow/workflow-worker-thread/tests/integration.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/integration.spec.ts @@ -7,7 +7,6 @@ import InvariantRegistry from '@deepseek-ai/dsh-invariants' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver' @@ -36,7 +35,6 @@ async function setup(script: Script) { const adapter = new MockAdapter(script) await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(spawn, { providerName: 'spawn' }) diff --git a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts index daf7516ca2..d9a118c7bb 100644 --- a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts +++ b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts @@ -1,13 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import LlmRuntime from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as Spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -31,12 +27,7 @@ afterEach(async () => { async function harness(): Promise { const built = new Context() - await built.plugin(LlmRuntime) - await built.plugin(SessionStore) - await built.plugin(SessionProjectionRegistry) - await built.plugin(SystemPrompt) - await built.plugin(ToolRuntime) - await built.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(built) await built.plugin(AgentLoop, { agents: [] }) await built.plugin(LlmDeepSeek) await built.plugin(SubagentRuntime) diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index 11f84b0c64..a4b6950d6b 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow", "description": "Workflow capability seam: ctx.workflowEngine service, run vocabulary, and workflow/* events", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 5714138036..eaff1f7076 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace", "description": "Workspace entity registry (ctx.workspaceRegistry): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness", - "version": "0.1.3-alpha.1", + "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4ede4a861..d6b6037f02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,7 +57,7 @@ importers: version: 4.1.8(vitest@4.1.8) '@yao-pkg/pkg': specifier: 6.21.0 - version: 6.21.0(patch_hash=28edd2180c36691c481522ef81f6f6614505f45e491aad542ac4663d4e6b3ff8) + version: 6.21.0(patch_hash=28edd2180c36691c481522ef81f6f6614505f45e491aad542ac4663d4e6b3ff8)(supports-color@9.4.0) '@yarnpkg/cli-dist': specifier: 4.17.1 version: 4.17.1 @@ -90,7 +90,7 @@ importers: version: 1.32.0 mdast-util-from-markdown: specifier: ^2.0.3 - version: 2.0.3 + version: 2.0.3(supports-color@9.4.0) mdast-util-gfm: specifier: ^3.1.0 version: 3.1.0 @@ -126,7 +126,7 @@ importers: version: 6.0.3 vite-tsconfig-paths: specifier: ^6.1.1 - version: 6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 6.1.1(supports-color@9.4.0)(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) vitest: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(happy-dom@20.11.6)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -356,6 +356,12 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../packages/core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../packages/core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../packages/test-support/agent-loop-testkit '@deepseek-ai/dsh-attachment-local': specifier: workspace:^ version: link:../../packages/attachment/attachment-local @@ -477,6 +483,103 @@ importers: specifier: 8.21.0 version: 8.21.0 + apps/desktop: + dependencies: + electron-updater: + specifier: ^6.8.9 + version: 6.8.9 + semver: + specifier: ^7.8.5 + version: 7.8.5 + devDependencies: + '@aws-sdk/client-s3': + specifier: 3.1067.0 + version: 3.1067.0 + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../packages/util/home-paths + '@electron/notarize': + specifier: 2.5.0 + version: 2.5.0 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/node': + specifier: ^22.20.0 + version: 22.20.0 + '@types/semver': + specifier: ^7.8.0 + version: 7.8.0 + app-builder-lib: + specifier: 26.15.3 + version: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + electron: + specifier: ^44.0.0 + version: 44.0.0(supports-color@9.4.0) + electron-builder: + specifier: ^26.15.3 + version: 26.15.3(electron-builder-squirrel-windows@26.15.3) + extract-zip: + specifier: ^2.0.1 + version: 2.0.1(supports-color@9.4.0) + js-yaml: + specifier: ^4.2.0 + version: 4.3.1 + msgpackr: + specifier: 2.0.4 + version: 2.0.4 + pnpm: + specifier: 11.7.0 + version: 11.7.0 + tar: + specifier: ^7.5.0 + version: 7.5.22 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + apps/desktop-host: + dependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../vendor/include + '@deepseek-ai/dsh': + specifier: workspace:^ + version: link:../cli + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../../packages/api/gateway + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../packages/boot/app-boot + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../packages/client/connection + '@deepseek-ai/dsh-client-modules': + specifier: workspace:^ + version: link:../../packages/client/modules + '@deepseek-ai/dsh-client-ui-directory-picker-native': + specifier: workspace:^ + version: link:../../packages/client/ui-directory-picker-native + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../packages/boot/cmdline + '@deepseek-ai/dsh-host-directory-picker-native': + specifier: workspace:^ + version: link:../../packages/host/directory-picker-native + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../packages/host/webserver + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../packages/util/launch-environment + '@deepseek-ai/dsh-web-frontend': + specifier: workspace:^ + version: link:../web + apps/web: devDependencies: '@deepseek-ai/cordis-plugin-group': @@ -488,6 +591,9 @@ importers: '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../../packages/client/store + '@deepseek-ai/dsh-client-ui-dockkit': + specifier: workspace:^ + version: link:../../packages/client/ui-dockkit '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../../packages/client/ui-primitives @@ -523,13 +629,13 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^4.0.0 - version: 4.7.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.7.0(supports-color@9.4.0)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) fflate: specifier: ^0.8.2 version: 0.8.3 http-server: specifier: ^14.1.1 - version: 14.1.1 + version: 14.1.1(supports-color@9.4.0) playwright: specifier: ^1.49.0 version: 1.61.1 @@ -786,6 +892,9 @@ importers: '@deepseek-ai/dsh-api-workspace-controller': specifier: workspace:^ version: link:../workspace-controller + '@deepseek-ai/dsh-api-workspace-files': + specifier: workspace:^ + version: link:../workspace-files '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection @@ -865,6 +974,12 @@ importers: '@deepseek-ai/dsh-agent-default-model': specifier: workspace:^ version: link:../../core/agent-default-model + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets @@ -1027,6 +1142,52 @@ importers: specifier: workspace:^ version: link:../../workspace/workspace + packages/api/workspace-files: + dependencies: + '@deepseek-ai/dsh-deque': + specifier: workspace:^ + version: link:../../util/deque + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../gateway + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../session-controller + '@deepseek-ai/dsh-client-resources': + specifier: workspace:^ + version: link:../../client/resources + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../../client/ui-slots + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-util-workspace-path': + specifier: workspace:^ + version: link:../../util/workspace-path + packages/attachment/attachment: devDependencies: '@deepseek-ai/cordis': @@ -1424,6 +1585,12 @@ importers: '@deepseek-ai/dsh-agent-default-model': specifier: workspace:^ version: link:../../core/agent-default-model + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1564,6 +1731,9 @@ importers: '@deepseek-ai/dsh-api-workspace-controller': specifier: workspace:^ version: link:../../api/workspace-controller + '@deepseek-ai/dsh-api-workspace-files': + specifier: workspace:^ + version: link:../../api/workspace-files '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot @@ -1582,6 +1752,9 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules + '@deepseek-ai/dsh-client-resources': + specifier: workspace:^ + version: link:../../client/resources '@deepseek-ai/dsh-client-ui-agent-preset': specifier: workspace:^ version: link:../../client/ui-agent-preset @@ -1672,6 +1845,15 @@ importers: '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../client/ui-sidebar + '@deepseek-ai/dsh-client-ui-sidebar-files': + specifier: workspace:^ + version: link:../../client/ui-sidebar-files + '@deepseek-ai/dsh-client-ui-sidebar-right': + specifier: workspace:^ + version: link:../../client/ui-sidebar-right + '@deepseek-ai/dsh-client-ui-sidebar-textpreview': + specifier: workspace:^ + version: link:../../client/ui-sidebar-textpreview '@deepseek-ai/dsh-client-ui-skill': specifier: workspace:^ version: link:../../client/ui-skill @@ -1960,6 +2142,27 @@ importers: specifier: workspace:^ version: link:../../util/package-manifest + packages/client/resources: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + packages/client/store: dependencies: immer: @@ -2169,6 +2372,9 @@ importers: '@deepseek-ai/dsh-api-workspace-controller': specifier: workspace:^ version: link:../../api/workspace-controller + '@deepseek-ai/dsh-api-workspace-files': + specifier: workspace:^ + version: link:../../api/workspace-files '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment @@ -2199,6 +2405,12 @@ importers: '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-sidebar-right': + specifier: workspace:^ + version: link:../ui-sidebar-right + '@deepseek-ai/dsh-client-ui-sidebar-textpreview': + specifier: workspace:^ + version: link:../ui-sidebar-textpreview '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2557,6 +2769,34 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-dockkit: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) + packages/client/ui-goal: devDependencies: '@deepseek-ai/cordis': @@ -3007,7 +3247,7 @@ importers: version: 0.16.47 mdast-util-from-markdown: specifier: ^2.0.3 - version: 2.0.3 + version: 2.0.3(supports-color@9.4.0) mdast-util-gfm: specifier: ^3.1.0 version: 3.1.0 @@ -3470,6 +3710,207 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-sidebar-files: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-api-workspace-files': + specifier: workspace:^ + version: link:../../api/workspace-files + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-dockkit': + specifier: workspace:^ + version: link:../ui-dockkit + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session + '@deepseek-ai/dsh-client-ui-sidebar-right': + specifier: workspace:^ + version: link:../ui-sidebar-right + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-util-workspace-path': + specifier: workspace:^ + version: link:../../util/workspace-path + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) + + packages/client/ui-sidebar-right: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + picomatch: + specifier: ^4.0.4 + version: 4.0.4 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-resources': + specifier: workspace:^ + version: link:../resources + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-dockkit': + specifier: workspace:^ + version: link:../ui-dockkit + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:^ + version: link:../ui-layout + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/picomatch': + specifier: ^4.0.2 + version: 4.0.3 + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) + + packages/client/ui-sidebar-textpreview: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-api-workspace-files': + specifier: workspace:^ + version: link:../../api/workspace-files + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-resources': + specifier: workspace:^ + version: link:../resources + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-dockkit': + specifier: workspace:^ + version: link:../ui-dockkit + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session + '@deepseek-ai/dsh-client-ui-sidebar-right': + specifier: workspace:^ + version: link:../ui-sidebar-right + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-util-workspace-path': + specifier: workspace:^ + version: link:../../util/workspace-path + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) + packages/client/ui-skill: devDependencies: '@deepseek-ai/cordis': @@ -3996,6 +4437,9 @@ importers: '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../store + '@deepseek-ai/dsh-client-ui-dockkit': + specifier: workspace:^ + version: link:../ui-dockkit '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives @@ -4213,6 +4657,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs @@ -4427,6 +4874,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -4472,6 +4922,9 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry + '@deepseek-ai/dsh-util-values': + specifier: workspace:^ + version: link:../../util/values packages/core/agent-default-model: dependencies: @@ -4742,6 +5195,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot @@ -5353,6 +5809,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-anonymous-user-id': specifier: workspace:^ version: link:../../identity/anonymous-user-id @@ -5589,6 +6048,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs @@ -5634,6 +6096,12 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands @@ -5665,6 +6133,12 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../shell/bash-local @@ -5756,6 +6230,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../goal @@ -6072,7 +6549,7 @@ importers: version: link:../../../vendor/schemastery compression: specifier: ^1.8.1 - version: 1.8.1 + version: 1.8.1(supports-color@9.4.0) negotiator: specifier: ^1.0.0 version: 1.0.0 @@ -6279,6 +6756,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -8117,6 +8597,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -8233,6 +8716,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -8335,6 +8821,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -9156,6 +9645,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -9184,6 +9676,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -9221,6 +9716,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-jobs': specifier: workspace:^ version: link:../../jobs/jobs @@ -9281,6 +9779,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -10478,6 +10979,9 @@ importers: '@deepseek-ai/dsh-util-time': specifier: workspace:^ version: link:../../packages/util/time + '@deepseek-ai/dsh-util-values': + specifier: workspace:^ + version: link:../../packages/util/values '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../../packages/web/web @@ -10639,7 +11143,7 @@ importers: version: 1.11.21 debug: specifier: 4.4.3 - version: 4.4.3 + version: 4.4.3(supports-color@9.4.0) mermaid: specifier: 11.16.0 version: 11.16.0 @@ -10828,6 +11332,9 @@ packages: resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} @@ -10841,14 +11348,26 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + '@aws-sdk/checksums@3.1000.29': + resolution: {integrity: sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-bedrock-runtime@3.1048.0': resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-s3@3.1067.0': + resolution: {integrity: sha512-3f64o9YWzwJ9WzMIC4JlUQiMOm7R/EtkIDyFdj8yaQXuh8SR9ezz2R32UMpvTlVMtpoPan3Uj8oveAHr2UeExw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.974.20': resolution: {integrity: sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.977.9': + resolution: {integrity: sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.46': resolution: {integrity: sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==} engines: {node: '>=20.0.0'} @@ -10889,6 +11408,14 @@ packages: resolution: {integrity: sha512-tdbnXbw73ww62ABWP0G0Z/euvFowEEvAoi/zG4NaZo7HJFpfGho/Z65HyVzkJLT1cMsUregr4pTyxljlarT0wA==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-flexible-checksums@3.974.54': + resolution: {integrity: sha512-cDplgLpXZy7MfREXbAeOm8PFT8ibjD5B5rbqxocFR/5rdSbXUPURIAvRToVqjcOR5gHYbEkndKAs+Zw7FfZj1A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.75': + resolution: {integrity: sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-websocket@3.972.28': resolution: {integrity: sha512-SCW06Zjugn86pq7+dxGnFcyWJuEWHT753HTU/Vj/OzVxP+NoShwdAr4ynxAcvWL883OgRVbSqW3ohnjIxwXjjw==} engines: {node: '>= 14.0.0'} @@ -10901,6 +11428,10 @@ packages: resolution: {integrity: sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.46': + resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1048.0': resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} engines: {node: '>=20.0.0'} @@ -10913,6 +11444,10 @@ packages: resolution: {integrity: sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.974.5': + resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-locate-window@3.965.7': resolution: {integrity: sha512-M0D6oIpohdNHjc7udzTHEQyot0+0iuA36jc2I9Hps+f/GtKi2HO/pyijQnCnNcwZqLB5+rtn81z3eZK/GyjAmA==} engines: {node: '>=20.0.0'} @@ -10921,10 +11456,18 @@ packages: resolution: {integrity: sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.40': + resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.4': resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -11132,6 +11675,50 @@ packages: resolution: {integrity: sha512-Bg/YN6kA7Swja/NQxka8xFdecb4E/auIEGF2G5A25EaQXhRnPj300/7/KpgsDDMYUzHTDAv4RyUxaQPJKW81Rw==} engines: {node: '>=22.19.0'} + '@electron-internal/extract-zip@1.0.5': + resolution: {integrity: sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==} + engines: {node: '>=22.12.0'} + + '@electron/asar@3.4.1': + resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} + engines: {node: '>=10.12.0'} + hasBin: true + + '@electron/fuses@1.8.0': + resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} + hasBin: true + + '@electron/get@3.1.0': + resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} + engines: {node: '>=14'} + + '@electron/get@5.1.0': + resolution: {integrity: sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==} + engines: {node: '>=22.12.0'} + + '@electron/notarize@2.5.0': + resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} + engines: {node: '>= 10.0.0'} + + '@electron/osx-sign@1.3.3': + resolution: {integrity: sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==} + engines: {node: '>=12.0.0'} + hasBin: true + + '@electron/rebuild@4.2.0': + resolution: {integrity: sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@electron/universal@2.0.3': + resolution: {integrity: sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==} + engines: {node: '>=16.4'} + + '@electron/windows-sign@1.2.2': + resolution: {integrity: sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==} + engines: {node: '>=14.14'} + hasBin: true + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -12050,6 +12637,14 @@ packages: typescript: optional: true + '@malept/cross-spawn-promise@2.0.0': + resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} + engines: {node: '>= 12.13.0'} + + '@malept/flatpak-bundler@0.4.0': + resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} + engines: {node: '>= 10.0.0'} + '@mermaid-js/mermaid-mindmap@9.3.0': resolution: {integrity: sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw==} @@ -12077,12 +12672,46 @@ packages: resolution: {integrity: sha512-Mmjg4anFBD5OzbPnGJOA0jPPN8645ERhQk38HQLpSenx1ox9bfdPkmAzUnNjeQtqQGFLtKe13J20RtLBmUKMZA==} hasBin: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + '@noble/hashes@2.3.0': resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} engines: {node: '>= 20.19.0'} @@ -12485,6 +13114,21 @@ packages: cpu: [x64] os: [win32] + '@peculiar/asn1-schema@2.9.4': + resolution: {integrity: sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==} + engines: {node: '>=14'} + + '@peculiar/json-schema@1.1.12': + resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==} + engines: {node: '>=8.0.0'} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/webcrypto@1.7.1': + resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} + engines: {node: '>=14.18.0'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -12919,6 +13563,10 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} @@ -12927,6 +13575,10 @@ packages: resolution: {integrity: sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==} engines: {node: '>=18.0.0'} + '@smithy/core@3.33.3': + resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.3.9': resolution: {integrity: sha512-ZlfJ/4Fa3jYb+3eaohPfG9utX9HmdhFNcFtpoGAhUhdynAOmGXtmigbi7eEiONKM+ykHw8RwKuDEb85Lx7t7fA==} engines: {node: '>=18.0.0'} @@ -12951,10 +13603,18 @@ packages: resolution: {integrity: sha512-LwQZazFayImv+IOm0S0enoLeUJwmAlhGC5O6YCcLWezyu08dF46GOxPOq35OpBIHkgd7OvNvBStIFwVNyrvoBw==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.7.3': + resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==} + engines: {node: '>=18.0.0'} + '@smithy/types@4.14.4': resolution: {integrity: sha512-B2S9+UGm1+/pHkcx3ZoLVX1a+pmSk8rqxRR+ZsNqZaJ5q9FWX9AFGQVM4qG5+OBeQUZVy99HY8HqW8gK/wgXzQ==} engines: {node: '>=18.0.0'} + '@smithy/types@4.17.2': + resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==} + engines: {node: '>=18.0.0'} + '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} @@ -12975,6 +13635,10 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + '@tanstack/react-virtual@3.14.9': resolution: {integrity: sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==} peerDependencies: @@ -13027,6 +13691,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -13150,12 +13817,18 @@ packages: '@types/fs-ext@2.0.3': resolution: {integrity: sha512-0j2F+laosJF2NTd2DVheQ5GvXo8ln9L175VwLPfbsppE33iYC+6gn6XlOQS0pGvZm2yrQ32/LRZh0As/7rCs2Q==} + '@types/fs-extra@9.0.13': + resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -13174,6 +13847,9 @@ packages: '@types/katex@0.16.8': resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} @@ -13195,6 +13871,9 @@ packages: '@types/node@22.20.0': resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/node@25.9.3': resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} @@ -13204,6 +13883,9 @@ packages: '@types/picomatch@3.0.2': resolution: {integrity: sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA==} + '@types/picomatch@4.0.3': + resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -13224,9 +13906,15 @@ packages: '@types/readable-stream@4.0.24': resolution: {integrity: sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==} + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} @@ -13260,6 +13948,9 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@typescript-eslint/types@8.61.0': resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -13472,6 +14163,10 @@ packages: '@vueuse/shared@12.8.2': resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + '@xmldom/xmldom@0.8.15': + resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} + engines: {node: '>=10.0.0'} + '@xterm/headless@6.0.0': resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==} @@ -13493,6 +14188,10 @@ packages: resolution: {integrity: sha512-WoxUM/Be4hfsX06FxsvpGgfYqwgivMV7/Ol7aFuSfSmY6rRaiju4QxOEe9RUS0iYcSHWl5i9AhB1cMoE0p+XiA==} engines: {node: '>=18.12.0'} + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -13563,12 +14262,23 @@ packages: anynum@1.0.0: resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==} + app-builder-lib@26.15.3: + resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==} + engines: {node: '>=14.0.0'} + peerDependencies: + dmg-builder: 26.15.3 + electron-builder-squirrel-windows: 26.15.3 + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -13580,9 +14290,23 @@ packages: ast-v8-to-istanbul@1.0.4: resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: @@ -13615,8 +14339,8 @@ packages: bare-buffer: optional: true - bare-path@3.1.1: - resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + bare-path@3.1.2: + resolution: {integrity: sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==} bare-stream@2.13.4: resolution: {integrity: sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==} @@ -13632,8 +14356,8 @@ packages: bare-events: optional: true - bare-url@2.5.2: - resolution: {integrity: sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==} + bare-url@2.5.3: + resolution: {integrity: sha512-3absfEzoyFosWT8v83ZcJgbTJxv+S/sI7jBfeCMlUVatSaefRmwweqBhFpMllQv+HTxs/FbbToVOw1c05NORkQ==} base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -13669,9 +14393,16 @@ packages: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + brace-expansion@2.1.2: resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} @@ -13684,9 +14415,15 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-image-size@0.6.4: resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} engines: {node: '>=4.0'} @@ -13697,6 +14434,14 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + builder-util-runtime@9.7.0: + resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} + engines: {node: '>=12.0.0'} + + builder-util@26.15.3: + resolution: {integrity: sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==} + engines: {node: '>=14.0.0'} + builtin-modules@3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} @@ -13709,10 +14454,22 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + bytestreamjs@2.0.1: + resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} + engines: {node: '>=6.0.0'} + cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -13763,9 +14520,27 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + chromium-pickle-js@0.2.0: + resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} + + ci-info@4.3.1: + resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} + engines: {node: '>=8'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -13777,6 +14552,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -13784,6 +14563,10 @@ packages: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -13796,6 +14579,10 @@ packages: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} + compare-version@0.1.2: + resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} + engines: {node: '>=0.10.0'} + compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} @@ -13807,6 +14594,9 @@ packages: resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} engines: {node: '>= 0.8.0'} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -13851,6 +14641,9 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cross-dirname@0.1.0: + resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -14071,16 +14864,32 @@ packages: resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + define-lazy-prop@3.0.0: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -14093,6 +14902,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -14104,6 +14916,12 @@ packages: resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} + dir-compare@4.2.0: + resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + + dmg-builder@26.15.3: + resolution: {integrity: sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==} + dockerfile-ast@0.7.1: resolution: {integrity: sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw==} @@ -14113,6 +14931,14 @@ packages: dompurify@3.4.11: resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + dotenv-expand@11.0.7: + resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dts-resolver@3.0.0: resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} engines: {node: ^22.18.0 || >=24.0.0} @@ -14142,9 +14968,37 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-builder-squirrel-windows@26.15.3: + resolution: {integrity: sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==} + + electron-builder@26.15.3: + resolution: {integrity: sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==} + engines: {node: '>=14.0.0'} + hasBin: true + + electron-publish@26.15.3: + resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} + electron-to-chromium@1.5.393: resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} + electron-updater@6.8.9: + resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} + + electron-winstaller@5.4.0: + resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} + engines: {node: '>=8.0.0'} + + electron@44.0.0: + resolution: {integrity: sha512-FkTqPrFPZYljdPI5b7KORGsJTd6FgUQDefl5MrU3Xz9R87pAj9JLreIjDqcRN8hJIkFHIou0o8kKzvcpT9qiRQ==} + engines: {node: '>= 22.12.0'} + hasBin: true + emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} @@ -14173,6 +15027,17 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -14188,9 +15053,16 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -14320,6 +15192,9 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express-rate-limit@8.5.2: resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} engines: {node: '>= 16'} @@ -14333,6 +15208,11 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + fast-check@4.8.0: resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} engines: {node: '>=12.17.0'} @@ -14362,6 +15242,9 @@ packages: resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} hasBin: true + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -14386,6 +15269,9 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -14417,6 +15303,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -14436,10 +15326,33 @@ packages: resolution: {integrity: sha512-/TrISPOFhCkbgIRWK9lzscRzwPCu0PqtCcvMc9jsHKBgZGoqA0VzhspVht5Zu8lxaXjIYIBWILHpRotYkCCcQA==} engines: {node: '>= 8.0.0'} + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + fs-extra@11.3.1: resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} engines: {node: '>=14.14'} + fs-extra@11.4.0: + resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} + engines: {node: '>=14.14'} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -14480,6 +15393,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-stream@9.0.1: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} @@ -14506,10 +15423,22 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + globals@17.7.0: resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} engines: {node: '>=18'} + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} @@ -14525,6 +15454,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -14539,10 +15472,17 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -14567,6 +15507,10 @@ packages: hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + html-encoding-sniffer@3.0.0: resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} engines: {node: '>=12'} @@ -14581,6 +15525,9 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -14598,6 +15545,10 @@ packages: engines: {node: '>=12'} hasBin: true + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -14638,6 +15589,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -14726,9 +15681,25 @@ packages: isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isbinaryfile@4.0.10: + resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} + engines: {node: '>= 8.0.0'} + + isbinaryfile@5.0.7: + resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} + engines: {node: '>= 18.0.0'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -14748,6 +15719,11 @@ packages: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -14843,11 +15819,17 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} @@ -14883,6 +15865,9 @@ packages: layout-base@2.0.1: resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lazy-val@1.0.5: + resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + lefthook-darwin-arm64@2.1.9: resolution: {integrity: sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg==} cpu: [arm64] @@ -15033,9 +16018,19 @@ packages: lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -15046,6 +16041,10 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -15056,6 +16055,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -15081,6 +16084,10 @@ packages: engines: {node: '>= 20'} hasBin: true + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -15225,10 +16232,18 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -15238,6 +16253,15 @@ packages: engines: {node: '>=4'} hasBin: true + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -15246,6 +16270,13 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} @@ -15270,6 +16301,10 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -15280,6 +16315,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.4: + resolution: {integrity: sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==} + multistream@4.1.0: resolution: {integrity: sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==} @@ -15309,6 +16351,10 @@ packages: resolution: {integrity: sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==} engines: {node: '>=10'} + node-abi@4.34.0: + resolution: {integrity: sha512-4Oy5Q6/Ftna9sXyrkdnKypfvm9uWRpxUPvlw4oA192QNMN39aq8k4l36TUUUU/ONw7ivGVi402Ud+UBPVDYh6A==} + engines: {node: '>=22.12.0'} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -15364,6 +16410,9 @@ packages: resolution: {integrity: sha512-yuXz43GmtQyMrO75u2Z8KZAafMhnMH8RTOZBJWGDU9HoD2QxT6q4PF28iLNm/OS9BkS8MHwCKpgTk3d6qW584A==} engines: {node: '>=20'} + node-api-version@0.2.1: + resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -15373,6 +16422,15 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + node-gyp@12.4.0: + resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -15386,6 +16444,15 @@ packages: non-layered-tidy-tree-layout@2.0.2: resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + npm-run-path@6.0.0: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} @@ -15398,6 +16465,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} @@ -15471,6 +16542,10 @@ packages: vite-plus: optional: true + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -15517,6 +16592,10 @@ packages: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -15542,6 +16621,13 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pe-library@0.4.1: + resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} + engines: {node: '>=12', npm: '>=6'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -15556,6 +16642,10 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + pkijs@3.4.0: + resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} + engines: {node: '>=16.0.0'} + platform@1.3.6: resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} @@ -15569,6 +16659,15 @@ packages: engines: {node: '>=18'} hasBin: true + plist@3.1.0: + resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} + engines: {node: '>=10.4.0'} + + pnpm@11.7.0: + resolution: {integrity: sha512-GcyFLBIMcSV2DyRD7mvgyltA+fUFmN4aCaHxd1A+AQ5Xwjx3ZG4B52HeWb+HT7IqM5jDOrlpH8E+uUa28PTWIA==} + engines: {node: '>=22.13'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -15618,6 +16717,10 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -15629,6 +16732,13 @@ packages: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -15655,6 +16765,13 @@ packages: pure-rand@8.4.0: resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.2.0: + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} + engines: {node: '>=16.0.0'} + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -15662,6 +16779,10 @@ packages: quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + range-parser@1.3.0: resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} @@ -15690,6 +16811,10 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + read-binary-file-arch@1.0.6: + resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} + hasBin: true + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -15737,6 +16862,13 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resedit@1.7.2: + resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} + engines: {node: '>=12', npm: '>=6'} + + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -15749,6 +16881,13 @@ packages: engines: {node: '>= 0.4'} hasBin: true + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -15756,6 +16895,15 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@2.6.3: + resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -15820,6 +16968,13 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sanitize-filename@1.6.4: + resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -15837,10 +16992,22 @@ packages: secure-compare@3.0.1: resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==} + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + semver@7.8.4: resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} engines: {node: '>=10'} @@ -15855,6 +17022,10 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -15908,6 +17079,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -15918,6 +17092,10 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + smol-toml@1.7.1: resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==} engines: {node: '>= 18'} @@ -15926,6 +17104,13 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -15942,9 +17127,16 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stat-mode@1.0.0: + resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} + engines: {node: '>= 6'} + standardwebhooks@1.1.1: resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} @@ -16000,6 +17192,10 @@ packages: stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + superjson@2.2.6: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} @@ -16042,9 +17238,22 @@ packages: teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + temp-file@3.4.0: + resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} + + temp@0.9.4: + resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} + engines: {node: '>=6.0.0'} + text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + tiny-async-pool@1.3.0: + resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -16067,6 +17276,13 @@ packages: resolution: {integrity: sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==} hasBin: true + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -16086,6 +17302,9 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + truncate-utf8-bytes@1.0.2: + resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} @@ -16163,6 +17382,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -16186,12 +17409,19 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + engines: {node: '>=18.17'} + undici@7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} @@ -16226,6 +17456,10 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -16254,6 +17488,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + utf8-byte-length@1.0.5: + resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -16482,6 +17719,9 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webcrypto-core@1.9.2: + resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -16513,6 +17753,16 @@ packages: engines: {node: '>= 8'} hasBin: true + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -16557,6 +17807,10 @@ packages: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -16567,6 +17821,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} @@ -16580,10 +17837,21 @@ packages: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + yargs@16.2.2: resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} engines: {node: '>=10'} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -16819,6 +18087,15 @@ snapshots: '@aws-sdk/types': 3.973.12 tslib: 2.8.1 + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.12 + '@aws-sdk/util-locate-window': 3.965.7 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + '@aws-crypto/sha256-browser@5.2.0': dependencies: '@aws-crypto/sha256-js': 5.2.0 @@ -16845,6 +18122,14 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 + '@aws-sdk/checksums@3.1000.29': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/client-bedrock-runtime@3.1048.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -16862,6 +18147,23 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 + '@aws-sdk/client-s3@3.1067.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/credential-provider-node': 3.972.55 + '@aws-sdk/middleware-flexible-checksums': 3.974.54 + '@aws-sdk/middleware-sdk-s3': 3.972.75 + '@aws-sdk/signature-v4-multi-region': 3.996.34 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/fetch-http-handler': 5.4.7 + '@smithy/node-http-handler': 4.7.8 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + '@aws-sdk/core@3.974.20': dependencies: '@aws-sdk/types': 3.973.12 @@ -16873,6 +18175,17 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/core@3.977.9': + dependencies: + '@aws-sdk/types': 3.974.5 + '@aws-sdk/xml-builder': 3.972.40 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.33.3 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.17.2 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.46': dependencies: '@aws-sdk/core': 3.974.20 @@ -16971,6 +18284,20 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 + '@aws-sdk/middleware-flexible-checksums@3.974.54': + dependencies: + '@aws-sdk/checksums': 3.1000.29 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.75': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/middleware-websocket@3.972.28': dependencies: '@aws-sdk/core': 3.974.20 @@ -17001,6 +18328,13 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.46': + dependencies: + '@aws-sdk/types': 3.974.5 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1048.0': dependencies: '@aws-sdk/core': 3.974.20 @@ -17024,6 +18358,11 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 + '@aws-sdk/types@3.974.5': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/util-locate-window@3.965.7': dependencies: tslib: 2.8.1 @@ -17034,8 +18373,15 @@ snapshots: fast-xml-parser: 5.7.3 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.40': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.4': {} + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -17044,20 +18390,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7': + '@babel/core@7.29.7(supports-color@9.4.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@9.4.0)) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@9.4.0) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -17093,17 +18439,17 @@ snapshots: '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@9.4.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@9.4.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@9.4.0) '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -17132,14 +18478,14 @@ snapshots: dependencies: '@babel/types': 8.0.0-rc.6 - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@9.4.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@9.4.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@9.4.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@9.4.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/runtime@7.29.7': {} @@ -17150,7 +18496,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.7(supports-color@9.4.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 @@ -17158,7 +18504,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/template': 7.29.7 '@babel/types': 7.29.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -17270,6 +18616,100 @@ snapshots: '@earendil-works/pi-telemetry@0.85.1': {} + '@electron-internal/extract-zip@1.0.5': {} + + '@electron/asar@3.4.1': + dependencies: + commander: 5.1.0 + glob: 7.2.3 + minimatch: 3.1.5 + + '@electron/fuses@1.8.0': + dependencies: + chalk: 4.1.2 + fs-extra: 9.1.0 + minimist: 1.2.8 + + '@electron/get@3.1.0': + dependencies: + debug: 4.4.3(supports-color@9.4.0) + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@electron/get@5.1.0(supports-color@9.4.0)': + dependencies: + debug: 4.4.3(supports-color@9.4.0) + env-paths: 3.0.0 + graceful-fs: 4.2.11 + progress: 2.0.3 + semver: 7.8.5 + sumchecker: 3.0.1 + optionalDependencies: + undici: 7.28.0 + transitivePeerDependencies: + - supports-color + + '@electron/notarize@2.5.0': + dependencies: + debug: 4.4.3(supports-color@9.4.0) + fs-extra: 9.1.0 + promise-retry: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@electron/osx-sign@1.3.3': + dependencies: + compare-version: 0.1.2 + debug: 4.4.3(supports-color@9.4.0) + fs-extra: 10.1.0 + isbinaryfile: 4.0.10 + minimist: 1.2.8 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + + '@electron/rebuild@4.2.0': + dependencies: + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3(supports-color@9.4.0) + node-abi: 4.34.0 + node-api-version: 0.2.1 + node-gyp: 12.4.0 + read-binary-file-arch: 1.0.6 + transitivePeerDependencies: + - supports-color + + '@electron/universal@2.0.3': + dependencies: + '@electron/asar': 3.4.1 + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3(supports-color@9.4.0) + dir-compare: 4.2.0 + fs-extra: 11.4.0 + minimatch: 9.0.9 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + + '@electron/windows-sign@1.2.2': + dependencies: + cross-dirname: 0.1.0 + debug: 4.4.3(supports-color@9.4.0) + fs-extra: 11.4.0 + minimist: 1.2.8 + postject: 1.0.0-alpha.6 + transitivePeerDependencies: + - supports-color + optional: true + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -17542,7 +18982,7 @@ snapshots: '@eslint/config-array@0.23.5': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) minimatch: 10.2.5 transitivePeerDependencies: - supports-color @@ -17903,6 +19343,19 @@ snapshots: optionalDependencies: typescript: 6.0.3 + '@malept/cross-spawn-promise@2.0.0': + dependencies: + cross-spawn: 7.0.6 + + '@malept/flatpak-bundler@0.4.0': + dependencies: + debug: 4.4.3(supports-color@9.4.0) + fs-extra: 9.1.0 + lodash: 4.18.1 + tmp-promise: 3.0.3 + transitivePeerDependencies: + - supports-color + '@mermaid-js/mermaid-mindmap@9.3.0': dependencies: '@braintree/sanitize-url': 6.0.4 @@ -17964,6 +19417,24 @@ snapshots: - supports-color - zod + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -17978,6 +19449,8 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@noble/hashes@1.4.0': {} + '@noble/hashes@2.3.0': {} '@nodable/entities@2.2.0': {} @@ -18245,6 +19718,28 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.76.0': optional: true + '@peculiar/asn1-schema@2.9.4': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/json-schema@1.1.12': + dependencies: + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/webcrypto@1.7.1': + dependencies: + '@peculiar/asn1-schema': 2.9.4 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + tslib: 2.8.1 + webcrypto-core: 1.9.2 + '@pkgjs/parseargs@0.11.0': optional: true @@ -18535,6 +20030,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/is@4.6.0': {} + '@sindresorhus/merge-streams@4.0.0': {} '@smithy/core@3.24.7': @@ -18543,6 +20040,11 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 + '@smithy/core@3.33.3': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.3.9': dependencies: '@smithy/core': 3.24.7 @@ -18577,10 +20079,20 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 + '@smithy/signature-v4@5.7.3': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@smithy/types@4.14.4': dependencies: tslib: 2.8.1 + '@smithy/types@4.17.2': + dependencies: + tslib: 2.8.1 + '@smithy/util-buffer-from@2.2.0': dependencies: '@smithy/is-array-buffer': 2.2.0 @@ -18605,6 +20117,10 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.4 + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + '@tanstack/react-virtual@3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/virtual-core': 3.17.7 @@ -18669,6 +20185,13 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 22.20.0 + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 22.20.0 + '@types/responselike': 1.0.3 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -18827,12 +20350,18 @@ snapshots: dependencies: '@types/node': 22.20.0 + '@types/fs-extra@9.0.13': + dependencies: + '@types/node': 22.20.0 + '@types/geojson@7946.0.16': {} '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 + '@types/http-cache-semantics@4.2.0': {} + '@types/http-errors@2.0.5': {} '@types/js-yaml@4.0.9': {} @@ -18850,6 +20379,10 @@ snapshots: '@types/katex@0.16.8': {} + '@types/keyv@3.1.4': + dependencies: + '@types/node': 22.20.0 + '@types/linkify-it@5.0.0': {} '@types/markdown-it@14.1.2': @@ -18871,6 +20404,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + '@types/node@25.9.3': dependencies: undici-types: 7.24.6 @@ -18881,6 +20418,8 @@ snapshots: '@types/picomatch@3.0.2': {} + '@types/picomatch@4.0.3': {} + '@types/prop-types@15.7.15': {} '@types/qs@6.15.1': {} @@ -18900,8 +20439,14 @@ snapshots: dependencies: '@types/node': 22.20.0 + '@types/responselike@1.0.3': + dependencies: + '@types/node': 22.20.0 + '@types/retry@0.12.0': {} + '@types/semver@7.8.0': {} + '@types/send@1.2.1': dependencies: '@types/node': 22.20.0 @@ -18931,6 +20476,11 @@ snapshots: dependencies: '@types/node': 22.20.0 + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 22.20.0 + optional: true + '@typescript-eslint/types@8.61.0': {} '@ungap/structured-clone@1.3.3': {} @@ -18940,11 +20490,11 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitejs/plugin-react@4.7.0(supports-color@9.4.0)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@9.4.0) + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@9.4.0)) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@9.4.0)) '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 @@ -19178,6 +20728,8 @@ snapshots: transitivePeerDependencies: - typescript + '@xmldom/xmldom@0.8.15': {} + '@xterm/headless@6.0.0': {} '@yao-pkg/pkg-fetch@3.6.4': @@ -19193,11 +20745,11 @@ snapshots: - bare-buffer - react-native-b4a - '@yao-pkg/pkg@6.21.0(patch_hash=28edd2180c36691c481522ef81f6f6614505f45e491aad542ac4663d4e6b3ff8)': + '@yao-pkg/pkg@6.21.0(patch_hash=28edd2180c36691c481522ef81f6f6614505f45e491aad542ac4663d4e6b3ff8)(supports-color@9.4.0)': dependencies: '@babel/generator': 7.29.7 '@babel/parser': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@9.4.0) '@babel/types': 7.29.7 '@roberts_lando/vfs': 0.3.3 '@yao-pkg/pkg-fetch': 3.6.4 @@ -19227,6 +20779,8 @@ snapshots: js-yaml: 4.3.1 tslib: 2.8.1 + abbrev@4.0.0: {} + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -19297,12 +20851,66 @@ snapshots: anynum@1.0.0: {} + app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3): + dependencies: + '@electron/asar': 3.4.1 + '@electron/fuses': 1.8.0 + '@electron/get': 3.1.0 + '@electron/notarize': 2.5.0 + '@electron/osx-sign': 1.3.3 + '@electron/rebuild': 4.2.0 + '@electron/universal': 2.0.3 + '@malept/flatpak-bundler': 0.4.0 + '@noble/hashes': 2.3.0 + '@peculiar/webcrypto': 1.7.1 + '@types/fs-extra': 9.0.13 + ajv: 8.20.0 + asn1js: 3.0.10 + async-exit-hook: 2.0.1 + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 + chromium-pickle-js: 0.2.0 + ci-info: 4.3.1 + debug: 4.4.3(supports-color@9.4.0) + dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3) + dotenv: 16.6.1 + dotenv-expand: 11.0.7 + ejs: 3.1.10 + electron-builder-squirrel-windows: 26.15.3(dmg-builder@26.15.3) + electron-publish: 26.15.3 + fs-extra: 10.1.0 + hosted-git-info: 4.1.0 + isbinaryfile: 5.0.7 + jiti: 2.7.0 + js-yaml: 4.3.1 + json5: 2.2.3 + lazy-val: 1.0.5 + minimatch: 10.2.5 + pkijs: 3.4.0 + plist: 3.1.0 + proper-lockfile: 4.1.2 + resedit: 1.7.2 + semver: 7.7.4 + tar: 7.5.22 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + unzipper: 0.12.5 + which: 5.0.0 + transitivePeerDependencies: + - supports-color + argparse@2.0.1: {} aria-query@5.3.0: dependencies: dequal: 2.0.3 + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + assertion-error@2.0.1: {} ast-kit@3.0.0-beta.1: @@ -19317,8 +20925,16 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + async-exit-hook@2.0.1: {} + async@3.2.6: {} + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + aws4@1.13.2: {} + b4a@1.8.1: {} balanced-match@1.0.2: {} @@ -19330,15 +20946,15 @@ snapshots: bare-fs@4.8.1: dependencies: bare-events: 2.9.2 - bare-path: 3.1.1 + bare-path: 3.1.2 bare-stream: 2.13.4(bare-events@2.9.2) - bare-url: 2.5.2 + bare-url: 2.5.3 fast-fifo: 1.3.2 transitivePeerDependencies: - bare-abort-controller - react-native-b4a - bare-path@3.1.1: {} + bare-path@3.1.2: {} bare-stream@2.13.4(bare-events@2.9.2): dependencies: @@ -19350,9 +20966,9 @@ snapshots: transitivePeerDependencies: - react-native-b4a - bare-url@2.5.2: + bare-url@2.5.3: dependencies: - bare-path: 3.1.1 + bare-path: 3.1.2 base64-js@1.5.1: {} @@ -19384,7 +21000,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 @@ -19394,8 +21010,16 @@ snapshots: transitivePeerDependencies: - supports-color + boolean@3.2.0: + optional: true + bowser@2.14.1: {} + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + brace-expansion@2.1.2: dependencies: balanced-match: 1.0.2 @@ -19412,8 +21036,12 @@ snapshots: node-releases: 2.0.51 update-browserslist-db: 1.2.3(browserslist@4.28.6) + buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} + buffer-image-size@0.6.4: dependencies: '@types/node': 22.20.0 @@ -19428,6 +21056,32 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + builder-util-runtime@9.7.0: + dependencies: + debug: 4.4.3(supports-color@9.4.0) + sax: 1.6.1 + transitivePeerDependencies: + - supports-color + + builder-util@26.15.3: + dependencies: + '@types/debug': 4.1.13 + builder-util-runtime: 9.7.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@9.4.0) + fs-extra: 10.1.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + js-yaml: 4.3.1 + sanitize-filename: 1.6.4 + source-map-support: 0.5.21 + stat-mode: 1.0.0 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + transitivePeerDependencies: + - supports-color + builtin-modules@3.3.0: {} bundle-name@4.1.0: @@ -19436,8 +21090,22 @@ snapshots: bytes@3.1.2: {} + bytestreamjs@2.0.1: {} + cac@7.0.0: {} + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -19479,12 +21147,28 @@ snapshots: chownr@3.0.0: {} + chromium-pickle-js@0.2.0: {} + + ci-info@4.3.1: {} + + ci-info@4.4.0: {} + cliui@7.0.4: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + clsx@2.1.1: {} color-convert@2.0.1: @@ -19493,27 +21177,35 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} commander@15.0.0: {} + commander@5.1.0: {} + commander@7.2.0: {} commander@8.3.0: {} commander@9.5.0: {} + compare-version@0.1.2: {} + compare-versions@6.1.1: {} compressible@2.0.18: dependencies: mime-db: 1.54.0 - compression@1.8.1: + compression@1.8.1(supports-color@9.4.0): dependencies: bytes: 3.1.2 compressible: 2.0.18 - debug: 2.6.9 + debug: 2.6.9(supports-color@9.4.0) negotiator: 0.6.4 on-headers: 1.1.0 safe-buffer: 5.2.1 @@ -19521,6 +21213,8 @@ snapshots: transitivePeerDependencies: - supports-color + concat-map@0.0.1: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -19554,6 +21248,9 @@ snapshots: dependencies: layout-base: 2.0.1 + cross-dirname@0.1.0: + optional: true + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -19762,13 +21459,17 @@ snapshots: dayjs@1.11.21: {} - debug@2.6.9: + debug@2.6.9(supports-color@9.4.0): dependencies: ms: 2.0.0 + optionalDependencies: + supports-color: 9.4.0 - debug@4.4.3: + debug@4.4.3(supports-color@9.4.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 9.4.0 decimal.js@10.6.0: {} @@ -19791,20 +21492,41 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.1 + defer-to-connect@2.0.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + optional: true + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + optional: true + defu@6.1.7: {} delaunator@5.1.0: dependencies: robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} + depd@2.0.0: {} dequal@2.0.3: {} detect-libc@2.1.2: {} + detect-node@2.1.0: + optional: true + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -19813,6 +21535,21 @@ snapshots: diff@9.0.0: {} + dir-compare@4.2.0: + dependencies: + minimatch: 3.1.5 + p-limit: 3.1.0 + + dmg-builder@26.15.3(electron-builder-squirrel-windows@26.15.3): + dependencies: + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 + fs-extra: 10.1.0 + js-yaml: 4.3.1 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + dockerfile-ast@0.7.1: dependencies: vscode-languageserver-textdocument: 1.0.12 @@ -19824,6 +21561,12 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + dotenv-expand@11.0.7: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + dts-resolver@3.0.0(oxc-resolver@11.20.0): optionalDependencies: oxc-resolver: 11.20.0 @@ -19860,8 +21603,84 @@ snapshots: ee-first@1.1.1: {} + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-builder-squirrel-windows@26.15.3(dmg-builder@26.15.3): + dependencies: + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 + electron-winstaller: 5.4.0 + transitivePeerDependencies: + - dmg-builder + - supports-color + + electron-builder@26.15.3(electron-builder-squirrel-windows@26.15.3): + dependencies: + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 + chalk: 4.1.2 + ci-info: 4.4.0 + dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3) + fs-extra: 10.1.0 + lazy-val: 1.0.5 + simple-update-notifier: 2.0.0 + yargs: 17.7.3 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + + electron-publish@26.15.3: + dependencies: + '@types/fs-extra': 9.0.13 + aws4: 1.13.2 + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 + chalk: 4.1.2 + form-data: 4.0.6 + fs-extra: 10.1.0 + lazy-val: 1.0.5 + mime: 2.6.0 + transitivePeerDependencies: + - supports-color + electron-to-chromium@1.5.393: {} + electron-updater@6.8.9: + dependencies: + builder-util-runtime: 9.7.0 + fs-extra: 10.1.0 + js-yaml: 4.3.1 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.4 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + + electron-winstaller@5.4.0: + dependencies: + '@electron/asar': 3.4.1 + debug: 4.4.3(supports-color@9.4.0) + fs-extra: 7.0.1 + lodash: 4.18.1 + temp: 0.9.4 + optionalDependencies: + '@electron/windows-sign': 1.2.2 + transitivePeerDependencies: + - supports-color + + electron@44.0.0(supports-color@9.4.0): + dependencies: + '@electron-internal/extract-zip': 1.0.5 + '@electron/get': 5.1.0(supports-color@9.4.0) + '@types/node': 24.13.3 + transitivePeerDependencies: + - supports-color + emoji-regex-xs@1.0.0: {} emoji-regex@8.0.0: {} @@ -19880,6 +21699,12 @@ snapshots: entities@8.0.0: {} + env-paths@2.2.1: {} + + env-paths@3.0.0: {} + + err-code@2.0.3: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -19890,8 +21715,18 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + es-toolkit@1.49.0: {} + es6-error@4.1.1: + optional: true + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -20028,7 +21863,7 @@ snapshots: '@types/estree': 1.0.9 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -20121,6 +21956,8 @@ snapshots: expect-type@1.3.0: {} + exponential-backoff@3.1.3: {} + express-rate-limit@8.5.2(express@5.2.1): dependencies: express: 5.2.1 @@ -20134,7 +21971,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -20161,6 +21998,16 @@ snapshots: extend@3.0.2: {} + extract-zip@2.0.1(supports-color@9.4.0): + dependencies: + debug: 4.4.3(supports-color@9.4.0) + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + fast-check@4.8.0: dependencies: pure-rand: 8.4.0 @@ -20189,6 +22036,10 @@ snapshots: path-expression-matcher: 1.5.0 strnum: 2.4.0 + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -20208,9 +22059,13 @@ snapshots: dependencies: flat-cache: 4.0.1 + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + finalhandler@2.1.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -20242,6 +22097,14 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -20256,12 +22119,45 @@ snapshots: dependencies: nan: 2.28.0 + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs-extra@11.3.1: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.1 universalify: 2.0.1 + fs-extra@11.4.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs.realpath@1.0.0: {} + fsevents@2.3.2: optional: true @@ -20310,6 +22206,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + get-stream@9.0.1: dependencies: '@sec-ant/readable-stream': 0.4.1 @@ -20343,8 +22243,33 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.2 + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.8.5 + serialize-error: 7.0.1 + optional: true + globals@17.7.0: {} + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + optional: true + globrex@0.1.2: {} google-auth-library@10.7.0: @@ -20362,6 +22287,20 @@ snapshots: gopd@1.2.0: {} + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + graceful-fs@4.2.11: {} hachure-fill@0.5.2: {} @@ -20381,8 +22320,17 @@ snapshots: has-flag@4.0.0: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + optional: true + has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -20413,6 +22361,10 @@ snapshots: hookable@6.1.1: {} + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + html-encoding-sniffer@3.0.0: dependencies: whatwg-encoding: 2.0.0 @@ -20427,6 +22379,8 @@ snapshots: html-void-elements@3.0.0: {} + http-cache-semantics@4.2.0: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -20438,7 +22392,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -20450,7 +22404,7 @@ snapshots: transitivePeerDependencies: - debug - http-server@14.1.1: + http-server@14.1.1(supports-color@9.4.0): dependencies: basic-auth: 2.0.1 chalk: 4.1.2 @@ -20461,7 +22415,7 @@ snapshots: mime: 1.6.0 minimist: 1.2.8 opener: 1.5.2 - portfinder: 1.0.38 + portfinder: 1.0.38(supports-color@9.4.0) secure-compare: 3.0.1 union: 0.5.0 url-join: 4.0.1 @@ -20469,10 +22423,15 @@ snapshots: - debug - supports-color + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -20500,6 +22459,11 @@ snapshots: imurmurhash@0.1.4: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + inherits@2.0.4: {} ini@1.3.8: {} @@ -20554,8 +22518,16 @@ snapshots: isarray@1.0.0: {} + isbinaryfile@4.0.10: {} + + isbinaryfile@5.0.7: {} + isexe@2.0.0: {} + isexe@3.1.5: {} + + isexe@4.0.0: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -20579,8 +22551,13 @@ snapshots: dependencies: '@isaacs/cliui': 9.0.0 - jiti@2.7.0: - optional: true + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + jiti@2.7.0: {} jose@6.2.3: {} @@ -20670,8 +22647,15 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: + optional: true + json5@2.2.3: {} + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + jsonfile@6.2.1: dependencies: universalify: 2.0.1 @@ -20730,6 +22714,8 @@ snapshots: layout-base@2.0.1: {} + lazy-val@1.0.5: {} + lefthook-darwin-arm64@2.1.9: optional: true @@ -20843,8 +22829,14 @@ snapshots: lodash-es@4.18.1: {} + lodash.escaperegexp@4.1.2: {} + + lodash.isequal@4.5.0: {} + lodash.merge@4.6.2: {} + lodash@4.18.1: {} + long@5.3.2: {} longest-streak@3.1.0: {} @@ -20853,6 +22845,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lowercase-keys@2.0.0: {} + lru-cache@10.4.3: {} lru-cache@11.5.1: {} @@ -20861,6 +22855,10 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + lz-string@1.5.0: {} magic-string@0.30.21: @@ -20875,7 +22873,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.8.4 + semver: 7.8.5 mark.js@8.11.1: {} @@ -20883,6 +22881,11 @@ snapshots: marked@16.4.2: {} + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -20892,14 +22895,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3: + mdast-util-from-markdown@2.0.3(supports-color@9.4.0): dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2 + micromark: 4.0.2(supports-color@9.4.0) micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -20921,7 +22924,7 @@ snapshots: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: @@ -20930,7 +22933,7 @@ snapshots: mdast-util-gfm-strikethrough@2.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -20940,7 +22943,7 @@ snapshots: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -20949,14 +22952,14 @@ snapshots: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color mdast-util-gfm@3.1.0: dependencies: - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-gfm-autolink-literal: 2.0.1 mdast-util-gfm-footnote: 2.1.0 mdast-util-gfm-strikethrough: 2.0.0 @@ -20972,7 +22975,7 @@ snapshots: '@types/mdast': 4.0.4 devlop: 1.1.0 longest-streak: 3.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-to-markdown: 2.1.2 unist-util-remove-position: 5.0.0 transitivePeerDependencies: @@ -21220,10 +23223,10 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2: + micromark@4.0.2(supports-color@9.4.0): dependencies: '@types/debug': 4.1.13 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -21242,20 +23245,38 @@ snapshots: transitivePeerDependencies: - supports-color + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.2: dependencies: mime-db: 1.54.0 mime@1.6.0: {} + mime@2.6.0: {} + + mimic-response@1.0.1: {} + mimic-response@3.1.0: {} minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.2 + minimatch@9.0.9: dependencies: brace-expansion: 2.1.2 @@ -21274,12 +23295,32 @@ snapshots: mkdirp-classic@0.5.3: {} + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + mri@1.2.0: {} ms@2.0.0: {} ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.0.4: + optionalDependencies: + msgpackr-extract: 3.0.4 + multistream@4.1.0: dependencies: once: 1.4.0 @@ -21301,6 +23342,10 @@ snapshots: dependencies: semver: 7.8.5 + node-abi@4.34.0: + dependencies: + semver: 7.8.5 + node-addon-api@7.1.1: {} node-addon-native-custom-loader@0.1.4: {} @@ -21352,6 +23397,10 @@ snapshots: node-addon-require-builtin-win32-ia32-msvc: 0.1.4 node-addon-require-builtin-win32-x64-msvc: 0.1.4 + node-api-version@0.2.1: + dependencies: + semver: 7.8.5 + node-domexception@1.0.0: {} node-fetch@3.3.2: @@ -21360,6 +23409,24 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + + node-gyp@12.4.0: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + graceful-fs: 4.2.11 + nopt: 9.0.0 + proc-log: 6.1.0 + semver: 7.8.5 + tar: 7.5.22 + tinyglobby: 0.2.17 + undici: 6.28.0 + which: 6.0.1 + node-int64@0.4.0: {} node-pty@1.2.0-beta.15(patch_hash=b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0): @@ -21371,6 +23438,12 @@ snapshots: non-layered-tidy-tree-layout@2.0.2: optional: true + nopt@9.0.0: + dependencies: + abbrev: 4.0.0 + + normalize-url@6.1.0: {} + npm-run-path@6.0.0: dependencies: path-key: 4.0.0 @@ -21380,6 +23453,9 @@ snapshots: object-inspect@1.13.4: {} + object-keys@1.1.1: + optional: true + obug@2.1.3: {} on-finished@2.4.1: @@ -21492,6 +23568,8 @@ snapshots: '@oxlint/binding-win32-x64-msvc': 1.76.0 oxlint-tsgolint: 7.0.2001 + p-cancelable@2.1.1: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -21527,6 +23605,8 @@ snapshots: path-expression-matcher@1.5.0: {} + path-is-absolute@1.0.1: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -21547,6 +23627,10 @@ snapshots: pathe@2.0.3: {} + pe-library@0.4.1: {} + + pend@1.2.0: {} + perfect-debounce@1.0.0: {} picocolors@1.1.1: {} @@ -21555,6 +23639,15 @@ snapshots: pkce-challenge@5.0.1: {} + pkijs@3.4.0: + dependencies: + '@noble/hashes': 1.4.0 + asn1js: 3.0.10 + bytestreamjs: 2.0.1 + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + platform@1.3.6: {} playwright-core@1.61.1: {} @@ -21565,6 +23658,14 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + plist@3.1.0: + dependencies: + '@xmldom/xmldom': 0.8.15 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + pnpm@11.7.0: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -21572,10 +23673,10 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 - portfinder@1.0.38: + portfinder@1.0.38(supports-color@9.4.0): dependencies: async: 3.2.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -21620,12 +23721,25 @@ snapshots: dependencies: parse-ms: 4.0.0 + proc-log@6.1.0: {} + process-nextick-args@2.0.1: {} process@0.11.10: {} progress@2.0.3: {} + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + property-information@7.2.0: {} protobufjs@7.6.4: @@ -21663,6 +23777,12 @@ snapshots: pure-rand@8.4.0: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.2.0: {} + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -21670,6 +23790,8 @@ snapshots: quansync@1.0.0: {} + quick-lru@5.1.1: {} + range-parser@1.3.0: {} raw-body@3.0.2: @@ -21700,6 +23822,12 @@ snapshots: dependencies: loose-envify: 1.4.0 + read-binary-file-arch@1.0.6: + dependencies: + debug: 4.4.3(supports-color@9.4.0) + transitivePeerDependencies: + - supports-color + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -21753,6 +23881,12 @@ snapshots: requires-port@1.0.0: {} + resedit@1.7.2: + dependencies: + pe-library: 0.4.1 + + resolve-alpn@1.2.1: {} + resolve-pkg-maps@1.0.0: {} resolve.exports@2.0.3: {} @@ -21764,10 +23898,30 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + + retry@0.12.0: {} + retry@0.13.1: {} rfdc@1.4.1: {} + rimraf@2.6.3: + dependencies: + glob: 7.2.3 + + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + robust-predicates@3.0.3: {} rolldown-plugin-dts@0.25.2(oxc-resolver@11.20.0)(rolldown@1.1.1)(typescript@6.0.3): @@ -21868,7 +24022,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -21890,6 +24044,12 @@ snapshots: safer-buffer@2.1.2: {} + sanitize-filename@1.6.4: + dependencies: + truncate-utf8-bytes: 1.0.2 + + sax@1.6.1: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -21908,15 +24068,22 @@ snapshots: secure-compare@3.0.1: {} + semver-compare@1.0.0: + optional: true + + semver@5.7.2: {} + semver@6.3.1: {} + semver@7.7.4: {} + semver@7.8.4: {} semver@7.8.5: {} send@1.2.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -21930,6 +24097,11 @@ snapshots: transitivePeerDependencies: - supports-color + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -22034,6 +24206,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} simple-concat@1.0.1: {} @@ -22044,10 +24218,21 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + simple-update-notifier@2.0.0: + dependencies: + semver: 7.8.5 + smol-toml@1.7.1: {} source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + space-separated-tokens@2.0.2: {} spdx-exceptions@2.5.0: {} @@ -22061,8 +24246,13 @@ snapshots: speakingurl@14.0.1: {} + sprintf-js@1.1.3: + optional: true + stackback@0.0.2: {} + stat-mode@1.0.0: {} + standardwebhooks@1.1.1: dependencies: '@stablelib/base64': 1.0.1 @@ -22128,6 +24318,12 @@ snapshots: stylis@4.4.0: {} + sumchecker@3.0.1: + dependencies: + debug: 4.4.3(supports-color@9.4.0) + transitivePeerDependencies: + - supports-color + superjson@2.2.6: dependencies: copy-anything: 4.0.5 @@ -22157,7 +24353,7 @@ snapshots: tar-stream: 3.2.1 optionalDependencies: bare-fs: 4.8.1 - bare-path: 3.1.1 + bare-path: 3.1.2 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -22197,12 +24393,28 @@ snapshots: - bare-abort-controller - react-native-b4a + temp-file@3.4.0: + dependencies: + async-exit-hook: 2.0.1 + fs-extra: 10.1.0 + + temp@0.9.4: + dependencies: + mkdirp: 0.5.6 + rimraf: 2.6.3 + text-decoder@1.2.7: dependencies: b4a: 1.8.1 transitivePeerDependencies: - react-native-b4a + tiny-async-pool@1.3.0: + dependencies: + semver: 5.7.2 + + tiny-typed-emitter@2.1.0: {} + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -22220,6 +24432,12 @@ snapshots: dependencies: tldts-core: 7.4.5 + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.7 + + tmp@0.2.7: {} + toidentifier@1.0.1: {} tough-cookie@6.0.1: @@ -22234,6 +24452,10 @@ snapshots: trim-lines@3.0.1: {} + truncate-utf8-bytes@1.0.2: + dependencies: + utf8-byte-length: 1.0.5 + ts-algebra@2.0.0: {} ts-api-utils@2.5.0(typescript@6.0.3): @@ -22293,6 +24515,9 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@0.13.1: + optional: true + type-is@2.1.0: dependencies: content-type: 2.0.0 @@ -22315,10 +24540,14 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.18.2: {} + undici-types@7.24.6: {} undici-types@8.3.0: {} + undici@6.28.0: {} + undici@7.28.0: {} undici@8.10.0: {} @@ -22357,6 +24586,8 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universalify@0.1.2: {} + universalify@2.0.1: {} unpipe@1.0.0: {} @@ -22385,6 +24616,8 @@ snapshots: dependencies: react: 18.3.1 + utf8-byte-length@1.0.5: {} + util-deprecate@1.0.2: {} uuid@14.0.1: {} @@ -22401,9 +24634,9 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vite-tsconfig-paths@6.1.1(supports-color@9.4.0)(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) globrex: 0.1.2 tsconfck: 3.1.6(typescript@6.0.3) vite: 8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) @@ -22646,6 +24879,14 @@ snapshots: web-streams-polyfill@3.3.3: {} + webcrypto-core@1.9.2: + dependencies: + '@peculiar/asn1-schema': 2.9.4 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + webidl-conversions@8.0.1: {} whatwg-encoding@2.0.0: @@ -22670,6 +24911,14 @@ snapshots: dependencies: isexe: 2.0.0 + which@5.0.0: + dependencies: + isexe: 3.1.5 + + which@6.0.1: + dependencies: + isexe: 4.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -22702,18 +24951,24 @@ snapshots: xml-naming@0.1.0: {} + xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} y18n@5.0.8: {} yallist@3.1.1: {} + yallist@4.0.0: {} + yallist@5.0.0: {} yaml@2.9.0: {} yargs-parser@20.2.9: {} + yargs-parser@21.1.1: {} + yargs@16.2.2: dependencies: cliui: 7.0.4 @@ -22724,6 +24979,21 @@ snapshots: y18n: 5.0.8 yargs-parser: 20.2.9 + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + yocto-queue@0.1.0: {} yoctocolors@2.1.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2ec80d70d0..7cb1746e11 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -50,6 +50,12 @@ allowBuilds: # The Python runtime deploy includes the reviewed workspace postinstall that # restores the executable bit on node-pty's macOS spawn helper. '@deepseek-ai/dsh-subprocess-local@file:packages/subprocess/subprocess-local': true + # electron-builder pulls in the optional Squirrel.Windows helper, whose + # install script only selects its bundled 7-Zip executable. Desktop ships + # Windows through NSIS, so that mutation is not part of our build. + electron-winstaller: false + # Store-index rewriting only needs msgpackr's portable JavaScript codec. + msgpackr-extract: false minimumReleaseAgeExclude: # Fresh pi-ai releases carry the model catalog updates that are the whole diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 97c1a6bc68..d34b14bec4 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -121,6 +121,7 @@ "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/dsh-util-time": "workspace:^", + "@deepseek-ai/dsh-util-values": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-fetch-http": "workspace:^", "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 227e757d0c..1620e29b03 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -53,10 +53,16 @@ const experimentalPackageDirectory = /^packages\/experimental\/[^/]+$/ /** npm namespace reserved for private experimental packages. */ const experimentalPackageNamePrefix = '@deepseek-ai/dsh-experimental-' /** Directories whose packages this repository publishes: one release member each. */ -const releaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|apps\/[^/]+|vendor\/[^/]+)$/ +const releaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|apps\/(?!desktop(?:-host)?$)[^/]+|vendor\/[^/]+)$/ +/** Installable application assembled by electron-builder rather than published to npm. */ +const desktopApplicationDirectory = 'apps/desktop' const localArtifactDirs = new Set(['node_modules']) const appPackageFiles: Readonly> = { '@deepseek-ai/dsh': ['lib/*.js'], + '@deepseek-ai/dsh-desktop-host': [ + 'lib/index.js', + 'config/desktop.cordis.patch.yml', + ], // Sourcemaps stay out by payload policy; the worker-preview surface // (dist/preview.html and dist/preview/) backs private experimental // packages and is not published. @@ -147,6 +153,7 @@ const packageFileExtras: Readonly> = { // The glob covers whichever sheets a package emits; sourcemaps stay // unpublished, as everywhere else in the repository. '@deepseek-ai/dsh-client-ui-primitives': ['lib/**/*.css'], + '@deepseek-ai/dsh-client-ui-dockkit': ['lib/**/*.css'], '@deepseek-ai/dsh-client-web': ['lib/**/*.css'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], // The CPython side ships as source .py files, published as-is rather than built. @@ -359,7 +366,7 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st } } - if (dir.startsWith('apps/') && manifest.name?.startsWith('@deepseek-ai/')) { + if (dir.startsWith('apps/') && dir !== desktopApplicationDirectory && manifest.name?.startsWith('@deepseek-ai/')) { const expectedFiles = appPackageFiles[manifest.name] if (expectedFiles === undefined) { errors.push(`${label}: app package has no publication files policy`) diff --git a/scripts/clean.ts b/scripts/clean.ts index c0f7d71763..be13a95026 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -68,6 +68,7 @@ export class RepositoryCleaner { const canonicalRoot = await realpath(this.root) await this.addIfPresent(targets, join(this.root, '.dsh-build'), canonicalRoot) + await this.addIfPresent(targets, join(this.root, 'apps/desktop/.desktop-build'), canonicalRoot) // These checks cover legacy root-level incremental state emitted by older configs. await this.addIfPresent(targets, join(this.root, '.typecheck'), canonicalRoot) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 70677a8ae6..0cd0e385e7 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -123,6 +123,7 @@ export const SERVICE_PAGE: Record = { webhookRuntime: 'webhook.md', workspaceRegistry: 'workspace.md', workspaceController: 'workspace.md', + workspaceFiles: 'workspace.md', directoryPickerController: 'workspace.md', } @@ -175,6 +176,9 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API', workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API', + resources: 'client-side resource model (protocol providers, pins, live sources) — packages/client/resources/README.md owns the API', + sidebarRight: 'client-side right-Sidebar navigation face — packages/client/ui-sidebar-right/README.md owns the API', + sidebarRightTabs: 'client-side right-Sidebar tab-type registry — packages/client/ui-sidebar-right/README.md owns the API', } /** @@ -253,6 +257,7 @@ export const LINK_MAP: Readonly> = { ContentBlock: 'llm-streaming.md', CreateAgentOptions: 'core.md', GenerateOptions: 'llm-streaming.md', + Inbox: 'core.md', InboxItem: 'core.md', InboxPlacement: 'core.md', InspectorJsonValue: 'extensions.md', @@ -670,6 +675,9 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'Promise', 'Record', 'Readonly', + 'ReadonlyMap', + 'Request', + 'Response', 'Uint8Array', ]) @@ -751,6 +759,14 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', + WorkspaceByteRange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', + WorkspaceDirectoryListing: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', + WorkspaceFileBytes: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', + WorkspaceFileChange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', + WorkspaceFileWatchFrame: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', + WorkspaceFileRange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', + WorkspaceFileStat: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', + WorkspaceFileText: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', } /** Repository data policy consumed by the Cordis catalog projector. */ diff --git a/scripts/gen-cordis-inspect-catalog.ts b/scripts/gen-cordis-inspect-catalog.ts index cb5ae60b65..552ac07733 100644 --- a/scripts/gen-cordis-inspect-catalog.ts +++ b/scripts/gen-cordis-inspect-catalog.ts @@ -10,7 +10,7 @@ const root = resolve(import.meta.dirname, '..') const CLIENT_OUT = 'packages/extensions/cordis-client-runner/src/client/api-catalog.ts' const CLIENT_SERVICES: Readonly> = { - layout: ['toggleSidebar', 'openDetails', 'closeDetails'], + layout: ['toggleSidebar', 'openRightbar', 'closeRightbar'], locale: ['getLocale', 'getSnapshot', 'subscribe', 'setLocale', 'addLanguage', 'register', 'bind'], sessions: ['open', 'openSubagent', 'setSubagentCatalogOpen', 'refreshSubagents', 'search', 'fork', 'scope', 'binding'], slots: ['register', 'inject'], diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0df05a31df..52f3706d6b 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -192,6 +192,13 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition.', }, + { + key: 'workspaceFiles', + pkg: 'api-workspace-files', + title: 'Host workspace file Remote service', + mode: 'core', + note: 'Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session\'s workspace root, confined by lstat, containment, and a stat re-check.', + }, { key: 'workspaceController', pkg: 'api-workspace-controller', diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index f7062c2659..9a16919d36 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -450,7 +450,7 @@ describe('docsPages locale routes', () => { const translated = rootPages.filter(page => page.contentLocale === 'zh-CN') const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US') - expect(translated).toHaveLength(46) + expect(translated).toHaveLength(48) expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true) expect(fallbacks).toEqual([]) }) diff --git a/scripts/release/bump.ts b/scripts/release/bump.ts index 69f6582838..146580105a 100644 --- a/scripts/release/bump.ts +++ b/scripts/release/bump.ts @@ -48,7 +48,7 @@ interface PlannedVersion { readonly tag: string | undefined } -/** One private dsh package whose version follows the publishable family. */ +/** One private dsh workspace whose version follows the publishable family. */ interface PrivateDshVersion { /** Repository-relative manifest path. */ readonly manifestPath: string @@ -243,13 +243,13 @@ function rootVersion(root: string): string { } /** - * Discover private package manifests that share the dsh version without joining - * its publish set. + * Discover private package and application manifests that share the dsh version + * without joining its publish set. * @param root - repository root. - * @returns Private package manifests sorted by path. + * @returns Private workspace manifests sorted by path. */ function privateDshVersions(root: string): PrivateDshVersion[] { - return globSync('packages/*/*/package.json', { cwd: root }) + return globSync(['apps/*/package.json', 'packages/*/*/package.json'], { cwd: root }) .map(path => path.replaceAll('\\', '/')) .sort() .flatMap((manifestPath) => { diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index 8d9fbbe3e8..64cd1e268e 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -49,10 +49,20 @@ describe('release families', () => { expect(members.map(member => member.name)).not.toContain('@deepseek-ai/dsh-experimental-agent-team') }) - it('bumps private dsh packages without adding release tags', () => { + it('excludes private applications from the publish set', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-release-private-')) + roots.push(root) + write(join(root, 'apps/public/package.json'), '{"name":"@deepseek-ai/dsh-public","version":"0.0.1"}\n') + write(join(root, 'apps/private/package.json'), '{"name":"@deepseek-ai/dsh-private","version":"0.0.1","private":true}\n') + + expect(releaseFamily('dsh').members(root).map(entry => entry.name)).toEqual(['@deepseek-ai/dsh-public']) + }) + + it('bumps private dsh workspaces without adding release tags', () => { const root = mkdtempSync(join(tmpdir(), 'dsh-release-version-')) roots.push(root) write(join(root, 'package.json'), '{"version":"0.0.1"}\n') + write(join(root, 'apps/desktop/package.json'), '{"version":"0.0.1","private":true}\n') write(join(root, 'packages/experimental/prototype/package.json'), '{"version":"0.0.1","private":true}\n') write(join(root, 'packages/core/unselected/package.json'), '{"version":"0.0.1"}\n') @@ -63,6 +73,7 @@ describe('release families', () => { expect(planned.map(entry => ({ path: entry.manifestPath, tag: entry.tag }))).toEqual([ { path: 'package.json', tag: undefined }, { path: 'packages/core/published/package.json', tag: 'dsh-v0.0.2' }, + { path: 'apps/desktop/package.json', tag: undefined }, { path: 'packages/experimental/prototype/package.json', tag: undefined }, ]) }) diff --git a/scripts/release/families.ts b/scripts/release/families.ts index 6a888c9879..d1b5d23b82 100644 --- a/scripts/release/families.ts +++ b/scripts/release/families.ts @@ -117,7 +117,7 @@ export abstract class ReleaseFamily { /** * Discover this family's members. * @param root - repository root. - * @returns Members sorted by directory, with names validated and deduplicated. + * @returns Publishable members sorted by directory, with names validated and deduplicated. */ members(root: string): ReleaseMember[] { const manifestPaths = globSync([...this.patterns], { cwd: root }).sort() @@ -128,6 +128,7 @@ export abstract class ReleaseFamily { for (const manifestPath of manifestPaths) { const normalized = manifestPath.replaceAll('\\', '/') const manifest = readManifest(resolve(root, manifestPath)) + if (manifest.private === true) continue const name = requireString(manifest, 'name', normalized) const version = requireString(manifest, 'version', normalized) if (name === WORKSPACE_ROOT_PACKAGE) throw new Error(`${normalized} selected the workspace root`) diff --git a/scripts/release/pack.ts b/scripts/release/pack.ts index 0742eedcda..6f187e83af 100644 --- a/scripts/release/pack.ts +++ b/scripts/release/pack.ts @@ -10,6 +10,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { parseArgs } from 'node:util' +import { pnpmInvocation } from '../pnpm-invocation.ts' import { releaseFamily, tarballName, type ReleaseFamily, type ReleaseMember } from './families.ts' import { isEntry, runConcurrent } from './process.ts' import { PUBLISH_ORDER_FILE, tarballFiles } from './tarball.ts' @@ -25,7 +26,8 @@ const DEFAULT_OUTPUT = 'dist/npm' * @returns The tarball filename. */ async function packMember(family: ReleaseFamily, member: ReleaseMember, destination: string): Promise { - await runConcurrent('pnpm', ['--dir', member.directory, 'pack', '--pack-destination', destination]) + const invocation = pnpmInvocation(['--dir', member.directory, 'pack', '--pack-destination', destination]) + await runConcurrent(invocation.command, invocation.args) const filename = tarballName(member) const tarball = join(destination, filename) diff --git a/scripts/release/tarball.ts b/scripts/release/tarball.ts index 568c24e877..b8a005e748 100644 --- a/scripts/release/tarball.ts +++ b/scripts/release/tarball.ts @@ -27,7 +27,7 @@ export interface PackedIdentity { * @returns Every path inside the archive. */ export function tarballFiles(tarball: string): string[] { - return capture('tar', ['-tzf', tarball]).split('\n').filter(line => line !== '') + return capture('tar', ['-tzf', tarball]).split(/\r?\n/u).filter(line => line !== '') } /** diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index f929c88512..c843a901ed 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -116,6 +116,11 @@ "symbol": "LlmCallConfigAdapterDefaults", "source": "packages/llm/llm/src/call-config.ts" }, + { + "doc": "docs/subsystems/core.md", + "symbol": "Inbox", + "source": "packages/core/agent/src/runtime-types.ts" + }, { "doc": "docs/subsystems/core.md", "symbol": "InboxTarget", diff --git a/scripts/verify-client-ui-i18n.spec.ts b/scripts/verify-client-ui-i18n.spec.ts index 4f4b45572d..ae02d25744 100644 --- a/scripts/verify-client-ui-i18n.spec.ts +++ b/scripts/verify-client-ui-i18n.spec.ts @@ -62,4 +62,20 @@ describe('Client UI i18n source check', () => { 'export const en = { title: "Hard-coded by design" }', )).toEqual([]) }) + + it('rejects Electron dialog, title, prompt, and DOM copy outside locale owners', () => { + const source = ` + dialog.showMessageBox({ title: 'Update available', message: 'Install it now?' }) + window.setTitle('Desktop plugins') + window.prompt('Target version') + status.textContent = 'Finished' + ` + expect(findUiI18nViolations('apps/desktop/src/main.ts', source).map(row => row.text)).toEqual([ + 'Update available', + 'Install it now?', + 'Desktop plugins', + 'Target version', + 'Finished', + ]) + }) }) diff --git a/scripts/verify-client-ui-i18n.ts b/scripts/verify-client-ui-i18n.ts index fdb0962f04..ea09388a45 100644 --- a/scripts/verify-client-ui-i18n.ts +++ b/scripts/verify-client-ui-i18n.ts @@ -114,7 +114,7 @@ export function findUiI18nViolations(file: string, sourceText: string): UiI18nVi sourceText, ts.ScriptTarget.Latest, true, - file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + file.endsWith('.tsx') ? ts.ScriptKind.TSX : file.endsWith('.js') ? ts.ScriptKind.JS : ts.ScriptKind.TS, ) const violations = new Map() @@ -254,13 +254,27 @@ export function findUiI18nViolations(file: string, sourceText: string): UiI18nVi && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent)) ) collectExpression(node.expression, 'JSX child') - if (file.endsWith('.tsx') && ts.isPropertyAssignment(node)) { + if ((file.endsWith('.tsx') || file.startsWith('apps/desktop/')) && ts.isPropertyAssignment(node)) { const name = propertyName(node.name) if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) { collectExpression(node.initializer, `${name} property`) } } + if (file.startsWith('apps/desktop/') && ts.isBinaryExpression(node) + && node.operatorToken.kind === ts.SyntaxKind.EqualsToken + && ts.isPropertyAccessExpression(node.left) + && (node.left.name.text === 'textContent' || node.left.name.text === 'innerText')) { + collectExpression(node.right, `${node.left.name.text} assignment`) + } + + if (file.startsWith('apps/desktop/') && ts.isCallExpression(node) + && ts.isPropertyAccessExpression(node.expression) + && (node.expression.name.text === 'setTitle' || node.expression.name.text === 'prompt')) { + const copy = node.arguments[0] + if (copy !== undefined) collectExpression(copy, `${node.expression.name.text} argument`) + } + if (ts.isVariableDeclaration(node) && node.initializer !== undefined) { const name = propertyName(node.name) if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) { @@ -314,6 +328,8 @@ function sourceFiles(): string[] { ...[...clientComponentRoots].flatMap(clientRoot => globSync(`${clientRoot}/**/*.{ts,tsx}`, { cwd: root })), ...globSync('apps/web/src/**/*.{ts,tsx}', { cwd: root }), + ...globSync('apps/desktop/src/{main,update-coordinator}.{ts,tsx}', { cwd: root }), + ...globSync('apps/desktop/renderer/*.js', { cwd: root }), ])] .map(file => file.replaceAll('\\', '/')) .filter(file => !file.endsWith('.d.ts')) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index e2300ec26d..449ee7322a 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -84,6 +84,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/experimental/agent-team-web-profile': { kind: 'indirect', reason: 'The private Web layer delegates model composition to the Host-side Agent Teams profile.' }, 'packages/experimental/client-ui-agent-team': { kind: 'none', reason: 'The browser projection and task controls register no model-facing input.' }, 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-dockkit': { kind: 'none', reason: 'Browser-side docking layout engine and components; registers nothing model-facing.' }, + 'packages/client/ui-sidebar-right': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-brand-official': { kind: 'none', reason: 'Browser-side presentation occupants; registers nothing model-facing.' }, 'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, @@ -109,6 +111,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-directory-picker-browse': { kind: 'none', reason: 'Browser-side directory-browsing surface; registers nothing model-facing.' }, 'packages/client/ui-directory-picker-native': { kind: 'none', reason: 'Browser-side surface driving the host OS chooser; registers nothing model-facing.' }, 'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-sidebar-textpreview': { kind: 'none', reason: 'Browser-only text viewer; registers no tool, prompt section, or session event, and what the user reads never enters a model request.' }, + 'packages/client/ui-sidebar-files': { kind: 'none', reason: 'Browser-only workspace file tree; registers no tool, prompt section, or session event.' }, + 'packages/client/resources': { kind: 'none', reason: 'Browser-side resource model (providers, pinning, useResource); registers nothing model-facing.' }, 'packages/client/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-settings-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, @@ -173,6 +178,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/api/session-controller': { kind: 'none', reason: 'Session API and transport owner; invoked Agent commands own any model-visible effect.' }, 'packages/api/settings-controller': { kind: 'none', reason: 'Configuration-surface API owner; it registers no prompt, tool, or session event.' }, 'packages/api/workspace-controller': { kind: 'none', reason: 'Workspace API and state projection owner; it registers no prompt, tool, or session event.' }, + 'packages/api/workspace-files': { kind: 'none', reason: 'Workspace file read API and its Client resource provider; it registers no prompt, tool, or session event.' }, 'packages/typert/protocol': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers nothing model-facing.' }, 'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' }, 'packages/jobs/jobs': { kind: 'indirect', reason: 'Producer and controller plugins own all model rendering over the job registry.' }, diff --git a/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md index fb3437d266..02083de5c3 100644 --- a/snapshots/web/bash-abort-row/ui.expected.md +++ b/snapshots/web/bash-abort-row/ui.expected.md @@ -4,6 +4,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/cordis-tool-round/ui.expected.md b/snapshots/web/cordis-tool-round/ui.expected.md index 07b054429a..955c1ec28e 100644 --- a/snapshots/web/cordis-tool-round/ui.expected.md +++ b/snapshots/web/cordis-tool-round/ui.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/details-session-lifecycle/sidebar.expected.md b/snapshots/web/details-session-lifecycle/sidebar.expected.md new file mode 100644 index 0000000000..318b30c209 --- /dev/null +++ b/snapshots/web/details-session-lifecycle/sidebar.expected.md @@ -0,0 +1,507 @@ +# Recorded-session Sidebar states + +## A normal: two panes + +```json +{ + "viewport": [ + 1680, + 1000 + ], + "columns": [ + 280, + 644, + 756 + ], + "columnTransition": "grid-template-columns", + "expanded": true, + "mode": "push", + "panelContentWidth": 756, + "panelOuterWidth": 757, + "coversViewport": false, + "resizeHandleWidth": 8, + "expandedDirectories": [], + "panes": [ + { + "active": true, + "tabs": [ + { + "title": "Files", + "selected": true + }, + { + "title": "Start", + "selected": false + } + ] + }, + { + "active": false, + "tabs": [ + { + "title": "Start", + "selected": true + } + ] + } + ] +} +``` + +## A manual fullscreen: underlying columns retained + +```json +{ + "viewport": [ + 1680, + 1000 + ], + "columns": [ + 280, + 644, + 756 + ], + "columnTransition": "none", + "expanded": true, + "mode": "fullscreen", + "panelContentWidth": 1680, + "panelOuterWidth": 1680, + "coversViewport": true, + "resizeHandleWidth": 0, + "expandedDirectories": [], + "panes": [ + { + "active": true, + "tabs": [ + { + "title": "Files", + "selected": true + }, + { + "title": "Start", + "selected": false + } + ] + }, + { + "active": false, + "tabs": [ + { + "title": "Start", + "selected": true + } + ] + } + ] +} +``` + +## A closed with manual fullscreen retained + +```json +{ + "viewport": [ + 1680, + 1000 + ], + "columns": [ + 280, + 1400, + 0 + ], + "columnTransition": "none", + "expanded": false, + "mode": "fullscreen", + "panelContentWidth": 0, + "panelOuterWidth": 0, + "coversViewport": false, + "resizeHandleWidth": 0, + "expandedDirectories": [], + "panes": [ + { + "active": true, + "tabs": [ + { + "title": "Files", + "selected": true + }, + { + "title": "Start", + "selected": false + } + ] + }, + { + "active": false, + "tabs": [ + { + "title": "Start", + "selected": true + } + ] + } + ] +} +``` + +## B closed: independent pane and expanded workspace directory + +```json +{ + "viewport": [ + 1680, + 1000 + ], + "columns": [ + 280, + 1400, + 0 + ], + "columnTransition": "grid-template-columns", + "expanded": false, + "mode": "push", + "panelContentWidth": 0, + "panelOuterWidth": 0, + "coversViewport": false, + "resizeHandleWidth": 0, + "expandedDirectories": [ + "workspace" + ], + "panes": [ + { + "active": true, + "tabs": [ + { + "title": "Files", + "selected": true + } + ] + } + ] +} +``` + +## A restored: manual fullscreen, tabs, and panes + +```json +{ + "viewport": [ + 1680, + 1000 + ], + "columns": [ + 280, + 644, + 756 + ], + "columnTransition": "none", + "expanded": true, + "mode": "fullscreen", + "panelContentWidth": 1680, + "panelOuterWidth": 1680, + "coversViewport": true, + "resizeHandleWidth": 0, + "expandedDirectories": [], + "panes": [ + { + "active": true, + "tabs": [ + { + "title": "Files", + "selected": true + }, + { + "title": "Start", + "selected": false + } + ] + }, + { + "active": false, + "tabs": [ + { + "title": "Start", + "selected": true + } + ] + } + ] +} +``` + +## B restored: normal mode and Files directory state + +```json +{ + "viewport": [ + 1680, + 1000 + ], + "columns": [ + 280, + 644, + 756 + ], + "columnTransition": "grid-template-columns", + "expanded": true, + "mode": "push", + "panelContentWidth": 756, + "panelOuterWidth": 757, + "coversViewport": false, + "resizeHandleWidth": 8, + "expandedDirectories": [ + "workspace" + ], + "panes": [ + { + "active": true, + "tabs": [ + { + "title": "Files", + "selected": true + } + ] + } + ] +} +``` + +## A restored: expanded normal panel + +```json +{ + "viewport": [ + 1680, + 1000 + ], + "columns": [ + 280, + 644, + 756 + ], + "columnTransition": "grid-template-columns", + "expanded": true, + "mode": "push", + "panelContentWidth": 756, + "panelOuterWidth": 757, + "coversViewport": false, + "resizeHandleWidth": 8, + "expandedDirectories": [], + "panes": [ + { + "active": true, + "tabs": [ + { + "title": "Files", + "selected": true + }, + { + "title": "Start", + "selected": false + } + ] + }, + { + "active": false, + "tabs": [ + { + "title": "Start", + "selected": true + } + ] + } + ] +} +``` + +## A capacity-closed: wide left preference protected + +```json +{ + "viewport": [ + 1024, + 1000 + ], + "columns": [ + 420, + 604, + 0 + ], + "columnTransition": "grid-template-columns", + "expanded": false, + "mode": "push", + "panelContentWidth": 0, + "panelOuterWidth": 0, + "coversViewport": false, + "resizeHandleWidth": 0, + "expandedDirectories": [], + "panes": [ + { + "active": true, + "tabs": [ + { + "title": "Files", + "selected": true + }, + { + "title": "Start", + "selected": false + } + ] + }, + { + "active": false, + "tabs": [ + { + "title": "Start", + "selected": true + } + ] + } + ] +} +``` + +## A widened: remains closed + +```json +{ + "viewport": [ + 1680, + 1000 + ], + "columns": [ + 420, + 1260, + 0 + ], + "columnTransition": "grid-template-columns", + "expanded": false, + "mode": "push", + "panelContentWidth": 0, + "panelOuterWidth": 0, + "coversViewport": false, + "resizeHandleWidth": 0, + "expandedDirectories": [], + "panes": [ + { + "active": true, + "tabs": [ + { + "title": "Files", + "selected": true + }, + { + "title": "Start", + "selected": false + } + ] + }, + { + "active": false, + "tabs": [ + { + "title": "Start", + "selected": true + } + ] + } + ] +} +``` + +## A automatic fullscreen at 767px + +```json +{ + "viewport": [ + 767, + 1000 + ], + "columns": [ + 56, + 711, + 0 + ], + "columnTransition": "none", + "expanded": true, + "mode": "fullscreen", + "panelContentWidth": 767, + "panelOuterWidth": 767, + "coversViewport": true, + "resizeHandleWidth": 0, + "expandedDirectories": [], + "panes": [ + { + "active": true, + "tabs": [ + { + "title": "Files", + "selected": true + }, + { + "title": "Start", + "selected": false + } + ] + }, + { + "active": false, + "tabs": [ + { + "title": "Start", + "selected": true + } + ] + } + ] +} +``` + +## A automatic fullscreen exited: widening does not reopen + +```json +{ + "viewport": [ + 1680, + 1000 + ], + "columns": [ + 420, + 1260, + 0 + ], + "columnTransition": "grid-template-columns", + "expanded": false, + "mode": "push", + "panelContentWidth": 0, + "panelOuterWidth": 0, + "coversViewport": false, + "resizeHandleWidth": 0, + "expandedDirectories": [], + "panes": [ + { + "active": true, + "tabs": [ + { + "title": "Files", + "selected": true + }, + { + "title": "Start", + "selected": false + } + ] + }, + { + "active": false, + "tabs": [ + { + "title": "Start", + "selected": true + } + ] + } + ] +} +``` diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md index 4d6f0f24c0..f5ce4e137a 100644 --- a/snapshots/web/feedback-command/ack-expanded.expected.md +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index 7dfd7fe123..d702f94f3a 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md index 4d6f0f24c0..f5ce4e137a 100644 --- a/snapshots/web/feedback-release/ack-expanded.expected.md +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index 7dfd7fe123..d702f94f3a 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/file-upload-round/ui.expected.md b/snapshots/web/file-upload-round/ui.expected.md index 2541ec1c4a..0a8849c1d1 100644 --- a/snapshots/web/file-upload-round/ui.expected.md +++ b/snapshots/web/file-upload-round/ui.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md index b65f5d2ae3..70b8e58bf5 100644 --- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index f0449b34f2..2274d37a76 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index aeb9ecd7a9..f26864b10d 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index eeaf7d1cca..b26c923482 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/lifecycle-chrome/hero.expected.md b/snapshots/web/lifecycle-chrome/hero.expected.md index a4694918d4..400ac58929 100644 --- a/snapshots/web/lifecycle-chrome/hero.expected.md +++ b/snapshots/web/lifecycle-chrome/hero.expected.md @@ -40,6 +40,3 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: Details -- button "Close details" -- text: Click a tool row in the message flow to view its details diff --git a/snapshots/web/lifecycle-chrome/plan-active.expected.md b/snapshots/web/lifecycle-chrome/plan-active.expected.md index e886dd913e..ccb4e102e5 100644 --- a/snapshots/web/lifecycle-chrome/plan-active.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active.expected.md @@ -40,6 +40,3 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: Details -- button "Close details" -- text: Click a tool row in the message flow to view its details diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md index 6cde9ddcba..a2285c7a0d 100644 --- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index dff102b7cb..fb568b1c26 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/live-interactions/cancel-expanded.expected.md b/snapshots/web/live-interactions/cancel-expanded.expected.md index 635ddf6d9f..58a183ae56 100644 --- a/snapshots/web/live-interactions/cancel-expanded.expected.md +++ b/snapshots/web/live-interactions/cancel-expanded.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md index 84320a45d0..4598bb6f79 100644 --- a/snapshots/web/live-interactions/cancel.expected.md +++ b/snapshots/web/live-interactions/cancel.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/live-interactions/error-auth.expected.md b/snapshots/web/live-interactions/error-auth.expected.md index e5b3c6ca64..cf060b5584 100644 --- a/snapshots/web/live-interactions/error-auth.expected.md +++ b/snapshots/web/live-interactions/error-auth.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md index a16ba7d1ee..48a4ca5b5f 100644 --- a/snapshots/web/live-interactions/loading.expected.md +++ b/snapshots/web/live-interactions/loading.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/live-interactions/retry-exhausted.expected.md b/snapshots/web/live-interactions/retry-exhausted.expected.md index 237543a043..c093363f43 100644 --- a/snapshots/web/live-interactions/retry-exhausted.expected.md +++ b/snapshots/web/live-interactions/retry-exhausted.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md index 3919ce3dfc..9cfda2b0ae 100644 --- a/snapshots/web/live-interactions/retry-expanded.expected.md +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index 1cfe5382f5..08061e9718 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md index fa61237f73..9e1a1be89e 100644 --- a/snapshots/web/live-interactions/running-draft.expected.md +++ b/snapshots/web/live-interactions/running-draft.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index 3ca1911afc..9070601856 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -4,6 +4,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index 91a71e8e57..b1d4efd49f 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 58abbd47bd..0e7c1a3cda 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index cc7b07f86d..9b2fea933a 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/ptc-round/ui.expected.md b/snapshots/web/ptc-round/ui.expected.md index 1d2c9b9abd..f07f6b2837 100644 --- a/snapshots/web/ptc-round/ui.expected.md +++ b/snapshots/web/ptc-round/ui.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md index 34c5652e9a..aba4bedb58 100644 --- a/snapshots/web/question-composer/answered-expanded.expected.md +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index 1a879c04c5..a2f7298f6b 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/question-composer/cancelled.expected.md b/snapshots/web/question-composer/cancelled.expected.md index d6f6be3bba..4b48983c38 100644 --- a/snapshots/web/question-composer/cancelled.expected.md +++ b/snapshots/web/question-composer/cancelled.expected.md @@ -4,6 +4,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/queue-actions/collapsed.expected.md b/snapshots/web/queue-actions/collapsed.expected.md index 0b3e7b65b0..6645c331fd 100644 --- a/snapshots/web/queue-actions/collapsed.expected.md +++ b/snapshots/web/queue-actions/collapsed.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/queue-actions/editing.expected.md b/snapshots/web/queue-actions/editing.expected.md index f32a37c548..d0586fb967 100644 --- a/snapshots/web/queue-actions/editing.expected.md +++ b/snapshots/web/queue-actions/editing.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/queue-actions/layout.expected.md b/snapshots/web/queue-actions/layout.expected.md index 0e0835f2bb..79f239edd8 100644 --- a/snapshots/web/queue-actions/layout.expected.md +++ b/snapshots/web/queue-actions/layout.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/queue-actions/preserved-expanded.expected.md b/snapshots/web/queue-actions/preserved-expanded.expected.md index 041650fb7b..85489c97ef 100644 --- a/snapshots/web/queue-actions/preserved-expanded.expected.md +++ b/snapshots/web/queue-actions/preserved-expanded.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index e9a74f7b05..a4330098cc 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/queue-actions/sending.expected.md b/snapshots/web/queue-actions/sending.expected.md index 10115fe73b..c31dbd674a 100644 --- a/snapshots/web/queue-actions/sending.expected.md +++ b/snapshots/web/queue-actions/sending.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/queue-actions/ui.expected.md b/snapshots/web/queue-actions/ui.expected.md index e314229b31..e7aa55934d 100644 --- a/snapshots/web/queue-actions/ui.expected.md +++ b/snapshots/web/queue-actions/ui.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/queued-image/delivered.expected.md b/snapshots/web/queued-image/delivered.expected.md index 23fad54c88..7fdc60ffaa 100644 --- a/snapshots/web/queued-image/delivered.expected.md +++ b/snapshots/web/queued-image/delivered.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/queued-image/queued.expected.md b/snapshots/web/queued-image/queued.expected.md index 457affb944..68d4ef90b7 100644 --- a/snapshots/web/queued-image/queued.expected.md +++ b/snapshots/web/queued-image/queued.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 3e9691ff71..67adda5e9e 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -4,6 +4,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index 7ce1e515d4..d2ce9690cb 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -4,6 +4,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/seeded-history/file-open-failure.expected.md b/snapshots/web/seeded-history/file-open-failure.expected.md deleted file mode 100644 index fece247a7a..0000000000 --- a/snapshots/web/seeded-history/file-open-failure.expected.md +++ /dev/null @@ -1,7 +0,0 @@ -- dialog "Couldn’t open file": - - heading "Couldn’t open file" [level=2] - - button "Close": - - img - - paragraph: "path open failed: xdg-open is not available" - - button "Cancel" - - button "Retry" diff --git a/snapshots/web/seeded-history/file-preview.expected.md b/snapshots/web/seeded-history/file-preview.expected.md new file mode 100644 index 0000000000..531cfe6e27 --- /dev/null +++ b/snapshots/web/seeded-history/file-preview.expected.md @@ -0,0 +1,6 @@ +- text: {{cwd}}/a.txt +- button "Wrap lines" [pressed]: + - img +- button "Read the file again": + - img +- text: alpha diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md index 70c6204338..84c770538d 100644 --- a/snapshots/web/seeded-history/ui-expanded.expected.md +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -4,6 +4,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index 7ea48e69f6..ed9567ae05 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -4,6 +4,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index 74903e7e6e..8f739e02c1 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -4,6 +4,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/steering/mid-steer.expected.md b/snapshots/web/steering/mid-steer.expected.md index 557e5bc0ff..94b2356941 100644 --- a/snapshots/web/steering/mid-steer.expected.md +++ b/snapshots/web/steering/mid-steer.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md index 5c2738c768..dbf54a6910 100644 --- a/snapshots/web/steering/settled-expanded.expected.md +++ b/snapshots/web/steering/settled-expanded.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index 237bab0dfc..d84e7d053c 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/subagent-conversation/nested.expected.md b/snapshots/web/subagent-conversation/nested.expected.md index e5d8498557..1ed0fc0b02 100644 --- a/snapshots/web/subagent-conversation/nested.expected.md +++ b/snapshots/web/subagent-conversation/nested.expected.md @@ -8,6 +8,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index 37e05fa0d0..a31c527d2a 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -11,6 +11,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index f4f25c4224..887f0a722b 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -11,6 +11,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/subagent-interrupt/offline-composer.expected.md b/snapshots/web/subagent-interrupt/offline-composer.expected.md index b34acde2d7..e7f7483bc3 100644 --- a/snapshots/web/subagent-interrupt/offline-composer.expected.md +++ b/snapshots/web/subagent-interrupt/offline-composer.expected.md @@ -8,6 +8,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md index ed25b8d556..4ef04fc9a1 100644 --- a/snapshots/web/turn-tail-actions/completed.expected.md +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md index 0c2c806aad..fc39e90660 100644 --- a/snapshots/web/turn-tail-actions/focused.expected.md +++ b/snapshots/web/turn-tail-actions/focused.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md index 502147691d..5808186525 100644 --- a/snapshots/web/turn-tail-actions/running.expected.md +++ b/snapshots/web/turn-tail-actions/running.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index 5aff82d1a1..2552b536b2 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index a01ee4fd78..92686f6691 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index 5ab42e660c..8dc1f0698c 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -6,6 +6,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/tsconfig.base.json b/tsconfig.base.json index d3900f6cba..04c09303a1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -154,6 +154,18 @@ "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"], "@deepseek-ai/dsh-client-ui-attachment": ["./packages/client/ui-attachment/src"], "@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"], + "@deepseek-ai/dsh-client-ui-dockkit": ["./packages/client/ui-dockkit/src"], + "@deepseek-ai/dsh-client-ui-sidebar-right": ["./packages/client/ui-sidebar-right/src"], + "@deepseek-ai/dsh-client-ui-sidebar-right/client": ["./packages/client/ui-sidebar-right/src/client"], + "@deepseek-ai/dsh-client-ui-sidebar-textpreview": ["./packages/client/ui-sidebar-textpreview/src"], + "@deepseek-ai/dsh-client-ui-sidebar-textpreview/client": ["./packages/client/ui-sidebar-textpreview/src/client"], + "@deepseek-ai/dsh-api-workspace-files": ["./packages/api/workspace-files/src"], + "@deepseek-ai/dsh-api-workspace-files/types": ["./packages/api/workspace-files/src/types.ts"], + "@deepseek-ai/dsh-api-workspace-files/client": ["./packages/api/workspace-files/src/client/index.ts"], + "@deepseek-ai/dsh-client-resources": ["./packages/client/resources/src"], + "@deepseek-ai/dsh-client-resources/client": ["./packages/client/resources/src/client"], + "@deepseek-ai/dsh-client-ui-sidebar-files": ["./packages/client/ui-sidebar-files/src"], + "@deepseek-ai/dsh-client-ui-sidebar-files/client": ["./packages/client/ui-sidebar-files/src/client"], "@deepseek-ai/dsh-client-store": ["./packages/client/store/src/index.ts"], "@deepseek-ai/dsh-client-ui-renderer": ["./packages/client/ui-renderer/src"], "@deepseek-ai/dsh-client-ui-renderer/client": ["./packages/client/ui-renderer/src/client"], diff --git a/tsconfig.client.json b/tsconfig.client.json index b313547c81..b9a2a55382 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -84,6 +84,10 @@ { "path": "./packages/client/ui-reference" }, { "path": "./packages/client/ui-subagent" }, { "path": "./packages/experimental/client-ui-agent-team" }, + { "path": "./packages/client/ui-dockkit" }, + { "path": "./packages/client/ui-sidebar-right" }, + { "path": "./packages/client/ui-sidebar-textpreview" }, + { "path": "./packages/client/ui-sidebar-files" }, { "path": "./packages/client/ui-jobs" }, { "path": "./packages/client/ui-schedule" }, { "path": "./packages/client/ui-directory-picker-browse" }, @@ -107,6 +111,8 @@ { "path": "./packages/client/locale" }, { "path": "./packages/client/ui-renderer" }, { "path": "./packages/client/ui-session" }, + { "path": "./packages/client/resources" }, + { "path": "./packages/api/workspace-files/tsconfig.client.json" }, { "path": "./packages/client/web" }, { "path": "./packages/context/file-reference" }, { "path": "./apps/web" } diff --git a/tsconfig.host.json b/tsconfig.host.json index 4432540b06..1c68bd9d0b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -89,6 +89,7 @@ "apps/web/tests/turn-tail-actions.e2e.ts", "apps/web/tests/goal-multi-turn-actions.e2e.ts", "apps/web/tests/agent-team-panel.e2e.ts", + "apps/web/tests/sidebar-right.e2e.ts", "apps/web/tests/chat-scroll-fixture.ts", "apps/web/tests/chat-scroll-contract.e2e.ts", "apps/web/tests/chat-long-interactions.e2e.ts", @@ -99,6 +100,8 @@ "apps/web/tests/workflow-run.e2e.ts", "apps/web/stress-tests/reasoning-chunks.stress.ts", "apps/cli/tests/**/*.ts", + "apps/desktop/scripts/**/*.ts", + "apps/desktop/tests/**/*.ts", "benchmarks/**/*.ts", "packages/*/*/tests/**/*.ts", "scripts/**/*.ts", @@ -160,6 +163,7 @@ { "path": "./packages/api/gateway/tsconfig.host.json" }, { "path": "./packages/api/remotes/tsconfig.host.json" }, { "path": "./packages/api/session-controller/tsconfig.host.json" }, + { "path": "./packages/api/workspace-files/tsconfig.host.json" }, { "path": "./packages/api/settings-controller" }, { "path": "./packages/api/workspace-controller/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, @@ -342,6 +346,8 @@ { "path": "./packages/lsp/lsp" }, { "path": "./packages/lsp/lsp-stdio" }, { "path": "./packages/lsp/tool-lsp" }, - { "path": "./apps/cli" } + { "path": "./apps/cli" }, + { "path": "./apps/desktop-host" }, + { "path": "./apps/desktop" } ] } diff --git a/tsdown.config.ts b/tsdown.config.ts index 5a0fcc8c78..2f24d4910e 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -16,7 +16,9 @@ function isBuildFaceClient(value: unknown): boolean { export default defineConfig(({ env }) => { const client = isBuildFaceClient(env?.DSH_BUILD_FACE) return { - workspace: ['vendor/*', 'packages/*/*', 'apps/cli'], + workspace: client + ? ['vendor/*', 'packages/*/*', 'apps/cli'] + : ['vendor/*', 'packages/*/*', 'apps/cli', 'apps/desktop', 'apps/desktop-host'], entry: client ? '' : ['lib/types/{index,invariant,startup}.js'], outDir: 'lib', format: ['esm'], diff --git a/website/docs.ts b/website/docs.ts index 3a54d64cc3..c9823653d9 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -347,6 +347,8 @@ const subsystemGroups = [ ['web-client.md', 'Web Client 架构', 'Web Client architecture'], ['client-modules.md', '客户端模块', 'Client modules'], ['slots.md', '客户端 Slots', 'Client slots'], + ['client-resources.md', '客户端资源', 'Client resources'], + ['sidebar-right.md', '右侧 Sidebar', 'Right Sidebar'], ['conversation.md', 'Conversation 组装', 'Conversation assembly'], ['typert.md', 'Typert', 'Typert'], ['storage.md', '存储', 'Storage'],