diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml index 0ed8f5d7a4..eacbd89847 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.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 -2026-07-22-slot-type-chain-implementation.md: 65b4ebb475fe34d71d8d3a08878b40103b3c95bd -2026-07-22-slot-type-chain-implementation.zh.md: 4c55171ca0118782568e17f349f83d6cf9211617 +2026-07-22-slot-type-chain-implementation.md: 617524475f3da8af5d281efcfe8f79d500f31be8 +2026-07-22-slot-type-chain-implementation.zh.md: 52edea30acea5989b3438cbcf4688df5a897f099 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md index 65b4ebb475..617524475f 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md @@ -42,7 +42,7 @@ Parity rule: **the declaring entry holds the exclusive right to render its child | Share | Type | Source of truth | Contents | |---|---|---|---| -| runtime | `PropsRuntime` | SlotMap entry for K | `OwnerOf` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions` | +| runtime | `PropsRuntime` | SlotMap entry for K | `OwnerOf` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions`/`useWorkspaces` | | child render | `PropsRenderSlots` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` | | store | `PropsStore` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) | | business | `I` | inject return type | plain data + callbacks (hooks banned) | @@ -84,7 +84,7 @@ An inject factory takes what its declarations earn it — `sessionId` for sessio ### Data-boundary discipline -Hooks are framework-made only: `useSession`, `useSessions`, `useStore`, `renderSlot` are the four seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own. +Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own. ### Tree context and the renderer seam diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md index 4c55171ca0..52edea30ac 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md @@ -42,7 +42,7 @@ ctx.slots.register({ | 份额 | 类型 | 真源 | 内容 | |---|---|---|---| -| 运行时 | `PropsRuntime` | K 对应的 SlotMap entry | `OwnerOf`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions` | +| 运行时 | `PropsRuntime` | K 对应的 SlotMap entry | `OwnerOf`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions`/`useWorkspaces` | | 子坑渲染 | `PropsRenderSlots` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` | | store | `PropsStore` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) | | 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) | @@ -84,7 +84,7 @@ inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`, ### 数据界线纪律 -hook 只许框架造:`useSession`、`useSessions`、`useStore`、`renderSlot` 是仅有的四席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。 +hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 是仅有的五席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。 ### 树上语境与渲染器安装缝 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 795cb97082..b5945b5342 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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 -2026-07-24-web-config-tree-boot-and-transport-layering.md: 9e93b828d5f11060aa476396f6981320c33485a5 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 996a5705bd5d00a2163a146ef8210247f512e6fa +2026-07-24-web-config-tree-boot-and-transport-layering.md: 377ebd2b3cf9ff1dff81dd3546bb262e0ebde88a +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 403e95fb3088d2164d50710d2a69de5526807764 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 9e93b828d5..377ebd2b3c 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -18,14 +18,14 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) **Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config. -**The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from runtime (dependency direction allows it; runtime keeps `bootHost`/`startHost` for headless). `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route. +**The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from the retired runtime package. `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route. **Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`. ## Consequences - Recomposing a web deployment is a yml/patch edit; the retired pieces (`mountWebPlugins`, `CLIENT_PACKAGES`, `createHostWebPluginRegistry`, `startWebServer`, the webserver's graph/SSE/api knowledge) are deleted. -- Headless still boots through `bootHost` (unchanged this round); its migration, the profile write path, the `$DSH_HOME` profile relocation, and IPC carriers are recorded deferrals in the design ledger. +- Headless boots the same composition through the same entry (landed in the stacked follow-up): port 0 is its only surface difference, the model face gains `ask_user_question`/workspace context/model titles per the unification ruling, and `bootHost`/`startHost` retired with the `dsh-host-runtime` package. The profile write path, the `$DSH_HOME` profile relocation, and IPC carriers remain recorded deferrals. - A TypeScript pitfall worth remembering: a `declare module 'cordis'` augmentation in a file with **no cordis import** is demoted to a standalone module declaration and silently shatters the program-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import type {} from 'cordis'`. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index 996a5705bd..403e95fb30 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -18,14 +18,14 @@ Status: implemented **每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。 -**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 从 runtime 迁入(依赖方向允许;runtime 保留 `bootHost`/`startHost` 供 headless)。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包元数据含否定结论永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/plugins/events` SSE 路由。 +**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 自已退役的 runtime 包迁入。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包元数据含否定结论永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/plugins/events` SSE 路由。 **包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设特设子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读槽(缺槽大声抛)并 provide `ctx.modules`。 ## 后果 - 重组一个 web 部署 = 改 yml/patch;退役件(`mountWebPlugins`、`CLIENT_PACKAGES`、`createHostWebPluginRegistry`、`startWebServer`、webserver 的图/SSE/api 知识)全部删除。 -- headless 本轮仍走 `bootHost`;它的迁移、profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体,均为设计台账中的挂账项。 +- headless 已在 stacked 后续轮迁入同一组合同一入口:唯一面差异是 port 0,模型面按统一裁决获得 `ask_user_question`/workspace context/模型标题,`bootHost`/`startHost` 随 `dsh-host-runtime` 包退役。profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体仍为挂账项。 - 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge(`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。 ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml index bf64ecf4d3..9087db9dd7 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.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 -2026-07-24-model-facing-session-query-tools.md: f05f9792619155070ec1c5c721702d7ed94442ce -2026-07-24-model-facing-session-query-tools.zh.md: 2570dda4b3ff22ff26e12e2f9d1418d76f079f5c +2026-07-24-model-facing-session-query-tools.md: 2a9a20a8b39dea309e759f4eb6ddcdabe25dd8be +2026-07-24-model-facing-session-query-tools.zh.md: 7fbe746681b329b3e50ae74608a8b2e5167c5ae9 diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md index f05f979261..2a9a20a8b3 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -36,7 +36,7 @@ Session-level results include the latest folded title when available. Each tool ## Host composition -The consumer is an opt-in plugin. The shipped ACP, TUI, and Web compositions mount `ctx.sessionQuery` for their non-model consumers but do not mount `@deepseek-ai/dsh-tool-session-query`, so their default model requests gain no query prompt or schemas. A composition that opts in also chooses whether to mount the generic timeout and spill policies; the dedicated ACP snapshot fixture mounts both and uses private local spill storage. Generic tool presentation requires no session-query-specific client plugin. +The consumer is an opt-in plugin. The shipped ACP and TUI apps mount `ctx.sessionQuery` for non-model consumers, while the shared Web/headless composition mounts neither the query service nor the consumer. No shipped composition mounts `@deepseek-ai/dsh-tool-session-query`, so default model requests gain no query prompt or schemas. A composition that opts in also chooses whether to mount the generic timeout and spill policies; the dedicated ACP snapshot fixture mounts both and uses private local spill storage. Generic tool presentation requires no session-query-specific client plugin. ## Alternatives considered @@ -48,7 +48,7 @@ The consumer is an opt-in plugin. The shipped ACP, TUI, and Web compositions mou ## Verification -Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, parent-filter preauthorization and oracle resistance, changed-observation rejection, service-diagnostic redaction for ordinary and adversarial unknown values, best-effort cyclic-cause logging, logger-failure containment, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Default-host tests and assembled request-header snapshots prove that the model-facing consumer remains absent while `ctx.sessionQuery` stays available. A package-owned Loader smoke and dedicated keyless ACP snapshot explicitly mount the consumer with timeout and spill support, pinning its prompt guidance, schemas, and path-independent exact event-read retention behavior. +Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, parent-filter preauthorization and oracle resistance, changed-observation rejection, service-diagnostic redaction for ordinary and adversarial unknown values, best-effort cyclic-cause logging, logger-failure containment, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Shipped configuration, app composition tests, and assembled ACP request-header snapshots prove that the model-facing consumer remains absent while `ctx.sessionQuery` stays available where mounted. A package-owned Loader smoke and dedicated keyless ACP snapshot explicitly mount the consumer with timeout and spill support, pinning its prompt guidance, schemas, and path-independent exact event-read retention behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md index 2570dda4b3..7fbe746681 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -36,7 +36,7 @@ Status: implemented ## 宿主组合 -该消费方是一个需显式启用的插件。发布的 ACP、TUI 与 Web 组合为其非模型消费方挂载 `ctx.sessionQuery`,但不挂载 `@deepseek-ai/dsh-tool-session-query`,因此其默认模型请求中不包含查询提示词或 schema。选择启用该插件的组合还要决定是否挂载通用的超时与 spill 策略;专用的 ACP 快照 fixture(测试前置数据)同时挂载这两项策略,并使用私有的本地 spill 存储。通用工具表现无需会话查询专用客户端插件。 +该消费方是一个需显式启用的插件。发布的 ACP 与 TUI 应用为非模型消费方挂载 `ctx.sessionQuery`,而 Web/headless 共享组合既不挂载查询服务,也不挂载该消费方。发布的组合均未挂载 `@deepseek-ai/dsh-tool-session-query`,因此默认模型请求中不包含查询提示词或 schema。选择启用该插件的组合还要决定是否挂载通用的超时与 spill 策略;专用的 ACP 快照 fixture(测试前置数据)同时挂载这两项策略,并使用私有的本地 spill 存储。通用工具表现无需会话查询专用客户端插件。 ## 考虑过的替代方案 @@ -48,7 +48,7 @@ Status: implemented ## 验证 -包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、父级过滤预授权与抵御预言机探测、变更观测拒绝、普通值与对抗性未知值的服务诊断脱敏、尽力记录循环 cause、日志失败隔离、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。默认宿主测试与组装后的请求头快照证明:面向模型的消费方仍未挂载,而 `ctx.sessionQuery` 保持可用。包自身的 Loader 冒烟测试与专用无密钥 ACP 快照显式挂载该消费方,并配套启用超时与 spill 支持,固定其提示词指引、schema 以及与路径无关的精确事件读取保留行为。 +包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、父级过滤预授权与抵御预言机探测、变更观测拒绝、普通值与对抗性未知值的服务诊断脱敏、尽力记录循环 cause、日志失败隔离、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。发布配置、应用组合测试与组装后的 ACP 请求头快照证明:面向模型的消费方仍未挂载,而 `ctx.sessionQuery` 在已经挂载该服务的组合中保持可用。包自身的 Loader 冒烟测试与专用无密钥 ACP 快照显式挂载该消费方,并配套启用超时与 spill 支持,固定其提示词指引、schema 以及与路径无关的精确事件读取保留行为。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml new file mode 100644 index 0000000000..3295a845f3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.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 +2026-07-25-workspace-ui-product-flow.md: a02087235a36f2c257de407facf2dc02ed072f3b +2026-07-25-workspace-ui-product-flow.zh.md: 8ccbf5b98401bef9c3fd40e948d35ec5f0818202 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md new file mode 100644 index 0000000000..a02087235a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -0,0 +1,117 @@ +# Agent Note: Workspace UI Complete Product Flow + +Status: implemented + +English | [中文](2026-07-25-workspace-ui-product-flow.zh.md) + +## Problem + +[Domain KV Storage and the Workspace Entity](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) defines the persistent Workspace entity, path conventions, and ordered Session ledger, but not the Host wiring, historical-data initialization, or GUI flow. The GUI presents both Workspaces and Sessions; users must be able to type immediately after entering New Session, even when no Host Session or Host Workspace exists yet. + +Pending Workspaces, pending Sessions, retained input, and Host entity publication need clear owners and must preserve the same page identity when RPC completions and Host frames arrive in either order. Eagerly creating a Host Session for the zero state would bring a page with no input into the Host lifecycle. Historical Sessions also expose only the lightweight `SessionHeader.cwd` for grouping; initialization cannot read event bodies. + +## Decision + +### Host and persistent data + +The Host provides the following GUI wiring on the Workspace entity: + +| RPC | Behavior | +| --- | --- | +| `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | +| `workspace.create({ name })` | Creates a directory and Workspace at `workspaceRoot/name`; fails on a display-name conflict | +| `workspace.create({ path })` | Adopts an existing directory and does not create an arbitrary path | +| `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | +| `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | + +`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. + +A Workspace's `sessionIds` is an ordered candidate index. A membership projection requires both that an id appear in the index and that the corresponding canonicalized `SessionHeader.cwd` equal the Workspace path; SessionHeader does not gain a `workspaceId`. A Session whose cwd matches but whose id is absent from the index remains Ungrouped, while an indexed id is filtered out if its header is missing, its cwd is invalid, or its cwd does not match. Two Workspace indexes claiming the same Session is corrupt state and fails loudly. + +The Workspace domain uses a durable marker to distinguish “never initialized” from “initialized but empty.” When the marker is absent, the Registry calls only `SessionPersistence.list()` to read header metadata; it calls neither `load` nor `inspect`, reads no history, and parses no event bodies. Valid cwd values are grouped by canonical path, and both Sessions within each group and the Workspace groups themselves are initialized in descending header `createdAt` order. Bootstrap is reentrant and writes the marker last; after the marker is written, new Sessions created without `workspaceId` are no longer adopted automatically. + +### Client object model + +`Session` and `Workspace` are frontend objects from the page Intent stage onward. + +- A frontend Session preallocates a SessionId when created and owns its Intent target and `pendingPrompt`; it remains the same Session object after Host `session.create` succeeds. +- Before materialization, a frontend Workspace has no WorkspaceId and owns its create input, phase, and error; after Host `workspace.create` succeeds, the same Workspace object adopts the returned view. +- `SessionManager` and `WorkspaceManager` own object indexes and merge Host baselines and deltas; the objects are the sole source of state for both Intents and Host views. +- `SessionsService` provides Session objects, real selection, scope, and list projections; `WorkspacesService` depends on `SessionsService` and owns the default Workspace, cross-object New Session flow, and Workspace materialization. + +A page has at most one frontend Session Intent and one accompanying Workspace Intent that exists only in the zero-Workspace state. Intents exist only on the current page and disappear on refresh; real Session selection can be restored persistently. Selecting a real Session or starting another Session Intent revokes the old Intent's eligibility for automatic sending, but does not roll back a Session already published by the Host or any accepted message. + +The Session owns the first input and drives one internal pipeline: when necessary, it attaches to a Workspace with its preallocated id, then sends `pendingPrompt`. Both attach and send failures return to the same Session. Workspace creation phase and error belong only to the Workspace object; the Session does not simulate the Workspace lifecycle. + +### User flow + +On initial entry, the application waits until both the Workspace and Session baselines are ready. It restores a real Session selection that remains valid; otherwise, it enters New Session and selects the most recent Workspace exactly once. The most recent Workspace is determined by the maximum `updatedAt` of its member Sessions, falling back to `createdAt` for an empty Workspace. This derived value chooses only the default target: it does not alter the Host Workspace order or trigger another selection after later hydration. + +When no Workspace exists, the page creates a frontend Workspace object named `workspace` and a frontend Session that targets it. Neither writes to the Host, and the composer always accepts input; the first send materializes the Workspace, attaches the Session, and sends the message in that order. + +Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's Use an existing folder and Create a new workspace actions immediately create a real Workspace when the user confirms, then retarget the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. + +Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Rename, Delete, moving across Workspaces, drag-and-drop ordering, manual adoption from Ungrouped, and separate display-name and directory-name inputs are outside this iteration's scope. + +### First send and recovery + +A frontend Session's `pendingPrompt` retains its original text until the Host accepts the message. The first send advances through Workspace materialization, Session attachment, and prompt sending in order: + +1. If Workspace creation fails, the Workspace Intent retains its input and error, and the Session continues to target that object. +2. If Session creation fails before publication, the Session Intent returns to an editable state and retries with the same preallocated SessionId. +3. `workspace-attach-failed` proves that the Session has been published; the same Session object enters the real list and retains the prompt, and subsequent retries attach it. +4. If the prompt fails, the Session retains it and retries only send without recreating the Workspace or Session. +5. If the page switches to another Intent while a Session is being created, the old Session does not send automatically even if it is subsequently published; it retains its original prompt and visible error. + +Lost RPC responses, Host frames arriving before completions, and completions arriving before Host frames all converge through the preallocated SessionId and object identity. The Manager performs ordered upserts of Host views and prioritizes preserving the original object identity during local materialization, rather than creating a temporary second row with the same id. + +### Sidebar and ordering + +Workspace groups strictly follow the persistent order returned by the Host. Bootstrap determines the historical order once, explicitly created Workspaces are placed first, and Session activity does not move Workspace groups. + +Within each group, order strictly follows `Workspace.sessionIds`. A newly attached Session is placed first; when a Session later becomes active, the Host moves only that id to the front and persists the change. The Client does not reorder the entire group by time after the Session list arrives, so it never displays one Workspace order and then jumps to another during hydration. + +A frontend Session Intent appears as a “New session” row and temporarily counts toward the group's Session total only when it targets a real Workspace. When it targets a Workspace Intent, neither the Workspace nor the Session appears in the sidebar. After the Intent is published, the real row with the same preallocated id takes its place; after refresh, both the Intent row and temporary count disappear. Search mode neither retains nor filters Intent rows. + +Real Sessions that cannot be assigned to any Workspace appear under Ungrouped. Host `session-added` and `workspace-changed` events may arrive in either order; list merging does not depend on frame order. + +### React and slot boundaries + +React components only consume `useSessions`, `useWorkspaces`, and session-scoped hooks; they do not own entity lifecycles. The Zustand store retains only layout, the current view, composer text for ordinary real Sessions, and other purely presentational state. Session and Workspace Intents, materialization phases, errors, and retained prompts reside in the React-free runtime object layer. + +The Sidebar and conversation empty hero receive standardized actions through slots: `startSession`, `updateSessionPrompt`, `sendSession`, `open`, and `toggleSidebar`. The Workspace picker reuses the same component and the `createWorkspace` seam; its owner supplies only popover state, an anchor, and a selection callback. The presentation layer does not send `host/workspace-changed` directly; Host events originate only from Host mutations and the stream adapter. + +## Alternatives considered + +**Store separate page records for pending Workspaces and Sessions.** This approach must replace identities after materialization and hand off input, errors, focus, and sidebar rows; Intent state owned by the objects preserves identity continuity. + +**Let the presentation layer or root Zustand store orchestrate object lifecycles.** This approach duplicates Manager and Service responsibilities and brings domain state back into React. Runtime services provide standardized actions, while slots inject only the narrow interfaces required by presentation. + +**Immediately create a Host Session or Host persistence intent in the zero state.** A page with no input would enter the Host lifecycle and change refresh semantics; before the first send, the frontend Session retains only a page-local Intent. + +**Delay an explicit Create Workspace until the first send.** After confirmation, the sidebar would still show no real empty Workspace, conflating “create a Workspace” with “prepare a Session”; only the zero-Workspace Intent generated automatically by the system delays materialization. + +**Continuously derive Workspaces dynamically from cwd.** This cannot represent empty Workspaces, stable display names, or explicit ordering, and would automatically adopt non-Workspace callers; cwd is used only for one historical bootstrap and bidirectional membership validation. + +**Have the Client batch-reorder by time after the Session list arrives.** The initial screen would first show the Host order and then jump as a whole, and reconnecting could change positions again; the Host's persistent ledger owns ordering, while the Client merges only individual updates. + +**Add workspaceId to SessionHeader.** This would create two persistent ownership fields alongside the Workspace index and require double writes; the header retains the Session's own cwd fact, while the Workspace index owns explicit membership. + +## Verification + +- The zero state with no Workspace writes nothing to the Host and accepts input; explicit Create Workspace immediately creates and displays an empty Workspace. +- Frontend Sessions and Workspaces preserve object identity across materialization; input, errors, focus, and sidebar projections always originate from the object layer. +- The first send advances through Workspace, Session, and prompt in order; successful stages are not rolled back, input is not lost before the prompt is accepted, and creation retries use the same SessionId. +- Workspace list performs one reentrant bootstrap using only headers; an initialized empty registry does not initialize again after restart, and membership reads validate both the index and canonical cwd. +- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. +- A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. +- Both the UI and Host reject duplicate Workspace names; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. +- Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. + +## Consequences + +- SessionHeader does not record last-active time, so historical bootstrap can initialize order only by `createdAt`; real Session activity events move individual entries afterward. +- Historical Sessions with a missing cwd, an invalid directory, or a failed realpath remain Ungrouped; this iteration has no manual-adoption entry point. +- Refreshing the page discards unmaterialized Workspace and Session Intents and input not yet accepted by the Host; this is the page-local contract. +- Explicit Create Workspace writes to disk immediately, so leaving without sending still leaves an empty Workspace. +- Before its first event, a Host Session retains the existing lazy-persistence semantics; frontend Intents do not change empty-Session behavior after a Host restart. diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md new file mode 100644 index 0000000000..8ccbf5b984 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -0,0 +1,117 @@ +# Agent Note: Workspace UI 完整产品动线 + +[English](2026-07-25-workspace-ui-product-flow.md) | 中文 + +Status: implemented + +## Problem + +[Domain KV storage 与 Workspace entity](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)定义了 Workspace 的持久实体、路径规范和有序 Session 账本,但没有定义 Host 接线、历史数据初始化或 GUI 动线。GUI 同时呈现 Workspace 和 Session;用户进入 New Session 后必须立即输入,即使此时还没有 Host Session,甚至没有 Host Workspace。 + +待创建 Workspace、待创建 Session、输入保留与 Host 实体发布必须具有明确所有者,并在 RPC completion 与 Host frame 以任意顺序到达时保持同一页面身份。若零态提前创建 Host Session,则无输入的页面状态会进入 Host 生命周期。历史 Session 又只有轻量 `SessionHeader.cwd` 可用于归组,初始化不能读取事件正文。 + +## Decision + +### Host 与持久数据 + +Host 在 Workspace entity 上提供以下 GUI 接线: + +| RPC | 行为 | +| --- | --- | +| `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | +| `workspace.create({ name })` | 在 `workspaceRoot/name` 创建目录和 Workspace;显示名冲突时失败 | +| `workspace.create({ path })` | 收编已经存在的目录,不为任意路径创建目录 | +| `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | +| `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | + +`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。 + +Workspace 的 `sessionIds` 是有序候选索引。成员投影同时要求 id 位于索引且对应 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 保持 Ungrouped,索引命中但 header 缺失、cwd 无效或 cwd 不匹配的 id 被过滤。同一 Session 被两个 Workspace 索引占用属于损坏状态并 fail loud。 + +Workspace domain 以 durable marker 区分“从未初始化”和“已初始化但为空”。marker 未设置时,Registry 只调用 `SessionPersistence.list()` 读取 header 元数据,不调用 `load`、`inspect`、history 或解析事件正文;有效 cwd 按 canonical path 分组,组内 Session 与 Workspace 组均按 header `createdAt` 降序初始化。Bootstrap 可重入,最后才写 marker;marker 写入后,绕过 `workspaceId` 的新 Session 不再被自动收编。 + +### Client 对象模型 + +`Session` 与 `Workspace` 从页面 Intent 阶段开始就是前端对象。 + +- 前端 Session 创建时预分配 SessionId,并在对象内持有 Intent target 与 `pendingPrompt`;Host `session.create` 成功后仍是同一个 Session 对象。 +- 前端 Workspace 在 materialize 前没有 WorkspaceId,并在对象内持有 create input、phase 与 error;Host `workspace.create` 成功后同一个 Workspace 对象 adopt 返回的 view。 +- `SessionManager` 与 `WorkspaceManager` 负责对象索引、Host 基线和增量合并;对象是 Intent 与 Host view 的唯一状态源。 +- `SessionsService` 提供 Session 对象、真实 selection、scope 与列表投影;`WorkspacesService` 依赖 `SessionsService`,负责默认 Workspace、跨对象 New Session 动线和 Workspace materialize。 + +页面至多有一个前端 Session Intent 和一个仅在零 Workspace 状态下配套的 Workspace Intent。Intent 只存在于当前页面,刷新后消失;真实 Session selection 可以持久恢复。选择真实 Session 或启动另一个 Session Intent 会放弃旧 Intent 的自动发送资格,但已经由 Host 发布的 Session 和已经接受的消息不会回滚。 + +Session 自己持有首条输入并驱动一条内部流水线:必要时以预分配 id attach 到 Workspace,然后发送 `pendingPrompt`。attach 与 send 的失败都落回同一 Session。Workspace 创建 phase/error 只属于 Workspace 对象,Session 不模拟 Workspace 生命周期。 + +### 用户动线 + +应用首次进入时等待 Workspace 与 Session 两份基线 ready。仍有效的真实 Session selection 被恢复;否则进入 New Session,并固定选择一次最近 Workspace。最近 Workspace 取其成员 Session 的最大 `updatedAt`,空 Workspace 回退到 `createdAt`;该派生只决定默认目标,不改变 Host Workspace 顺序,也不会在后续 hydration 时二次改选。 + +完全没有 Workspace 时,页面创建默认名为 `workspace` 的前端 Workspace 对象和指向它的前端 Session。两者不写 Host,composer 始终可输入;首次发送才依次 materialize Workspace、attach Session、发送消息。 + +顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的 Use an existing folder 与 Create a new workspace 会在用户确认时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 + +Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认,Host 继续拒绝绕过 UI 或并发产生的同名请求。Rename、Delete、跨 Workspace 移动、拖拽排序、Ungrouped 手动收编和显示名/目录名双输入不在本期范围。 + +### 首次发送与恢复 + +前端 Session 的 `pendingPrompt` 在 Host 接受消息前始终保留原文。首次发送按 Workspace materialize、Session attach、prompt send 顺序推进: + +1. Workspace 创建失败时,Workspace Intent 保留输入与错误,Session 仍指向该对象。 +2. Session 创建在发布前失败时,Session Intent 回到可编辑状态,以同一预分配 SessionId 重试。 +3. `workspace-attach-failed` 证明 Session 已发布;同一 Session 对象进入真实列表并保留 prompt,后续重试 attach。 +4. prompt 失败时,Session 保留 prompt 并只重试 send,不重复创建 Workspace 或 Session。 +5. Session 创建期间若页面切换到另一个 Intent,旧 Session 即使随后发布也不自动发送;它保留原 prompt 和可见错误。 + +RPC lost response、Host frame 先于 completion 和 completion 先于 Host frame 都通过预分配 SessionId 与对象身份收敛。Manager 对 Host view 做有序 upsert,本地 materialize 时优先保留原对象身份,不生成同 id 的临时第二行。 + +### Sidebar 与排序 + +Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放在首位;Session 活跃不会移动 Workspace 组。 + +组内严格使用 `Workspace.sessionIds`。新 attach 的 Session 放在首位,后续某个 Session 活跃时 Host 只前移该 id 并持久化。Client 不在 Session list 到达后按时间整体重排,因此不会先显示一套 Workspace 顺序再因 hydration 瞬间跳动。 + +前端 Session Intent 只有在目标是真实 Workspace 时才作为 “New session” 行显示,并临时计入该组 Session 数量;目标是 Workspace Intent 时,Workspace 与 Session 都不进入 sidebar。Intent 发布后由同一预分配 id 对应的真实行接替,刷新后 Intent 行和临时计数一起消失。搜索模式不保存或筛选 Intent 行。 + +无法归入任何 Workspace 的真实 Session 进入 Ungrouped。Host `session-added` 与 `workspace-changed` 可以任意顺序到达,列表合并不依赖 frame 顺序。 + +### React 与 slot 边界 + +React 组件只消费 `useSessions`、`useWorkspaces` 与 session-scoped hooks,不拥有实体生命周期。Zustand store 只保留布局、当前 view、普通真实 Session 的 composer 文本和其他纯呈现状态;Session/Workspace Intent、materialize phase、错误和 retained prompt 位于 React-free runtime 对象层。 + +Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSession`、`updateSessionPrompt`、`sendSession`、`open` 与 `toggleSidebar`。Workspace picker 复用同一组件与 `createWorkspace` seam;owner 只提供 popover 开关、锚点和选中回调。呈现层不直接发送 `host/workspace-changed`,Host event 只由 Host mutation 与 stream adapter 产生。 + +## Alternatives considered + +**为待创建 Workspace 与 Session 保存独立页面记录。** 该方案在 materialize 后需要替换身份并转交输入、错误、焦点和 sidebar 行;对象自身的 Intent 状态可以保持身份连续。 + +**由呈现层或 root Zustand store 编排对象生命周期。** 该方案会重复 Manager/Service 的职责,并把领域状态带回 React。标准化动作由 runtime service 提供,slot 只注入呈现所需的窄接口。 + +**零态立即创建 Host Session 或 Host persistence intent。** 未输入页面会进入 Host 生命周期,并改变刷新语义;前端 Session 在首次发送前只保留 page-local Intent。 + +**显式 Create Workspace 延迟到首次发送。** 用户确认后 sidebar 仍看不到真实空 Workspace,“创建 Workspace”与“准备 Session”语义混合;只有系统自动产生的零 Workspace Intent 延迟 materialize。 + +**持续按 cwd 动态派生 Workspace。** 该方案无法表达空 Workspace、稳定显示名和显式顺序,也会自动收编非 Workspace 调用方;cwd 只用于一次历史 bootstrap 与成员双向校验。 + +**Client 在 Session list 到达后按时间批量重排。** 首屏会先展示 Host 顺序再整体跳动,重连也可能改变位置;排序由 Host 持久账本拥有,Client 只合并单项更新。 + +**在 SessionHeader 增加 workspaceId。** 它会与 Workspace 索引形成两个持久归属字段并要求双写;header 保留 Session 自身 cwd 事实,Workspace 索引负责显式归属。 + +## Verification + +- 完全无 Workspace 的零态不写 Host 且允许输入;显式 Create Workspace 立即创建并显示空 Workspace。 +- 前端 Session 与 Workspace 在 materialize 前后保持对象身份,输入、错误、焦点和 sidebar 投影始终来自对象层。 +- 首发按 Workspace、Session、prompt 顺序推进,各成功阶段不回滚,输入在 prompt 接受前不丢失,创建重试使用同一 SessionId。 +- Workspace list 只读取 header 完成一次可重入 bootstrap;initialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。 +- 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 +- 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 +- UI 与 Host 两层拒绝同名 Workspace;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 +- keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 + +## Consequences + +- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化;此后由真实 Session 活跃事件逐项前移。 +- 历史 cwd 缺失、目录无效或 realpath 失败的 Session 留在 Ungrouped;本期没有手动收编入口。 +- 页面刷新会丢弃未 materialize 的 Workspace/Session Intent 和尚未被 Host 接受的输入,这是 page-local 契约。 +- 显式 Create Workspace 立即落盘,用户不发送就离开也会留下空 Workspace。 +- Host Session 在首个事件前仍遵循现有懒持久化语义;前端 Intent 不改变 Host 重启后的空 Session 行为。 diff --git a/.gitignore b/.gitignore index ae9b4b5ddd..d6b400aeb3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ pnpm-debug.log .cache/ examples/*/*.jsonl .sessions/ +.storages/ examples/*/.sessions/ coverage/ .doc-typecheck-*/ diff --git a/apps/cli/README.md b/apps/cli/README.md index 4172eca836..2afd9c346b 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -10,7 +10,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web surface treats its invoking directory as the default project, loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opts into first-message model titles. The headless surface retains deterministic fallback titles without making the auxiliary title-model request. +The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). ## Install (developer machine) diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index a85f2eaf2c..b89df77c46 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -72,6 +72,22 @@ config: root: './.sessions' +- id: storage + name: '@deepseek-ai/dsh-storage' + +- id: storage-json + name: '@deepseek-ai/dsh-storage-json' + config: + root: './.storages' + +- id: storage-domain + name: '@deepseek-ai/dsh-storage-domain' + config: + backend: json + +- id: workspace + name: '@deepseek-ai/dsh-workspace' + - id: bash-local name: '@deepseek-ai/dsh-bash-local' @@ -217,6 +233,9 @@ - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' +- id: ui-workspace + name: '@deepseek-ai/dsh-client-ui-workspace' + - id: ui-question name: '@deepseek-ai/dsh-client-ui-question' diff --git a/apps/cli/package.json b/apps/cli/package.json index a39ce726bb..e1c07f90b5 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -32,12 +32,12 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", - "@deepseek-ai/dsh-host-runtime": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", @@ -50,6 +50,9 @@ "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-spill-policy": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", @@ -69,6 +72,7 @@ "@deepseek-ai/dsh-tui": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "cordis": "^4.0.0-rc.7", "js-yaml": "^4.2.0" diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 52df4203b1..29e20b87b8 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -1,8 +1,8 @@ /** - * AppCLIEntry — the pre-cordis boot glue every dsh surface shape shares - * (config-tree boot wired for `dsh web` this round; TUI/headless migrate - * later). Everything here is what must exist before the Loader runs: layered - * env, the patch composition over the shipped cordis.yml (profile json + CLI + * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share + * (`dsh web` and `dsh -p` boot the one composition; TUI migrates later). + * Everything here is what must exist before the Loader runs: layered env, + * the patch composition over the shipped cordis.yml (profile json + CLI * flags + the resolved frontend dist), and the fail-loud triple after the * tree settles. */ @@ -62,22 +62,30 @@ const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType) const FIBER_ACTIVE = 2 as FiberState.ACTIVE const FIBER_PENDING = 0 as FiberState.PENDING -/** Constructor facts for one `dsh web` invocation (argv already parsed by web.ts). */ +/** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */ export interface AppCLIEntryOptions { /** Absolute path of the shipped cordis.yml. */ configPath: string - /** Whether to append the HMR row (the whole prod/dev difference). */ + /** Whether to append the HMR row (the whole prod/dev difference; web surface only). */ dev: boolean /** --host when explicitly passed; undefined keeps the yml engineering default. */ host?: string - /** --port when explicitly passed; undefined keeps the yml engineering default. */ + /** + * Listen port override onto the webserver row. Web passes the --port flag + * value; headless passes 0 (an OS-assigned port, so parallel `dsh -p` runs + * never collide — and the printed URL still opens the live session in a + * browser). + */ port?: number + /** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */ + workspaceRoot?: string } /** - * Boot driver for the config-tree `dsh web` shape: holds only what exists - * independently of (and prior to) cordis — argv facts, the composed patch - * set, and finally the root ctx. + * Boot driver for the config-tree dsh surfaces (web and headless share the + * one composition; the surfaces differ only in constructor facts): holds only + * what exists independently of (and prior to) cordis — argv facts, the + * composed patch set, and finally the root ctx. */ export class AppCLIEntry { /** The root context, set by {@link run}. */ @@ -99,13 +107,13 @@ export class AppCLIEntry { this.assertBoot() const port = this.ctx.get('httpServer')?.port /* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */ - if (port === undefined) throw new Error('dsh web: httpServer service missing after settled boot') + if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot') return { ctx: this.ctx, port } } /** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */ private loadEnvLayers(): void { - loadEnv('dsh web', resolveDshHome()) + loadEnv('dsh', resolveDshHome()) } /** @@ -127,7 +135,7 @@ export class AppCLIEntry { for (const [key, value] of Object.entries(this.readProfile())) { const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key) if (mapping === undefined) { - throw new Error(`dsh web: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`) + throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`) } put(mapping.entryId, mapping.configKey, value) } @@ -135,6 +143,7 @@ export class AppCLIEntry { // Source 2: CLI flags (field set disjoint from the json mappings). if (this.options.host !== undefined) put('webserver', 'host', this.options.host) if (this.options.port !== undefined) put('webserver', 'port', this.options.port) + if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot) // Source 3: the frontend dist — an assembly fact of this app, never yml // user config. Workspace knowledge stays here. @@ -142,7 +151,7 @@ export class AppCLIEntry { this.patches = [...overrides.entries()].map(([id, bag]) => { const yml = rows.get(id) - if (yml === undefined) throw new Error(`dsh web: patch target row "${id}" not found in ${this.options.configPath}`) + if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`) return { id, config: { ...(yml.config ?? {}) as Record, ...bag } } }) } @@ -173,8 +182,8 @@ export class AppCLIEntry { * below catches PENDING fibers (cordis inject waiting has no timeout). */ private assertBoot(): void { - installFailLoud('dsh web') - assertEntriesLoaded(this.ctx, 'dsh web') + installFailLoud('dsh') + assertEntriesLoaded(this.ctx, 'dsh') const failures: string[] = [] for (const entry of this.ctx.loader.entries()) { if (entry.fiber === undefined || entry.disabled) continue @@ -188,14 +197,14 @@ export class AppCLIEntry { } } if (failures.length > 0) { - throw new Error(`dsh web: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) + throw new Error(`dsh: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) } } /** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */ private parseYmlRows(): Map { const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema }) - if (!Array.isArray(doc)) throw new Error(`dsh web: ${this.options.configPath} is not a top-level entry list`) + if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`) const rows = new Map() for (const row of doc as { id?: string; config?: unknown }[]) { if (typeof row.id === 'string') rows.set(row.id, row) @@ -214,7 +223,7 @@ export class AppCLIEntry { } const parsed: unknown = JSON.parse(raw) if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`dsh web: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`) + throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`) } return parsed as Record } @@ -225,7 +234,7 @@ export class AppCLIEntry { try { return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html') } catch { - throw new Error('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first') + throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first') } } } diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 303bac61f8..7dceebaf47 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -1,18 +1,20 @@ /** - * `dsh -p "task"` — the headless assembly: startHost + in-process isomorphic - * injection (InProcessApiClient over the host handler, so the full carrier - * chain — wire serialization, zod, SSE framing — really runs; this is the - * protocol's second real consumer). No HTTP server, no port, no dist - * resolution. Runs one task turn, prints the final assistant text, exits - * (completed → 0, else 1). + * `dsh -p "task"` — headless over the one shared composition: AppCLIEntry + * boots the same cordis.yml as `dsh web` (port 0, so parallel runs never + * collide), then in-process isomorphic injection (InProcessApiClient over + * toFetchHandler(ctx.apiProxy), so the full carrier chain — wire + * serialization, zod, SSE framing — really runs). The printed URL opens the + * live session in a browser while the task runs. Runs one task turn, prints + * the final assistant text, exits (completed → 0, else 1). */ import { parseArgs } from 'node:util' -import { startHost } from '@deepseek-ai/dsh-host-runtime' -import { InProcessApiClient } from '@deepseek-ai/dsh-host-apiproxy' +import { fileURLToPath } from 'node:url' +import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import type { SessionId } from '@deepseek-ai/dsh-session' +import { AppCLIEntry } from './app-cli-entry.ts' /** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */ interface TurnOutcome { @@ -78,15 +80,18 @@ export async function runHeadless(argv: string[]): Promise { } // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). - const host = await startHost({ - boot: { - persistenceRoot: './.sessions', - workspaceContext: false, - }, + const entry = new AppCLIEntry({ + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + dev: false, + port: 0, }) - const api = new InProcessApiClient(host.handler) + const { ctx, port } = await entry.run() + const dispose = async (): Promise => { await ctx.fiber.dispose() } + // The headless session is web-observable while it runs (same composition). + process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`) + const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy)) - const created = await unwrap(await api.sessions.create({}), () => host.dispose()) + const created = await unwrap(await api.sessions.create({}), dispose) // Open the stream before prompting so no frame is lost — kept in this order // even though in-process delivery has no race, so the code survives a move @@ -99,11 +104,11 @@ export async function runHeadless(argv: string[]): Promise { sessionId: created.sessionId, mode: 'queue', content: [{ type: 'text', text: task }], - }), () => host.dispose()) + }), dispose) const outcome = await done process.stdout.write(outcome.text + '\n') abort.abort() - await host.dispose() + await dispose() process.exit(outcome.reason === 'completed' ? 0 : 1) } diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index bcd1482df3..37e34cdf54 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -21,6 +21,7 @@ export async function runWeb(argv: string[]): Promise { host: { type: 'string' }, port: { type: 'string' }, dev: { type: 'boolean', default: false }, + 'workspace-root': { type: 'string' }, }, allowPositionals: false, }) @@ -44,6 +45,7 @@ export async function runWeb(argv: string[]): Promise { dev: values.dev, ...values.host !== undefined ? { host: values.host } : {}, ...port !== undefined ? { port } : {}, + ...values['workspace-root'] !== undefined ? { workspaceRoot: values['workspace-root'] } : {}, }) const { ctx, port: boundPort } = await entry.run() diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index b33280943a..4db4861b93 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../../packages/host/apiproxy" }, - { - "path": "../../packages/host/runtime" - }, { "path": "../../packages/host/webserver" }, diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index 673e92f9ce..c1616bb724 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -14,6 +14,7 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] }, { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, ] @@ -72,12 +73,12 @@ afterEach(() => { function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; documentTitle: string } { const tree = screen.getByRole('tree', { name: 'Sessions' }) const sidebar = within(tree).getByText(label).textContent ?? '' - const breadcrumb = within(screen.getByRole('navigation', { name: '会话层级' })) + const breadcrumb = within(screen.getByRole('navigation', { name: 'Session hierarchy' })) .getByRole('button', { name: label }).textContent ?? '' return { sidebar, breadcrumb, documentTitle: document.title } } -it('projects initial and revised durable titles through the built eight-plugin fixture app', async () => { +it('projects initial and revised durable titles through the built nine-plugin fixture app', async () => { const root = document.querySelector('#root') if (root === null) throw new Error('snapshot root missing') act(() => { @@ -92,8 +93,9 @@ it('projects initial and revised durable titles through the built eight-plugin f unmount = () => { entry.dispose() } }) - const projectLabel = await screen.findByText('fixture', {}, { timeout: 10_000 }) - const projectRow = projectLabel.closest('[role="treeitem"]') + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const projectCount = await within(tree).findByText('4 sessions') + const projectRow = projectCount.closest('[role="treeitem"]') if (projectRow === null) throw new Error('fixture project row missing') fireEvent.click(projectRow) diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts new file mode 100644 index 0000000000..78ac843a64 --- /dev/null +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -0,0 +1,323 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' +import { AppWebEntry } from '@deepseek-ai/dsh-client-web' + +const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ + { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { + id: '@deepseek-ai/dsh-client-ui-workspace', + dir: 'ui-workspace', + url: '/plugins/ui-workspace.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-conversation', + '@deepseek-ai/dsh-client-ui-sidebar', + ], + }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, +] + +const bundles = new Map(PLUGINS.map(plugin => [ + plugin.url, + readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), +])) + +interface FixtureWindow extends Window { + __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] } + __ModuleLoader__?: unknown +} + +class ResizeObserverStub { + observe(): void {} + disconnect(): void {} + unobserve(): void {} +} + +const win = window as FixtureWindow +let unmount: (() => void) | undefined + +beforeEach(() => { + localStorage.clear() + document.title = 'DeepSeek Harness' + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => { callback(0) }, 0) as unknown as number) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) +}) + +afterEach(() => { + act(() => { unmount?.() }) + unmount = undefined + cleanup() + delete win.__DSH_BOOT__ + delete win.__ModuleLoader__ + delete (globalThis as Record).__fxTiming + document.body.innerHTML = '' + document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) + document.title = '' + history.replaceState(null, '', '/') + vi.unstubAllGlobals() +}) + +/** Boot the complete built client graph against one keyless fixture branch. */ +function boot(search: string): void { + history.replaceState(null, '', `/${search}`) + const root = document.createElement('div') + root.id = 'root' + document.body.appendChild(root) + win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } + act(() => { + const entry = new AppWebEntry(root, { + fetchBundle: (url) => { + const code = bundles.get(url) + return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code) + }, + executeBundle: (code) => { (0, eval)(code) }, + }) + void entry.run() + unmount = () => { entry.dispose() } + }) +} + +/** Recreate the built client graph while preserving browser-persistent state. */ +function refresh(search: string): void { + act(() => { unmount?.() }) + unmount = undefined + cleanup() + delete win.__DSH_BOOT__ + delete win.__ModuleLoader__ + delete (globalThis as Record).__fxTiming + document.body.innerHTML = '' + document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) + boot(search) +} + +/** Collapse decorative whitespace while preserving the text a user sees. */ +function visibleText(element: Element): string { + return (element.textContent ?? '').replace(/\s+/g, ' ').trim() +} + +/** Identify the interactive Workspace chip by its menu contract. */ +function workspaceChip(): HTMLElement { + const chip = screen.getAllByRole('button', { name: 'Choose workspace' }) + .find(element => element.getAttribute('aria-haspopup') === 'menu') + if (chip === undefined) throw new Error('Workspace chip missing') + return chip +} + +/** Wait for the runtime-owned controlled input to echo a browser edit. */ +async function setComposerText(composer: HTMLElement, value: string): Promise { + fireEvent.change(composer, { target: { value } }) + await waitFor(() => { expect((composer as HTMLTextAreaElement).value).toBe(value) }) +} + +it('starts a writable page-local draft without inventing a sidebar Workspace', async () => { + boot('?fixture=empty') + + const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + const tree = screen.getByRole('tree', { name: 'Sessions' }) + await setComposerText(composer, 'keep this local') + + expect({ + headline: visibleText(screen.getByText("Let's start building")), + workspaceDraft: visibleText(workspaceChip()), + sidebar: visibleText(tree), + composerDisabled: (composer as HTMLTextAreaElement).disabled, + prompt: (composer as HTMLTextAreaElement).value, + }).toMatchInlineSnapshot(` + { + "composerDisabled": false, + "headline": "Let's start building", + "prompt": "keep this local", + "sidebar": "No sessions yet", + "workspaceDraft": "workspace", + } + `) +}) + +it('creates a real empty Workspace immediately and focuses its Session draft', async () => { + boot('?fixture=empty') + + await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + const workspaceSection = screen.getByText('Workspaces').parentElement + if (workspaceSection === null) throw new Error('Workspace section missing') + fireEvent.click(within(workspaceSection).getByRole('button', { name: 'Create workspace' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' })) + + const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' }) + fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), { + target: { value: 'nova' }, + }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' })) + + const tree = await screen.findByRole('tree', { name: 'Sessions' }) + await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }) + const group = within(tree).getByText('1 session').closest('[role="treeitem"]') + const draft = within(tree).getByText('New session').closest('[role="treeitem"]') + if (group === null || draft === null) throw new Error('created Workspace projection missing') + + expect({ + workspace: visibleText(group), + draft: visibleText(draft), + draftSelected: draft.getAttribute('aria-selected'), + composerWorkspace: visibleText(workspaceChip()), + }).toMatchInlineSnapshot(` + { + "composerWorkspace": "nova", + "draft": "New session", + "draftSelected": "true", + "workspace": "nova1 session", + } + `) +}) + +it('drops the page-local draft on refresh while retaining real Workspaces and Sessions', async () => { + boot('?fixture') + + const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + const tree = screen.getByRole('tree', { name: 'Sessions' }) + await setComposerText(composer, 'discard this page-local draft') + const beforeGroup = within(tree).getByText('4 sessions').closest('[role="treeitem"]') + if (beforeGroup === null) throw new Error('fixture Workspace projection missing before refresh') + + const before = { + workspace: visibleText(beforeGroup), + draft: visibleText(within(tree).getByText('New session')), + prompt: (composer as HTMLTextAreaElement).value, + } + + refresh('?fixture') + + const refreshedComposer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + const refreshedTree = screen.getByRole('tree', { name: 'Sessions' }) + const afterGroup = within(refreshedTree).getByText('4 sessions').closest('[role="treeitem"]') + if (afterGroup === null) throw new Error('fixture Workspace projection missing after refresh') + + expect({ + before, + after: { + workspace: visibleText(afterGroup), + replacementDraft: visibleText(within(refreshedTree).getByText('New session')), + prompt: (refreshedComposer as HTMLTextAreaElement).value, + }, + }).toMatchInlineSnapshot(` + { + "after": { + "prompt": "", + "replacementDraft": "New session", + "workspace": "fixture4 sessions", + }, + "before": { + "draft": "New session", + "prompt": "discard this page-local draft", + "workspace": "fixture4 sessions", + }, + } + `) +}) + +it('keeps a published Session with only cwd membership evidence in Ungrouped', async () => { + boot('?fixture&fixtureAttach=fail') + + const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + await setComposerText(composer, 'keep this cwd-only session') + fireEvent.click(screen.getByRole('button', { name: 'Send message' })) + + const tree = screen.getByRole('tree', { name: 'Sessions' }) + await waitFor(() => { expect(within(tree).getByText('Ungrouped')).toBeDefined() }, { timeout: 10_000 }) + const workspaceGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]') + const ungroupedGroup = within(tree).getByText('1 session').closest('[role="treeitem"]') + const ungroupedSection = ungroupedGroup?.parentElement + if (workspaceGroup === null || ungroupedGroup === null || ungroupedSection === null || ungroupedSection === undefined) { + throw new Error('Workspace or Ungrouped projection missing') + } + const session = within(ungroupedSection).getByRole('treeitem', { selected: true }) + const retained = screen.getByDisplayValue('keep this cwd-only session') + + expect({ + workspace: visibleText(workspaceGroup), + ungrouped: visibleText(ungroupedGroup), + session: within(session).getByText('fixture', { exact: true }).textContent, + sessionSelected: session.getAttribute('aria-selected'), + prompt: (retained as HTMLTextAreaElement).value, + }).toMatchInlineSnapshot(` + { + "prompt": "keep this cwd-only session", + "session": "fixture", + "sessionSelected": "true", + "ungrouped": "Ungrouped1 session", + "workspace": "fixture3 sessions", + } + `) +}) + +it('materializes the automatic Workspace and Session on the first successful send', async () => { + boot('?fixture=empty') + + const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + await setComposerText(composer, 'build a lighthouse') + fireEvent.click(screen.getByRole('button', { name: 'Send message' })) + + const tree = screen.getByRole('tree', { name: 'Sessions' }) + await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 }) + await screen.findByText('build a lighthouse', { exact: true }, { timeout: 10_000 }) + const group = within(tree).getByText('1 session').closest('[role="treeitem"]') + const session = within(tree).getByRole('treeitem', { selected: true }) + if (group === null) throw new Error('materialized Workspace projection missing') + + expect({ + workspace: visibleText(group), + session: within(session).getByText('workspace', { exact: true }).textContent, + sessionSelected: session.getAttribute('aria-selected'), + promptVisible: screen.getByText('build a lighthouse', { exact: true }).textContent, + }).toMatchInlineSnapshot(` + { + "promptVisible": "build a lighthouse", + "session": "workspace", + "sessionSelected": "true", + "workspace": "workspace1 session", + } + `) +}) + +it('keeps the published Workspace, Session, and unsent prompt after rejection', async () => { + boot('?fixture=empty&fixturePrompt=reject') + + const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + await setComposerText(composer, 'do not lose this') + fireEvent.click(screen.getByRole('button', { name: 'Send message' })) + + const alert = await screen.findByRole('alert', {}, { timeout: 10_000 }) + const retained = screen.getByDisplayValue('do not lose this') + const tree = screen.getByRole('tree', { name: 'Sessions' }) + await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }) + const group = within(tree).getByText('1 session').closest('[role="treeitem"]') + const session = within(tree).getByRole('treeitem', { selected: true }) + if (group === null) throw new Error('rejected-send Workspace projection missing') + + expect({ + workspace: visibleText(group), + session: within(session).getByText('workspace', { exact: true }).textContent, + error: visibleText(alert), + prompt: (retained as HTMLTextAreaElement).value, + }).toMatchInlineSnapshot(` + { + "error": "Message send failed: agent-busy: fixture: prompt rejected before acceptance", + "prompt": "do not lose this", + "session": "workspace", + "workspace": "workspace1 session", + } + `) +}) diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 451e373044..0f2dfd1610 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -40,8 +40,10 @@ flowchart LR pkg_storage_json["storage-json"] pkg_storage_sqlite["storage-sqlite"] pkg_storage_domain["storage-domain"] + svc_storageDomain["ctx.storageDomain
Domain data facility"] pkg_workspace["workspace"] svc_workspace["ctx.workspace
Workspace entity registry"] + pkg_apiproxy["apiproxy"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] pkg_tool_session_query["tool-session-query"] @@ -181,6 +183,7 @@ flowchart LR pkg_spill --> svc_spillStore pkg_spill_local --> svc_spillStore pkg_storage --> svc_storage + pkg_storage_domain --> svc_storageDomain pkg_storage_json --> svc_storage pkg_storage_sqlite --> svc_storage pkg_subagent --> svc_subagents @@ -255,7 +258,7 @@ flowchart LR svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain - svc_storage --> pkg_workspace + svc_storageDomain --> pkg_workspace svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent svc_systemPrompt --> pkg_agent_loop @@ -284,6 +287,7 @@ flowchart LR svc_web --> pkg_tool_web svc_workflows --> pkg_tool_ralph svc_workflows --> pkg_tool_workflow + svc_workspace --> pkg_apiproxy svc_fs -. event gate .-> pkg_fs_policy ``` @@ -295,8 +299,9 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/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. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | -| `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | - | - | Owns WorkspaceId-branded records over the domain form; sessionIds is the single source of ownership truth. RPC and GUI consumers arrive next phase. | +| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | +| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | +| `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5786cacf27..a19e21e299 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -487,19 +487,21 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-c ## `@deepseek-ai/dsh-host-apiproxy` -Requires: `agents` · `sessions` · `tools` · `userInteraction` +Requires: `agents` · `sessions` · `tools` · `userInteraction` · `workspace` ```ts config-catalog -/** Gateway plugin config: the host-level default agent routing. */ +/** Gateway plugin config: host-level agent routing and Workspace creation root. */ export interface Config { /** Default provider route for created/resumed agents. */ provider: string /** Default model id. */ model: string + /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ + workspaceRoot?: string } ``` -Source: [`packages/host/apiproxy/src/index.ts:32`](../packages/host/apiproxy/src/index.ts) +Source: [`packages/host/apiproxy/src/index.ts:33`](../packages/host/apiproxy/src/index.ts) ## `@deepseek-ai/dsh-host-webserver` @@ -1219,7 +1221,7 @@ export interface Config { } ``` -Source: [`packages/storage/storage-domain/src/index.ts:45`](../packages/storage/storage-domain/src/index.ts) +Source: [`packages/storage/storage-domain/src/index.ts:52`](../packages/storage/storage-domain/src/index.ts) ## `@deepseek-ai/dsh-storage-json` @@ -2042,6 +2044,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@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-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-workspace` ([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts)) - `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts)) - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) @@ -2058,7 +2061,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) -- `@deepseek-ai/dsh-workspace` — requires `storage` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) +- `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) ## Seam packages (not directly loadable) @@ -2089,7 +2092,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) -- `@deepseek-ai/dsh-host-runtime` ([`packages/host/runtime/src/index.ts`](../packages/host/runtime/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts)) - `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 534cacf39b..2de69b750e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1471,7 +1471,50 @@ mount(form: K, facility: StorageForms[K]): () => v form(form: K): StorageForms[K] ``` -Source: [`packages/storage/storage/src/index.ts:35`](../../packages/storage/storage/src/index.ts) +Source: [`packages/storage/storage/src/index.ts:47`](../../packages/storage/storage/src/index.ts) + +## `ctx.storageDomain` — `DomainFacility` + +The mounted domain facility. Opens declared domains over routed backends; one facility instance owns the open-domain table and enforces single-open per domain name. + +```ts cordis-catalog +/** + * Open one declared domain. Steps, each failing the whole call: reject a + * name that is already open (`already-open`); resolve the backend route + * (`backend-not-found` passes through from the hub); require its `kv` facet + * (`facet-unsupported`); open the unit projected from the spec (backend + * `version-mismatch`/`malformed-medium` pass through); load and validate + * every stored record against the spec's zod schemas (`invalid-record` + * with the offending table and key); construct the domain. + * + * Lifecycle: the CALLER owns the returned handle and closes it via + * `Domain.close()` (typically as its own `ctx.effect` disposer) — the + * facility does not tie the domain to any consumer fiber. Domains still + * open when the facility unmounts are closed by the plugin disposer. + * @param spec - The domain declaration, typically from `defineDomain`. + * @returns the opened domain handle, typed by the spec. + */ +async open(spec: S): Promise> + +/** + * Look up an open domain by name, untyped. Diagnostic surface (the package + * invariant cross-checks change events against live domain state); typed + * consumers hold the handle returned by {@link open}. + * @param name - Domain name. + * @returns the open domain runtime, or `undefined` when not open. + */ +get(name: string): DomainImpl | undefined + +/** + * Close every domain still open on this facility. The unmount path for + * consumers that never called `Domain.close()` themselves; closing is + * idempotent, so double-closing an already-closed domain is harmless. + * @returns resolution after every unit is released. + */ +async closeAll(): Promise +``` + +Source: [`packages/storage/storage-domain/src/index.ts:69`](../../packages/storage/storage-domain/src/index.ts) ## `ctx.subagents` — `SubagentService` @@ -1935,49 +1978,59 @@ Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/ ## `ctx.workspace` — `WorkspaceRegistry` -The workspace registry service. Opens the `workspace` domain at startup, rebuilds one entity per stored record, and serves entities from an in-memory cache keyed by id. Session persistence is an OPTIONAL peer (resolved via `ctx.get`, never injected): while it is absent, session attachment rejects (what cannot be validated is not recorded) and `sessionIds` projections serve the account unfiltered. - -There is deliberately no delete entry point in this phase: workspace deletion ships as one complete semantic together with the session-cascade primitives (future work in the owning Agent Note). +Durable workspace registry. Startup waits for `sessionPersistence`, builds one canonical-cwd header index, and completes the one-time history bootstrap before the service becomes active. The persistence dependency is mandatory so an unavailable peer can never be mistaken for an empty history and commit the initialized marker. ```ts cordis-catalog /** - * Create a workspace over an existing directory. The path is canonicalized - * through `fs.realpath` first — a nonexistent path rejects with the - * original `ENOENT`, a path resolving to anything but a directory rejects, - * and a canonical path already owned by another workspace (including a - * symlink resolving to it) rejects. - * @param path - Directory the workspace points at; canonicalized before storing. - * @param title - Display title; defaults to `basename` of the canonical path. - * @returns the created workspace after durability. + * Create or reuse a workspace for an existing directory. The path is + * canonicalized through `fs.realpath`; a nonexistent path rejects with the + * original error and a non-directory rejects. Repeated calls for the same + * canonical path return the existing entity without changing its title. + * A newly created workspace is prepended to the durable registry order. + * A different canonical path cannot create a duplicate display title. + * @param path - Existing directory to own, in any path spelling. + * @param title - Display title used only when a new record is created. + * @returns the existing or newly durable workspace. */ async create(path: string, title?: string): Promise /** * Look up a workspace by id. - * @param id - The workspace id. + * @param id - Workspace id. * @returns the workspace, or `undefined` when unknown. */ get(id: WorkspaceId): Workspace | undefined /** - * Snapshot of all workspaces, in load-then-creation order. - * @returns a fresh array of the cached entities. + * Synchronous workspace projection in durable registry order. Every + * entity's `sessionIds` getter is already filtered by the startup/live + * canonical-cwd header index; this method performs no persistence reads. + * @returns a fresh ordered array of workspace entities. */ list(): Workspace[] /** - * Resolve a workspace by directory path, through the same `fs.realpath` - * canon as {@link create} (hence async). A path that does not exist rejects - * with the original error — a missing directory has no canonical form to - * compare (a workspace whose recorded directory vanished is only reachable - * by id; see `Workspace.status`). - * @param path - Directory path in any spelling (symlinks, `..`, trailing slash). - * @returns the owning workspace, or `undefined` when none matches. + * Move one accounted, cwd-validated session to the front of its workspace. + * Ungrouped sessions and candidates filtered by the header check are + * no-ops. The owning workspace's relative position never changes. + * @param sessionId - Session whose activity was observed. + * @returns resolution after the possible record write. + */ +async touchSession(sessionId: SessionId): Promise + +/** + * Resolve by canonical directory path without creating or mutating a + * workspace. A missing path rejects during `realpath`; an existing unowned + * directory returns `undefined`. + * @param path - Existing directory path in any spelling. + * @returns the workspace owning the canonical path, when one exists. */ async resolveByPath(path: string): Promise ``` -Source: [`packages/workspace/workspace/src/index.ts:60`](../../packages/workspace/workspace/src/index.ts) +Types: [SessionId](../core-data-structures/core.md) + +Source: [`packages/workspace/workspace/src/index.ts:75`](../../packages/workspace/workspace/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index bba7989556..d9521f7d67 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -28,7 +28,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `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) | +| `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`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace`](../packages/workspace/workspace), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 5d3326134e..c1a543586e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -148,6 +148,7 @@ flowchart TD pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_theme["client-ui-theme"] pkg_client_ui_trajectory["client-ui-trajectory"] + pkg_client_ui_workspace["client-ui-workspace"] pkg_client_web["client-web"] pkg_client_web_react["client-web-react"] end @@ -172,7 +173,6 @@ flowchart TD end subgraph group_host["packages/host"] pkg_host_apiproxy["host-apiproxy"] - pkg_host_runtime["host-runtime"] pkg_host_webserver["host-webserver"] end subgraph group_lsp["packages/lsp"] @@ -239,7 +239,6 @@ flowchart TD pkg_code_runtime --> pkg_invariants pkg_jsonrpc_demo --> pkg_invariants pkg_host_apiproxy --> pkg_invariants - pkg_host_runtime --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants pkg_llm --> pkg_brand @@ -260,6 +259,10 @@ flowchart TD pkg_client_ui_sidebar --> pkg_client_ui_primitives pkg_client_ui_sidebar --> pkg_client_ui_slots pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_telemetry --> pkg_brand @@ -818,7 +821,6 @@ flowchart TD | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | -| [`host-runtime`](../packages/host/runtime) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | @@ -827,6 +829,7 @@ flowchart TD | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | diff --git a/knip.json b/knip.json index b6a1fb63d7..2d437dd421 100644 --- a/knip.json +++ b/knip.json @@ -57,11 +57,6 @@ ] }, "packages/host/webserver": { - "project": [ - "src/**/*.ts" - ] - }, - "packages/host/runtime": { "entry": [ "tests/**/*.spec.ts" ], diff --git a/missions/readme.md b/missions/readme.md new file mode 100644 index 0000000000..66167719ad --- /dev/null +++ b/missions/readme.md @@ -0,0 +1,36 @@ +# Workspace GUI 收尾备忘 + +## 产品改动 + +- 用户要求“去掉功能”时,先拆开视觉入口、可访问性语义和响应行为分别确认。本次 composer 加号保留原样和 `Add attachment` 标签,只在组合层停止传入 Workspace 回调;不要删除按钮、改样式或把它禁用。 +- 临时交互不应上浮到 React 呈现层。Session/Workspace Intent、首次消息保留和 materialize 重试归 runtime 对象与 service;组件只接收标准 action、hooks 和纯呈现状态。 +- RFC、测试名称和 PR 描述只写最终产品语义,不保留 `reconcilePublishedDraft`、`pendingCwd` 等已经撤销的中间方案。 + +## Snapshot 与测试定位 + +- `apps/web/tests/**/*.snapshot.ts` 验证 built application,需用 `DSH_EXAMPLE_MODE=lib`,并确认相关 `lib/` 已由当前源码构建;普通 source-mode Vitest 通过不能替代它。 +- 对 runtime 管理的受控输入执行 `fireEvent.change` 后,必须 `waitFor` 输入值回显再点击发送,否则发送可能读取旧的空 prompt。 +- 页面中 Workspace 与 Session 可以同名,禁止用无作用域的 `findByText` 定位。先用 `within` 锁定 Sessions tree、计数或对应 group,再找目标行。 +- 新 push 后先看 assembled snapshot 是否真正跑过;本地 focused snapshot 通过后仍以 `gh pr checks` 的 artifact job 为准。 + +## Coverage 收口 + +- 测试筛选和 coverage 筛选是两件事。用 owning tests 配合逐个 `--coverage.include=''`,先拿到真实未覆盖行和分支,不要直接反复跑全仓 coverage。 +- 多个 coverage 进程并发时必须给不同的 `--coverage.reportsDirectory`,否则报告目录互相覆盖。各 worker 完成后再跑一次合并后的精确 coverage,确认共享 worktree 的改动组合起来仍为 100%。 +- 全仓 coverage 若先被无关测试超时打断,不能把它当作目标文件的结论;先用精确 include 修本分支缺口,再让 CI exhaustive coverage 验证整体。 +- Coverage 测试仍要描述行为,不写“为了覆盖某分支”的注释。不可达分支才使用已有规范允许的 `v8 ignore`,可达分支补真实行为测试。 + +## 并发与提交 + +- Coverage 适合按不相交写区并发:例如 Sidebar tests、Workspace picker tests、connection/storage tests。派工时明确“只改 tests、不改 src、不 commit、不得回滚他人改动”。 +- 不直接信任各 worker 的单独结果;主会话审查 diff、运行合并后的 focused coverage、清理生成报告,再统一 commit。 +- 推送前按 `dsh-pre-push-checks` 选择最小充分验证,不重复已经通过的检查;正常 push 让 pre-push typecheck 运行,并核对本地 HEAD 与远端 ref 一致。 +- 生成的 `.coverage/` 只属于本地诊断。环境拒绝 `rm -rf` 时,依次使用 `find .coverage -type f -delete` 和 `find .coverage -depth -type d -empty -delete`;不要让报告进入 commit。 + +## GitHub 与 CI + +- GitHub 操作统一走 `gh`,并从 git 配置注入代理:`proxy="$(git config --get http.https://github.com.proxy)"; https_proxy="$proxy" http_proxy="$proxy" GH_PAGER=cat ~/.local/bin/gh ...`。不要改用网页。 +- 每次 push 都会产生一轮新 checks;旧轮次的失败不能代表当前 HEAD。先确认 run 对应当前提交,再拉失败日志。 +- `gh run watch` 只监视一个 workflow。最终必须用 `gh pr checks` 汇总 CI、e2e、sandbox 和 Windows 等独立 workflow;偶发平台失败先等当前 HEAD 重跑结果,不预先修改无关代码。 +- PR base 和 description 在最终 push 后再次用 `gh pr edit --base ... --body-file ...` 同步。PR 描述应包含最终产品动线、架构边界和实际运行过的验证,不写仍待执行的承诺。 +- Review thread 用 GraphQL/`gh api` 检查 `isResolved` 和已有回复,避免对已经解决的旧实现评论重复修复。 diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 5bde15dc2c..9001b38877 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -10,8 +10,8 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07- 1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`. 2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `..` (e.g. `'conversation.chat.toolview'`). -3. **Component props are the four shares, all derived**: `PropsRuntime` (SlotMap: owner params + `useSession`/`sessionId` on session scope + `useSessions`) & `PropsRenderSlots` (children keys) & `PropsStore` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally. -4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useStore`, `renderSlot` are the four seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.) +3. **Component props are the four shares, all derived**: `PropsRuntime` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots` (children keys) & `PropsStore` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally. +4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.) 5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription. 6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path). 7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hooks, no ReactNode producers, no whole-service objects. Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for. @@ -70,6 +70,16 @@ Run the narrowest rung that covers what you touched; escalate only when the chan If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep. +## New plugin package checklist + +Bringing up a new `packages/client/` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy): + +1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. +2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; the `CLIENT_PACKAGES` roster in `apps/cli/src/web.ts`; an `apps/cli/package.json` dependency (`mountWebPlugins` resolves roster packages against the composing app's URL — a roster row that is not a dependency of `apps/cli` fails to mount). `pnpm-workspace.yaml` already globs `packages/*/*`. +3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else. +4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case). +5. Rebuild the bundle (`pnpm --filter bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources. + ## New component checklist 1. Compose through register: merge the slot contract into `SlotMap`, declare the slot in its parent entry's `children`, register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists. diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index dc9fbb85b8..569670c274 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,6 +2,10 @@ Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +## Keyless fixture + +Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. + ## Model Experience None, as the wire consumer layer moves already-composed messages between browser and host; nothing here reaches a model request. diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index c7e1ed5c68..1edaf9c7df 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -8,6 +8,7 @@ export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + WorkspaceApi, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index e1e21dfd78..53c18dca5c 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -10,7 +10,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, - ToolCallView, ToolEventView, ToolResultView, + ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView, } from './api.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' import { AbstractApiClient, RpcId } from './api.ts' @@ -242,6 +242,20 @@ interface StreamConn { push(envelope: RpcRequest): void } +/** Deterministic fixture branches used by keyless Web assembly tests. */ +export interface FixtureOptions { + /** Start with no real Workspace or Session. */ + empty?: boolean + /** Reject every prompt before appending its user event. */ + rejectPrompt?: boolean + /** Publish the Session but fail its Workspace account write. */ + failWorkspaceAttach?: boolean + /** Publish and frame the Session, then throw instead of returning create. */ + dropSessionCreateResponse?: boolean + /** Order of the two successful create frames. */ + createFrameOrder?: 'session-first' | 'workspace-first' +} + /** 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, audit C5). breakNow force-ends the stream without the @@ -286,10 +300,11 @@ class FxInbox implements StreamConn { /** * In-memory fake host: fx-alpha carries history and replay scripts; fx-beta is fx-alpha's child session (lineage indent material). + * @param options - fixture branches for empty state and failure timing. * @returns an ApiProxy backed entirely by in-memory state — no host process, no network. */ -export function createFixtureApi(): ApiProxy { - const sessions: SessionSummary[] = [ +export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { + const sessions: SessionSummary[] = options.empty ? [] : [ { sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, cwd: '/tmp/fixture' }, { sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' }, { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' }, @@ -298,6 +313,20 @@ export function createFixtureApi(): ApiProxy { const nextTurn = new Map([[sid('fx-alpha'), 60]]) let nextSession = 1 let nextRpc = 1 + let attachedSessions = options.empty ? 0 : 1 + // Workspace entities mirroring the host registry: the fixture sessions all + // live under one workspace, whose account carries them in attach order. + const wid = (raw: string): WorkspaceId => raw as WorkspaceId + const fixtureEpoch = new Date(Date.now() - 300_000).toISOString() + const workspaces: WorkspaceView[] = options.empty ? [] : [{ + workspaceId: wid('fx-ws-fixture'), + path: '/tmp/fixture', + title: 'fixture', + sessionIds: [sid('fx-alpha'), sid('fx-beta'), sid('fx-gamma')], + createdAt: fixtureEpoch, + updatedAt: fixtureEpoch, + }] + let nextWorkspace = 1 const mint = (): ReturnType => RpcId(`fx-rpc-${nextRpc++}`) /** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */ const pendingApprovalRpcId = mint() @@ -464,12 +493,71 @@ export function createFixtureApi(): ApiProxy { return { sessions: { list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }), - create: (request) => { + create: async (request) => { + const workspace = request.payload.workspaceId === undefined + ? undefined + : workspaces.find(w => w.workspaceId === request.payload.workspaceId) + if (request.payload.workspaceId !== undefined && workspace === undefined) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${request.payload.workspaceId}`, + details: { workspaceId: request.payload.workspaceId }, + }) + } + const cwd = workspace?.path ?? request.payload.cwd ?? '/tmp/fixture' + const requestedId = request.payload.sessionId + const attachWorkspace = (sessionId: SessionId): void => { + /* v8 ignore next -- callers enter only when a target Workspace exists. */ + if (workspace === undefined || workspace.sessionIds.includes(sessionId)) return + workspace.sessionIds = [sessionId, ...workspace.sessionIds] + workspace.updatedAt = new Date().toISOString() + emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) + } + const attachFailure = ( + sessionId: SessionId, + workspaceId: WorkspaceId, + ): Promise> => err(request, { + code: 'workspace-attach-failed' as const, + message: `fixture rejected Workspace attachment for ${sessionId}`, + details: { sessionId, workspaceId }, + }) + if (requestedId !== undefined) { + const existing = summaryOf(requestedId) + if (existing !== undefined) { + if (existing.cwd !== cwd) { + return err(request, { + code: 'session-conflict', + message: `session ${requestedId} already uses ${existing.cwd ?? 'no cwd'}`, + details: { sessionId: requestedId, requestedCwd: cwd, ...existing.cwd === undefined ? {} : { existingCwd: existing.cwd } }, + }) + } + if (workspace !== undefined && !workspace.sessionIds.includes(requestedId)) { + if (options.failWorkspaceAttach) return attachFailure(requestedId, workspace.workspaceId) + attachWorkspace(requestedId) + } + return ok(request, { sessionId: requestedId }) + } + } const created: SessionSummary = { - sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd: '/tmp/fixture', + sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd, } sessions.push(created) - emitHost({ type: 'host/session-added', sessionId: created.sessionId }) + attachedSessions += 1 + const emitSession = (): void => { + emitHost({ type: 'host/session-added', sessionId: created.sessionId, cwd }) + } + if (workspace !== undefined && options.failWorkspaceAttach) { + emitSession() + return attachFailure(created.sessionId, workspace.workspaceId) + } + if (workspace !== undefined && options.createFrameOrder === 'workspace-first') { + attachWorkspace(created.sessionId) + emitSession() + } else { + emitSession() + if (workspace !== undefined) attachWorkspace(created.sessionId) + } + if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication') return ok(request, { sessionId: created.sessionId }) }, history: async (request) => { @@ -489,6 +577,13 @@ export function createFixtureApi(): ApiProxy { if (summary === undefined) { return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) } + if (options.rejectPrompt) { + return err(request, { + code: 'agent-busy', + message: 'fixture: prompt rejected before acceptance', + details: { reason: 'fixture-prompt-rejection' }, + }) + } summary.updatedAt = Date.now() const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('') if (mode === 'steer' && replays.has(id)) { @@ -524,7 +619,28 @@ export function createFixtureApi(): ApiProxy { }, }, host: { - describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }), + describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }), + }, + workspace: { + list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }), + create: (request) => { + const { path, name } = request.payload + const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}` + const existing = workspaces.find(w => w.path === target) + if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false }) + const now = new Date().toISOString() + const created: WorkspaceView = { + workspaceId: wid(`fx-ws-${nextWorkspace++}`), + path: target, + title: name ?? target.split('/').filter(Boolean).at(-1) ?? target, + sessionIds: [], + createdAt: now, + updatedAt: now, + } + workspaces.unshift(created) + emitHost({ type: 'host/workspace-changed', workspace: { ...created } }) + return ok(request, { workspace: { ...created }, created: true }) + }, }, events: { async *mux(_request, signal) { @@ -606,7 +722,12 @@ export function createFixtureApi(): ApiProxy { * to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)). */ export class FixtureApiClient extends AbstractApiClient { - private readonly api = createFixtureApi() + private readonly api: ApiProxy + + constructor() { + super() + this.api = createFixtureApi(fixtureOptionsFromLocation()) + } protected doFetch(): Promise { throw new Error('FixtureApiClient overrides all protocol paths; doFetch must be unreachable') @@ -634,6 +755,8 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.prompt': return this.api.sessions.prompt(request) case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) + case 'workspace.list': return this.api.workspace.list(request) + case 'workspace.create': return this.api.workspace.create(request) } } @@ -678,3 +801,16 @@ export class FixtureApiClient extends AbstractApiClient { return this.api.respond(message) } } + +/** Browser query mapping; direct unit callers pass FixtureOptions explicitly. */ +function fixtureOptionsFromLocation(): FixtureOptions { + if (typeof location === 'undefined') return {} + const query = new URLSearchParams(location.search) + return { + empty: query.get('fixture') === 'empty', + rejectPrompt: query.get('fixturePrompt') === 'reject', + failWorkspaceAttach: query.get('fixtureAttach') === 'fail', + dropSessionCreateResponse: query.get('fixtureSessionCreate') === 'drop-response', + createFrameOrder: query.get('fixtureFrames') === 'workspace-first' ? 'workspace-first' : 'session-first', + } +} diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 673aa978da..eb074e5011 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -13,7 +13,7 @@ import { WebApiClient } from './web-api-client.ts' export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, - ToolCallView, ToolResultView, + ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index af5743bf9a..faca82d5c3 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -71,6 +71,14 @@ export class FakeApiClient implements IApiClient { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), } + readonly workspace: IApiClient['workspace'] = { + list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [] }))), + create: (payload: unknown) => this.record('workspace.create', payload, Promise.resolve(ok({ + workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, + created: true, + }))), + } + /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ suppressStreamOpen = false diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index ac32955c36..16fa4b4ed6 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -5,7 +5,7 @@ * the hand-written fixture/host parallel implementations. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionId } from '../src/client/api.ts' +import type { SessionId, WorkspaceId } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' import type { HostFrame, MuxFrame, RpcMessage, RpcRequest } from '../src/client/api.ts' import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts' @@ -87,7 +87,7 @@ describe('createFixtureApi', () => { await consuming if (!created.result.ok) throw new Error('create failed') const createdId = created.result.value.sessionId - expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId }]) + expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, cwd: '/tmp/fixture' }]) const list = await api.sessions.list(req({})) if (!list.result.ok) throw new Error('list failed') expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true) @@ -259,6 +259,213 @@ describe('createFixtureApi', () => { const api = createFixtureApi() const response = await api.host.describe(req({})) expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } }) + const empty = await createFixtureApi({ empty: true }).host.describe(req({})) + expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } }) + }) + + it('workspace.list serves the resident account and create reuses on path collision', async () => { + const api = createFixtureApi() + const listed = await api.workspace.list(req({})) + if (!listed.result.ok) throw new Error('list failed') + expect(listed.result.value.items).toEqual([expect.objectContaining({ + workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture', + sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'], + })]) + // path collision → the existing entity comes back, created:false, no frame. + const reused = await api.workspace.create(req({ path: '/tmp/fixture' })) + if (!reused.result.ok) throw new Error('reuse failed') + expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } }) + }) + + it('workspace.create by name mints a new entity and pushes host/workspace-changed', async () => { + const api = createFixtureApi() + const abort = new AbortController() + const seen: HostFrame[] = [] + const consuming = (async () => { + for await (const envelope of api.events.host(req({}), abort.signal)) { + seen.push(envelope.payload) + abort.abort() + } + })() + await new Promise(resolve => setTimeout(resolve, 10)) + const created = await api.workspace.create(req({ name: 'nova' })) + if (!created.result.ok) throw new Error('create failed') + expect(created.result.value.created).toBe(true) + expect(created.result.value.workspace).toMatchObject({ + path: '/tmp/fixture-workspaces/nova', title: 'nova', sessionIds: [], + }) + await consuming + expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }]) + // path spelling falls back to the basename when no title/name rides along. + const pathOnly = await api.workspace.create(req({ path: '/tmp/fixture-elsewhere/base' })) + if (!pathOnly.result.ok) throw new Error('pathOnly failed') + expect(pathOnly.result.value.workspace.title).toBe('base') + // Degenerate spellings reach the impl unfiltered (the fixture carrier has + // no schema gate): both-absent falls back to the bucket dir, and a + // basename-less path serves as its own title. + const bare = await api.workspace.create(req({})) + if (!bare.result.ok) throw new Error('bare failed') + expect(bare.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/', title: 'fixture-workspaces' }) + const rootPath = await api.workspace.create(req({ path: '/' })) + if (!rootPath.result.ok) throw new Error('rootPath failed') + expect(rootPath.result.value.workspace.title).toBe('/') + }) + + it('session.create({workspaceId}) lands on the account and unknown ids error', async () => { + const api = createFixtureApi() + const abort = new AbortController() + const seen: HostFrame[] = [] + const consuming = (async () => { + for await (const envelope of api.events.host(req({}), abort.signal)) { + seen.push(envelope.payload) + if (seen.length >= 2) abort.abort() + } + })() + await new Promise(resolve => setTimeout(resolve, 10)) + const missing = await api.sessions.create(req({ workspaceId: 'fx-ws-void' as WorkspaceId })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) + const created = await api.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId })) + if (!created.result.ok) throw new Error('create failed') + const id = created.result.value.sessionId + await consuming + // The session lands with the workspace's path as cwd, and the account + // write pushes the fresh workspace snapshot after session-added. + expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, cwd: '/tmp/fixture' }) + expect(seen[1]).toMatchObject({ + type: 'host/workspace-changed', + workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] }, + }) + }) + + it('supports an empty baseline, preallocated ids, workspace-first frames, and idempotent retry', async () => { + const api = createFixtureApi({ empty: true, createFrameOrder: 'workspace-first' }) + const initialSessions = await api.sessions.list(req({})) + const initialWorkspaces = await api.workspace.list(req({})) + expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } }) + expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } }) + + const made = await api.workspace.create(req({ name: 'nova' })) + if (!made.result.ok) throw new Error('workspace create failed') + const abort = new AbortController() + const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2) + await new Promise(resolve => setTimeout(resolve, 10)) + const preallocated = sid('fx-preallocated') + const created = await api.sessions.create(req({ + workspaceId: made.result.value.workspace.workspaceId, + sessionId: preallocated, + })) + expect(created.result).toEqual({ ok: true, value: { sessionId: preallocated } }) + const frames = await framesPromise + expect(frames[0]).toMatchObject({ + type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] }, + }) + expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, cwd: made.result.value.workspace.path }) + + const retried = await api.sessions.create(req({ + workspaceId: made.result.value.workspace.workspaceId, + sessionId: preallocated, + })) + expect(retried.result).toEqual({ ok: true, value: { sessionId: preallocated } }) + const listed = await api.sessions.list(req({})) + if (!listed.result.ok) throw new Error('session list failed') + expect(listed.result.value.items.filter(item => item.sessionId === preallocated)).toHaveLength(1) + + const conflict = await api.sessions.create(req({ sessionId: preallocated, cwd: '/elsewhere' })) + expect(conflict.result).toMatchObject({ + ok: false, + error: { code: 'session-conflict', details: { sessionId: preallocated, requestedCwd: '/elsewhere' } }, + }) + }) + + it('attaches an existing ungrouped Session to a matching Workspace', async () => { + const api = createFixtureApi() + const sessionId = sid('fx-existing-ungrouped') + await expect(api.sessions.create(req({ sessionId, cwd: '/tmp/fixture' }))).resolves.toMatchObject({ + result: { ok: true, value: { sessionId } }, + }) + + await expect(api.sessions.create(req({ + sessionId, + workspaceId: 'fx-ws-fixture' as WorkspaceId, + }))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } }) + + const workspaces = await api.workspace.list(req({})) + if (!workspaces.result.ok) throw new Error('workspace list failed') + expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId) + }) + + it('reports a conflict without an existing cwd detail for an unrecorded cwd', async () => { + const api = createFixtureApi() + const listed = await api.sessions.list(req({})) + if (!listed.result.ok) throw new Error('session list failed') + const existing = listed.result.value.items.find(item => item.sessionId === sid('fx-alpha')) + if (existing === undefined) throw new Error('fixture Session missing') + delete existing.cwd + + const conflict = await api.sessions.create(req({ sessionId: existing.sessionId })) + expect(conflict.result).toEqual({ + ok: false, + error: { + code: 'session-conflict', + message: `session ${existing.sessionId} already uses no cwd`, + details: { sessionId: existing.sessionId, requestedCwd: '/tmp/fixture' }, + }, + }) + }) + + it('publishes an ungrouped Session when Workspace attachment fails', async () => { + const api = createFixtureApi({ failWorkspaceAttach: true }) + const sessionId = sid('fx-partial') + const created = await api.sessions.create(req({ + workspaceId: 'fx-ws-fixture' as WorkspaceId, + sessionId, + })) + expect(created.result).toMatchObject({ + ok: false, + error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: 'fx-ws-fixture' } }, + }) + const listed = await api.sessions.list(req({})) + const workspaces = await api.workspace.list(req({})) + if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed') + expect(listed.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1) + expect(workspaces.result.value.items[0]?.sessionIds).not.toContain(sessionId) + + const retried = await api.sessions.create(req({ + workspaceId: 'fx-ws-fixture' as WorkspaceId, + sessionId, + })) + expect(retried.result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) + const afterRetry = await api.sessions.list(req({})) + if (!afterRetry.result.ok) throw new Error('list failed') + expect(afterRetry.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1) + }) + + it('reconciles a dropped create response and can reject a prompt before acceptance', async () => { + const sessionId = sid('fx-lost-response') + const dropped = createFixtureApi({ dropSessionCreateResponse: true }) + await expect(Promise.resolve().then(() => dropped.sessions.create(req({ + workspaceId: 'fx-ws-fixture' as WorkspaceId, + sessionId, + })))).rejects.toThrow(/dropped session\.create response/) + const listed = await dropped.sessions.list(req({})) + const workspaces = await dropped.workspace.list(req({})) + if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed') + expect(listed.result.value.items.some(item => item.sessionId === sessionId)).toBe(true) + expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId) + await expect(dropped.sessions.create(req({ + workspaceId: 'fx-ws-fixture' as WorkspaceId, + sessionId, + }))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } }) + + const rejecting = createFixtureApi({ empty: true, rejectPrompt: true }) + const real = await rejecting.sessions.create(req({ sessionId: sid('fx-rejected') })) + if (!real.result.ok) throw new Error('session create failed') + const prompt = await rejecting.sessions.prompt(req({ + sessionId: real.result.value.sessionId, + mode: 'queue' as const, + content: [{ type: 'text' as const, text: 'keep me' }], + })) + expect(prompt.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } }) }) it('timing hooks: history delay + one-shot failure, silent append, and breakStreams end open generators', async () => { @@ -311,6 +518,7 @@ describe('createFixtureApi', () => { describe('FixtureApiClient (protocol-level fake carrier)', () => { afterEach(() => { vi.restoreAllMocks() + vi.unstubAllGlobals() }) it('doFetch is an unreachable tripwire (all protocol paths overridden)', () => { @@ -346,6 +554,57 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true) + expect((await client.workspace.list({})).result.ok).toBe(true) + const workspace = await client.workspace.create({ name: 'via-client' }) + if (!workspace.result.ok) throw new Error('workspace create failed') + expect(workspace.result.value.workspace.title).toBe('via-client') + }) + + it('maps empty, prompt-reject, and workspace-first query scenarios', async () => { + vi.stubGlobal('location', { + search: '?fixture=empty&fixturePrompt=reject&fixtureFrames=workspace-first', + }) + const client = new FixtureApiClient() + await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } }) + const made = await client.workspace.create({ name: 'query-workspace' }) + if (!made.result.ok) throw new Error('workspace create failed') + const abort = new AbortController() + const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2) + await new Promise(resolve => setTimeout(resolve, 10)) + const sessionId = sid('fx-query-session') + const created = await client.sessions.create({ + workspaceId: made.result.value.workspace.workspaceId, + sessionId, + }) + expect(created.result).toMatchObject({ ok: true, value: { sessionId } }) + const frames = await framesPromise + expect(frames.map(frame => frame.type)).toEqual(['host/workspace-changed', 'host/session-added']) + const rejected = await client.sessions.prompt({ + sessionId, + mode: 'queue', + content: [{ type: 'text', text: 'retain' }], + }) + expect(rejected.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } }) + }) + + it('maps attach-failure and dropped-response query scenarios', async () => { + vi.stubGlobal('location', { search: '?fixture&fixtureAttach=fail' }) + const partial = new FixtureApiClient() + const partialResult = await partial.sessions.create({ + workspaceId: 'fx-ws-fixture' as WorkspaceId, + sessionId: sid('fx-query-partial'), + }) + expect(partialResult.result).toMatchObject({ + ok: false, + error: { code: 'workspace-attach-failed', details: { sessionId: 'fx-query-partial' } }, + }) + + vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' }) + const dropped = new FixtureApiClient() + await expect(dropped.sessions.create({ + workspaceId: 'fx-ws-fixture' as WorkspaceId, + sessionId: sid('fx-query-dropped'), + })).rejects.toThrow(/dropped session\.create response/) }) it('fires onOpen at stream-iteration start and taps server-request full forms', async () => { diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 4fd5d15905..6b697cbed9 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -1,6 +1,16 @@ # @deepseek-ai/dsh-client-runtime -Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state, and page-local Session Intent state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, page-local Workspace Intent state, default-target derivation, and the cross-object New Session flow. The runtime fans the shared Host stream into both managers. Contract: api-contracts v3 §4. + +## Workspace and Session lists + +Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental frames arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. + +SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. + +## Session creation failures + +`SessionsService.create` accepts an optional caller-preallocated SessionId. It throws `SessionCreateError` on failure: `requestedSessionId` remains available after transport uncertainty, while `publishedSessionId` is set when `workspace-attach-failed` proves the Host published a real Session before attachment failed. For the New Session flow, the frontend Session object owns its retained prompt and advances it through attachment and send; a partially published Session keeps the same object and prompt while it appears as Ungrouped. ## Session title projection diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 4c0bf3d01f..830d1b8249 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,30 +1,32 @@ -/** - * Browser runtime services for slots, sessions, and connection-stream - * delivery. The web shell mounts this static client entry through the host - * plugin graph. - */ +/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ import type { Context } from 'cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from './slots.ts' import { SessionsService } from './sessions/service.ts' import type { SessionListState } from './sessions/service.ts' +import { WorkspacesService } from './workspaces/service.ts' import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts' export { SlotsService } from './slots.ts' export type { RootOwnerProps } from './slots.ts' -export { SessionsService, scopeOf } from './sessions/service.ts' +export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' +export { WorkspacesService } from './workspaces/service.ts' export type { Session } from './sessions/session.ts' export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts' +export type { SessionIntentListSnapshot, SessionListPhase } from './sessions/manager.ts' +export type { WorkspaceListPhase } from './workspaces/manager.ts' +export type { WorkspaceListState } from './workspaces/service.ts' +export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' // Runtime owns the snapshot store; web-react only binds it to React. export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts' export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' export type { - AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot, - RunningToolCall, SteeringMessageNode, - ToolResultNode, UnknownSurfaceNode, UserMessageNode, + AssistantBlock, AssistantMessageNode, ComposerPhase, ContextMessageNode, ConversationNode, + ConversationSnapshot, PendingPrompt, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget, + SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export { PendingWait } from './sessions/pending.ts' export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' @@ -57,6 +59,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Props injected into every global slot component. */ interface GlobalStandardProps { useSessions: SnapshotSelectorHook + /** Selector hook over real Workspaces and their independent baseline lifecycle. */ + useWorkspaces: SnapshotSelectorHook } } @@ -72,6 +76,7 @@ declare module 'cordis' { interface Context { slots: import('./slots.ts').SlotsService sessions: import('./sessions/service.ts').SessionsService + workspaces: import('./workspaces/service.ts').WorkspacesService } } @@ -85,10 +90,17 @@ export function apply(ctx: Context): void { ctx.plugin(SlotsService) const connection = ctx.get('connection') as ConnectionHandle const sessions = new SessionsService(ctx, connection.api) + const workspaces = new WorkspacesService(ctx, connection.api, sessions) const loop = connection.start({ - onMuxEnvelope: (envelope) => { sessions.manager.handleMuxEnvelope(envelope) }, - onHostEnvelope: (envelope) => { sessions.manager.handleHostEnvelope(envelope) }, - onConnected: () => { sessions.manager.handleConnected() }, + onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) }, + onHostEnvelope: (envelope) => { + sessions.handleHostEnvelope(envelope) + workspaces.handleHostEnvelope(envelope) + }, + onConnected: () => { + sessions.handleConnected() + workspaces.handleConnected() + }, }) ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop') } diff --git a/packages/client/runtime/src/client/ordered-baseline.ts b/packages/client/runtime/src/client/ordered-baseline.ts new file mode 100644 index 0000000000..b7fdcd545e --- /dev/null +++ b/packages/client/runtime/src/client/ordered-baseline.ts @@ -0,0 +1,43 @@ +/** + * Merge an authoritative baseline without moving identities already visible to + * the client. Baseline-only identities are inserted relative to the nearest + * following known identity; identities absent from the baseline are removed. + * + * @param current - the established client order. + * @param baseline - the latest authoritative rows. + * @param keyOf - stable identity selector. + * @returns baseline-valued rows with the established relative order retained. + */ +export function mergeOrderedBaseline( + current: readonly T[], + baseline: readonly T[], + keyOf: (value: T) => unknown, +): T[] { + const baselineByKey = new Map() + for (const value of baseline) baselineByKey.set(keyOf(value), value) + + const merged = current + .map(value => baselineByKey.get(keyOf(value))) + .filter((value): value is T => value !== undefined) + const mergedKeys = new Set(merged.map(keyOf)) + + for (let index = 0; index < baseline.length; index++) { + const value = baseline[index] + /* v8 ignore next -- dense-array guard: index is bounded by baseline.length. */ + if (value === undefined || mergedKeys.has(keyOf(value))) continue + let insertion = merged.length + for (let following = index + 1; following < baseline.length; following++) { + const candidate = baseline[following] + /* v8 ignore next -- dense-array guard: following is bounded by baseline.length. */ + if (candidate === undefined) continue + const known = merged.findIndex(item => keyOf(item) === keyOf(candidate)) + if (known !== -1) { + insertion = known + break + } + } + merged.splice(insertion, 0, value) + mergedKeys.add(keyOf(value)) + } + return merged +} diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 08b80f2a26..78d1eeabf5 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -4,7 +4,9 @@ // string here (narrow to real brands when convenient). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { RpcError, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { + RpcError, SessionId, ToolCallView, ToolResultView, WorkspaceId, +} from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' /** Assistant content blocks sorted by what the UI cares about @@ -149,12 +151,58 @@ export interface PartialAssistant { /** History-open lifecycle of a Session window. */ export type OpenState = 'cold' | 'loading' | 'open' | 'error' +/** + * Input-area shape of an OPEN session, derived at snapshot assembly (the one + * place that knows the predicate — consumers switch, never re-derive): + * + * - `blank`: no activity ever (no nodes, no partial, not running, no pending + * waits, no prompt attempt) — the UI renders the blank-session guidance + * hero. + * - `engaging`: the first prompt was initiated but no content landed yet — + * the UI holds the composer through the accept → running → first-event + * frames. Entered synchronously before prompt()'s first await. + * - `active`: content exists (nodes, partial, running turn, or pending + * waits) — the ordinary conversation view. + * + * Monotone within a session object: blank → engaging → active, no returns. + * A failed first prompt stays `engaging` (composer + error strip — retry + * semantics; bouncing back to the hero would discard the error context). + * Sessions whose window is not open (`loading`/`error`) are outside phase + * jurisdiction: consumers branch on {@link ConversationSnapshot.openState} + * first (phase still reports `active`-ish facts but must not be rendered). + */ +export type ComposerPhase = 'blank' | 'engaging' | 'active' + /** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */ export interface PromptError { op: 'send' | 'stop' error: RpcError } +/** Workspace target of a frontend-only Session. */ +export type SessionIntentTarget = + | { kind: 'workspace'; workspaceId: WorkspaceId } + | { kind: 'workspace-intent' } + +/** Publication state owned by a frontend Session before it joins the Host. */ +export interface SessionIntentSnapshot { + target: SessionIntentTarget + phase: 'ready' | 'connecting' + error?: { step: 'session'; message: string } +} + +/** One editable prompt retained by its Session until the Host accepts it. */ +export interface PendingPrompt { + text: string + phase: 'editing' | 'sending' | 'failed' + /** Failed prerequisite retried before sending, or the send itself. */ + retry: 'connect' | 'send' + /** Workspace needed when retrying Session attachment. */ + workspaceId?: WorkspaceId + /** Last failure diagnostic, absent while editing or sending. */ + error?: string +} + /** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */ export interface ConversationSnapshot { sessionId: SessionId @@ -166,6 +214,8 @@ export interface ConversationSnapshot { runningCalls: readonly RunningToolCall[] pending: readonly PendingInteraction[] running: boolean + /** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */ + composerPhase: ComposerPhase /** Set after host/session-removed; the UI grays out and disables input. */ removed: boolean openState: OpenState @@ -173,5 +223,9 @@ export interface ConversationSnapshot { hasMore: boolean loadingOlder: boolean promptError: PromptError | null + /** Frontend-only publication state; null for a Host-connected Session. */ + intent: SessionIntentSnapshot | null + /** Session-owned editable prompt waiting for connection, attachment, or send. */ + pendingPrompt: PendingPrompt | null lastAgentError: string | null } diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index c6bd572ea7..3fd9af5d65 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -1,6 +1,6 @@ // flattenLineage: summaries -> flat list with lineage indentation (pure function). -// Roots sort by updatedAt desc, DFS expansion with children in the same order; orphaned lineage -// degrades to root level; cycles fail soft and emit as roots. +// The input order is authoritative; lineage only makes each child adjacent to its parent. +// Orphaned lineage degrades to root level; cycles fail soft and emit as roots. import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' @@ -22,8 +22,9 @@ export interface SessionListEntry { } /** - * summaries -> flat list with lineage indentation (pure; roots by updatedAt - * desc, DFS children in the same order, orphans degrade to roots). + * Summaries -> flat list with lineage indentation. Root and sibling order + * follows the established input order; this projection never re-sorts a + * hydrated list from mutable timestamps. * @param summaries - the host's session.list items. * @returns display rows in render order. */ @@ -43,9 +44,6 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess } } - const byUpdatedDesc = (a: TitledSessionSummary, b: TitledSessionSummary): number => b.updatedAt - a.updatedAt - roots.sort(byUpdatedDesc) - const out: SessionListEntry[] = [] const visited = new Set() const walk = (s: TitledSessionSummary, depth: number): void => { @@ -57,7 +55,6 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess out.push({ ...s, depth }) const kids = children.get(s.sessionId) if (kids === undefined) return - kids.sort(byUpdatedDesc) for (const kid of kids) walk(kid, depth + 1) } for (const root of roots) walk(root, 0) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index b65935a97d..907fc961f6 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -2,22 +2,51 @@ // dispatch entry + list state, constructed and held by SessionsService (one per client runtime). // List data never enters zustand; React connects via subscribe/getListSnapshot. -import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' +import { mergeOrderedBaseline } from '../ordered-baseline.ts' import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' import { flattenLineage } from './lineage.ts' import { Notifier } from './notifier.ts' import { Session } from './session.ts' +import type { SessionIntentSnapshot, SessionIntentTarget } from './conversation.ts' + +/** + * List arrival lifecycle, orthogonal to the pull-activity `state` axis: + * `pending` (no successful pull yet — an empty items array means "nothing + * arrived", not "nothing exists") → `ready` (at least one pull landed). + * Monotone: `ready` never steps back — later pull failures and reconnect + * re-pulls ride the `state`/`error` axis, which is where failure is modeled + * (no `error` phase here; that would duplicate `state`). + */ +export type SessionListPhase = 'pending' | 'ready' + +/** Session-owned frontend Intent projected into the global list snapshot. */ +export interface SessionIntentListSnapshot extends SessionIntentSnapshot { + sessionId: SessionId + prompt: string +} /** Immutable session-list snapshot for useSessionList. */ export interface SessionListSnapshot { items: readonly SessionListEntry[] + /** Selected real or frontend-only Session id. */ + current: SessionId | undefined + /** Sole page-local frontend Session projection; its state remains owned by Session. */ + intent: SessionIntentListSnapshot | undefined state: 'idle' | 'loading' | 'error' + /** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */ + phase: SessionListPhase error: RpcError | null } +type SessionListMutation = + | { kind: 'upsert'; summary: SessionSummary } + | { kind: 'remove'; sessionId: SessionId } + | { kind: 'status'; sessionId: SessionId; running: boolean } + /** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */ const PENDING_BUFFER_CAP = 32 @@ -39,8 +68,16 @@ export class SessionManager { private readonly titleSnapshots = new Map() private summaries: SessionSummary[] = [] private listState: 'idle' | 'loading' | 'error' = 'idle' + /** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */ + private listPhase: SessionListPhase = 'pending' private listError: RpcError | null = null private listInflight: Promise | null = null + /** Mutations arriving after a list request starts are replayed over its response. */ + private listMutations: SessionListMutation[] | null = null + + private selected: SessionId | undefined + private intentSessionId: SessionId | undefined + private stopIntentWatch: (() => void) | undefined private listSnapshotCache: SessionListSnapshot /** Entry-identity cache (§C.2 reference stability): list rebuilds reuse the previous entry @@ -52,10 +89,90 @@ export class SessionManager { this.listSnapshotCache = this.buildListSnapshot() }) - constructor(private readonly api: IApiClient) { + /** + * @param api - shared wire client. + * @param restoredSelection - persisted real-Session selection candidate. + */ + constructor( + private readonly api: IApiClient, + restoredSelection?: SessionId, + ) { + this.selected = restoredSelection this.listSnapshotCache = this.buildListSnapshot() } + // ---- Selection and client-local intents ---- + + /** + * Select a real Session and discard the unmaterialized intent. + * @param sessionId - listed real Session id. + */ + select(sessionId: SessionId): void { + if (!this.summaries.some(summary => summary.sessionId === sessionId)) { + throw new Error(`sessions.select: unknown session ${sessionId}`) + } + this.discardIntent() + this.selected = sessionId + this.notifier.notifyNow() + } + + /** Clear selection and abandon any frontend-only Session. */ + clearSelection(): void { + this.discardIntent() + this.selected = undefined + this.notifier.notifyNow() + } + + /** + * Start a frontend Session against a real or still-local Workspace target. + * @param target - real Workspace or the WorkspacesService-owned local target. + * @param prompt - optional prompt retained when retargeting from a picker. + * @returns the frontend Session object that owns the Intent. + */ + startIntent(target: SessionIntentTarget, prompt = ''): Session { + this.discardIntent() + const sessionId = `client-session-${crypto.randomUUID()}` as SessionId + const session = this.createSession(sessionId, { target, prompt }) + this.sessions.set(sessionId, session) + this.intentSessionId = sessionId + this.selected = sessionId + this.stopIntentWatch = session.subscribe(() => { + if (this.intentSessionId !== sessionId) return + if (session.getSnapshot().intent === null) { + this.intentSessionId = undefined + this.stopIntentWatch?.() + this.stopIntentWatch = undefined + } + this.notifier.markDirty() + }) + this.notifier.notifyNow() + return session + } + + /** + * Resolve the active frontend Session Intent. + * @returns the active frontend Session, if one remains selected. + */ + getIntent(): Session | undefined { + return this.intentSessionId === undefined ? undefined : this.sessions.get(this.intentSessionId) + } + + /** + * Update the retained prompt of the active frontend Session. + * @param text - exact controlled-input value for the active frontend Session. + */ + updateIntent(text: string): void { + this.getIntent()?.updatePendingPrompt(text) + } + + private discardIntent(): void { + const session = this.getIntent() + this.intentSessionId = undefined + this.stopIntentWatch?.() + this.stopIntentWatch = undefined + session?.abandonIntent() + } + // ---- Instance management ---- /** @@ -67,7 +184,7 @@ export class SessionManager { get(sessionId: SessionId): Session { let session = this.sessions.get(sessionId) if (session === undefined) { - session = new Session(sessionId, this.api) + session = this.createSession(sessionId) this.sessions.set(sessionId, session) // Sync the running bit from the list snapshot into the new instance (consistency when the list precedes open). const summary = this.summaries.find(s => s.sessionId === sessionId) @@ -82,6 +199,22 @@ export class SessionManager { return session } + private createSession( + sessionId: SessionId, + intent?: { target: SessionIntentTarget; prompt: string }, + ): Session { + return new Session(sessionId, this.api, { + ...(intent === undefined ? {} : { intent }), + onPublished: (published) => { + this.sessions.set(published.sessionId, published) + this.recordMutation({ + kind: 'upsert', + summary: { sessionId: published.sessionId, updatedAt: Date.now(), running: false }, + }) + }, + }) + } + // ---- List surface ---- /** Full refresh via session.list (single-flight: an in-flight call is reused). */ @@ -89,13 +222,21 @@ export class SessionManager { if (this.listInflight !== null) return this.listInflight this.listState = 'loading' this.listError = null + const established = this.summaries + const mutations: SessionListMutation[] = [] + this.listMutations = mutations this.notifier.markDirty() this.listInflight = (async () => { try { const { result } = await this.api.sessions.list({}) if (result.ok) { - this.summaries = result.value.items + let summaries = this.listPhase === 'pending' + ? result.value.items + : mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId) + for (const mutation of mutations) summaries = applyMutation(summaries, mutation) + this.summaries = summaries this.listState = 'idle' + this.listPhase = 'ready' // Push running bits down to instantiated Sessions (the list is the authoritative summary source). for (const s of this.summaries) this.sessions.get(s.sessionId)?.handleRunning(s.running) } else { @@ -108,6 +249,7 @@ export class SessionManager { /* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */ this.listError = folded.ok ? null : folded.error } finally { + this.listMutations = null this.listInflight = null this.notifier.markDirty() } @@ -118,18 +260,37 @@ export class SessionManager { /** * Contract session.create; on success merge into summaries immediately (no * wait for the next refresh). - * @param cwd - optional working directory for the new session. + * @param opts - target workspace or working directory, plus an optional caller-owned id. * @returns the create result. */ - async create(cwd?: string): Promise> { + async create( + opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}, + ): Promise> { try { - const { result } = await this.api.sessions.create(cwd === undefined ? {} : { cwd }) - if (result.ok && !this.summaries.some(s => s.sessionId === result.value.sessionId)) { - this.summaries = [ - { sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, ...(cwd !== undefined ? { cwd } : {}) }, - ...this.summaries, - ] - this.notifier.markDirty() + const payload = opts.workspaceId !== undefined + ? { workspaceId: opts.workspaceId, ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }) } + : { + ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), + ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }), + } + const { result } = await this.api.sessions.create(payload) + if (result.ok) { + this.recordMutation({ kind: 'upsert', summary: { + sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, + ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), + } }) + } else { + const publishedSessionId = workspaceAttachSessionId(result.error) + // Publication precedes attachment. The error's id is a real Session, + // so expose it immediately as Ungrouped while the caller keeps the + // prompt buffer and decides whether to retry attachment. + if (publishedSessionId !== undefined) { + this.recordMutation({ kind: 'upsert', summary: { + sessionId: publishedSessionId, + updatedAt: Date.now(), + running: false, + } }) + } } return result } catch (error) { @@ -137,6 +298,23 @@ export class SessionManager { } } + /** + * Insert-or-enrich a locally synthesized summary: a new id prepends; an + * existing entry only gains fields it lacks (the session-added frame and the + * create() echo race — whichever lands second must fill the placeholder's + * missing cwd/parentSessionId, never overwrite list-refresh data). + */ + private mergeSummary(summary: SessionSummary): void { + this.recordMutation({ kind: 'upsert', summary }) + } + + /** Apply immediately and retain for replay when a list response is in flight. */ + private recordMutation(mutation: SessionListMutation): void { + this.listMutations?.push(mutation) + this.summaries = applyMutation(this.summaries, mutation) + this.notifier.markDirty() + } + // ---- Subscription surface (for useSessionList) ---- /** @@ -216,31 +394,24 @@ export class SessionManager { const frame = envelope.payload switch (frame.type) { case 'host/session-added': { - if (!this.summaries.some(s => s.sessionId === frame.sessionId)) { - this.summaries = [ - { - sessionId: frame.sessionId, updatedAt: Date.now(), running: false, - ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}), - }, - ...this.summaries, - ] - this.notifier.markDirty() - } + this.mergeSummary({ + sessionId: frame.sessionId, updatedAt: Date.now(), running: false, + ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}), + ...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}), + }) + this.sessions.get(frame.sessionId)?.handlePublished() return } case 'host/session-removed': { - this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId) + this.recordMutation({ kind: 'remove', sessionId: frame.sessionId }) this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation this.titleSnapshots.delete(frame.sessionId) - this.notifier.markDirty() return } case 'host/session-status': { - this.summaries = this.summaries.map(s => - s.sessionId === frame.sessionId && s.running !== frame.running ? { ...s, running: frame.running } : s) + this.recordMutation({ kind: 'status', sessionId: frame.sessionId, running: frame.running }) this.sessions.get(frame.sessionId)?.handleRunning(frame.running) - this.notifier.markDirty() return } case 'host/agent-error': { @@ -252,7 +423,7 @@ export class SessionManager { } } - /** After each connection generation (first connect included): refresh the list + resync opened instances (reconnect = rebuild). */ + /** After each connection generation: refresh the session baseline and rebuild opened windows. */ handleConnected(): void { void this.refreshList() for (const session of this.sessions.values()) void session.resync() @@ -281,6 +452,57 @@ export class SessionManager { } const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i]) if (!sameOrder) this.itemsCache = items - return { items: this.itemsCache, state: this.listState, error: this.listError } + const intentSession = this.getIntent() + const intentState = intentSession?.getSnapshot() + const intent = intentSession !== undefined + && intentState !== undefined && intentState.intent !== null && intentState.pendingPrompt !== null + ? { + sessionId: intentSession.sessionId, + ...intentState.intent, + prompt: intentState.pendingPrompt.text, + } + : undefined + const selected = this.selected + const current = selected !== undefined && ( + intent?.sessionId === selected || items.some(item => item.sessionId === selected) + ) ? selected : undefined + return { + items: this.itemsCache, + current, + intent, + state: this.listState, + phase: this.listPhase, + error: this.listError, + } } } + +/** Apply one list mutation without deriving display order. */ +function applyMutation(summaries: readonly SessionSummary[], mutation: SessionListMutation): SessionSummary[] { + switch (mutation.kind) { + case 'upsert': { + const existing = summaries.find(summary => summary.sessionId === mutation.summary.sessionId) + if (existing === undefined) return [mutation.summary, ...summaries] + const filled: SessionSummary = { + ...existing, + ...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}), + ...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined + ? { parentSessionId: mutation.summary.parentSessionId } : {}), + } + if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId) return [...summaries] + return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary) + } + case 'remove': + return summaries.filter(summary => summary.sessionId !== mutation.sessionId) + case 'status': + return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.running !== mutation.running + ? { ...summary, running: mutation.running } + : summary) + } +} + +/** Temporary source-plane bridge while the Host contract and client project build independently. */ +function workspaceAttachSessionId(error: RpcError): SessionId | undefined { + const candidate = error as unknown as { code: string; details: { sessionId?: SessionId } } + return candidate.code === 'workspace-attach-failed' ? candidate.details.sessionId : undefined +} diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index af07362f08..845292a481 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -15,12 +15,16 @@ * survives frozen (read-only view) until the stage moves on. */ import type { Context, Fiber } from 'cordis' -import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' import { SessionManager } from './manager.ts' +import type { + SessionIntentListSnapshot, SessionListPhase, +} from './manager.ts' import type { Session } from './session.ts' +import type { SessionIntentTarget } from './conversation.ts' /** Session list row projected from the host list RPC plus live stream increments. */ export interface SessionSummary { @@ -40,7 +44,36 @@ export interface SessionSummary { * the single useSessions standard hook reads list and selection together — * sidebar highlighting and SessionProvider share one fact source). */ -export interface SessionListState { ids: SessionId[]; byId: Record; current: SessionId | undefined } +export interface SessionListState { + ids: SessionId[] + byId: Record + current: SessionId | undefined + /** Frontend Session Intent projected from its owning Session object. */ + intent: SessionIntentListSnapshot | undefined + /** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */ + phase: SessionListPhase +} + +/** Structured session-create failure preserving partial publication identity. */ +export class SessionCreateError extends Error { + override readonly name = 'SessionCreateError' + /** Definitely published by Host before Workspace attachment failed. */ + readonly publishedSessionId: SessionId | undefined + + /** + * @param rpcError - Host business or folded transport error. + * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation. + */ + constructor( + readonly rpcError: RpcError, + readonly requestedSessionId: SessionId | undefined, + ) { + super(`session create failed: ${rpcError.code}: ${rpcError.message}`) + this.publishedSessionId = rpcError.code === 'workspace-attach-failed' + ? rpcError.details.sessionId + : undefined + } +} /** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */ export interface SessionBinding { @@ -64,6 +97,20 @@ export function scopeOf(ctx: Context): SessionId | undefined { /** Shared no-op plugin backing each session scope fiber. */ function sessionScope(): void {} +/** + * Workspace display title of a session cwd: the path's last non-empty + * segment (both separators accepted; trailing separators ignored), or '' + * for separator-only paths — callers own their fallback (session id, raw + * cwd, default-directory copy). The repo-wide single basename derivation — + * every surface naming a workspace (picker rows, toggle labels, list titles) + * calls this instead of re-splitting paths. + * @param cwd - workspace directory path. + * @returns basename title, or '' when no non-empty segment exists. + */ +export function workspaceTitleOf(cwd: string): string { + return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? '' +} + /** * Display title projection: durable title, project directory basename, then * the raw id. @@ -71,8 +118,8 @@ function sessionScope(): void {} function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string { if (title !== undefined) return title if (cwd !== undefined && cwd !== '') { - const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() - if (base !== undefined && base !== '') return base + const base = workspaceTitleOf(cwd) + if (base !== '') return base } return id } @@ -89,8 +136,8 @@ interface ScopeRecord { export class SessionsService { /** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */ readonly list: SnapshotStore - /** The object-layer instance cluster and frame dispatch entry (wired to the connection by the runtime apply). */ - readonly manager: SessionManager + /** The object-layer instance cluster and frame dispatch entry. */ + private readonly manager: SessionManager /** * Persisted selection cell (the durable half of `list.current`). Private on @@ -117,12 +164,14 @@ export class SessionsService { * @param ctx - client root context (scope fibers mount under it). * @param api - wire client shared with every Session. */ - constructor(private readonly rootCtx: Context, private readonly api: IApiClient) { - this.manager = new SessionManager(api) + constructor(private readonly rootCtx: Context, api: IApiClient) { this.selection = createSnapshotStore<{ sessionId?: SessionId }>( {}, { persist: { name: 'dsh.sessions.current' } }) - this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined }) + this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId) + this.list = createSnapshotStore({ + ids: [], byId: {}, current: undefined, intent: undefined, phase: 'pending', + }) // The manager owns wire truth; the store is its projection. Manager // notifications are already microtask-batched. this.manager.subscribe(() => { this.projectList() }) @@ -142,56 +191,88 @@ export class SessionsService { * @param id - session id (must exist in the list store). */ open(id: SessionId): void { - if (this.list.getSnapshot().byId[id] === undefined) { - throw new Error(`sessions.open: unknown session ${id}`) - } - this.selection.update((draft) => { draft.sessionId = id }) - this.list.update((draft) => { draft.current = id }) + this.manager.select(id) } /** * Clear the current selection so the layout shows the no-session empty - * state. Wipes the persisted selection too — a reload stays on empty until - * the user opens or starts a session. Staging holds the previous occupant - * across the blank (same masked-gap rule as a transient list miss). + * state (new-session affordance and the workspace preselection flow). + * Wipes the persisted selection too — a reload stays on empty until the + * user opens or starts a session. The staged scope keeps its frozen view + * per the masked-gap contract until the next open() moves the stage. */ clear(): void { - this.selection.set({}) - this.list.update((draft) => { draft.current = undefined }) + this.manager.clearSelection() + } + + /** + * Start or retarget the sole client-local Session intent. + * @param target - resolved real or frontend-only Workspace target. + * @param prompt - optional prompt retained across retargeting. + * @returns the frontend Session object that owns the Intent. + */ + startIntent(target: SessionIntentTarget, prompt = ''): Session { + return this.manager.startIntent(target, prompt) + } + + /** + * Resolve the active frontend Session Intent. + * @returns the active frontend Session object, if one exists. + */ + intent(): Session | undefined { + return this.manager.getIntent() + } + + /** + * Update the retained prompt of the active frontend Session. + * @param text - exact controlled-input value for the current Session Intent. + */ + updateIntent(text: string): void { + this.manager.updateIntent(text) + } + + /** + * Refresh the real Session baseline, reusing an in-flight pull. + * @returns completion of the current or newly started baseline pull. + */ + refresh(): Promise { + return this.manager.refreshList() + } + + /** + * Route a mux stream envelope into the Session object layer. + * @param envelope - validated mux stream envelope. + */ + handleMuxEnvelope(envelope: Parameters[0]): void { + this.manager.handleMuxEnvelope(envelope) + } + + /** + * Route a Host stream envelope into the Session object layer. + * @param envelope - validated Host stream envelope. + */ + handleHostEnvelope(envelope: Parameters[0]): void { + this.manager.handleHostEnvelope(envelope) + } + + /** Rebuild the Session baseline and every opened window after connection. */ + handleConnected(): void { + this.manager.handleConnected() } /** * Create a session on the host. - * @param opts - creation options (project directory). + * @param opts - target workspace or directory and an optional preallocated id. * @returns the new session id. + * @throws {SessionCreateError} with the requested id and, after an attach + * failure, the definitely published id. */ - async create(opts: { cwd?: string } = {}): Promise { - const result = await this.manager.create(opts.cwd) - if (!result.ok) throw new Error(`session create failed: ${result.error.code}: ${result.error.message}`) + async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise { + const result = await this.manager.create(opts) + if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId) return result.value.sessionId } - /** - * Create a workspace folder under the host process cwd and a session in it. - * Name is a single path segment (no separators); the host mkdir runs inside - * session.create. Caller opens the returned id when it wants the session staged. - * @param name - workspace folder basename. - * @returns the new session id. - */ - async createWorkspace(name: string): Promise { - const trimmed = name.trim() - if (trimmed === '') throw new Error('sessions.createWorkspace: name is required') - if (/[/\\]/.test(trimmed)) { - throw new Error('sessions.createWorkspace: name must not contain path separators') - } - const { result } = await this.api.host.describe({}) - if (!result.ok) { - throw new Error(`host.describe failed: ${result.error.code}: ${result.error.message}`) - } - const hostCwd = result.value.cwd.replace(/[/\\]+$/, '') - return this.create({ cwd: `${hostCwd}/${trimmed}` }) - } - /** * Resolve a session-scoped context view (use-and-discard). * @param id - session id. @@ -244,11 +325,12 @@ export class SessionsService { * failed one retries the next time current is touched). */ private followCurrent(): void { - const current = this.list.getSnapshot().current + const snapshot = this.list.getSnapshot() + const current = snapshot.current // A masked gap (current blanked while the selection's session is // transiently absent) holds the stage: tearing down on the gap would // destroy exactly the frozen scope the mask exists to preserve. - if (current === undefined || current === this.watched) return + if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return this.watched = current this.sweepDeferred() const record = this.resolve(current) @@ -300,7 +382,7 @@ export class SessionsService { /** Project the manager's list snapshot into the store (title derivation is display-only). */ private projectList(): void { - const items = this.manager.getListSnapshot().items + const { items, current, intent, phase } = this.manager.getListSnapshot() const ids: SessionId[] = [] const byId: Record = {} for (const entry of items) { @@ -315,11 +397,13 @@ export class SessionsService { ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), } } - // current = the persisted selection, masked while its session is absent - // (falls to the empty state; resurfaces if the session returns). - const selected = this.selection.getSnapshot().sessionId - const current = selected !== undefined && byId[selected] !== undefined ? selected : undefined - this.list.set({ ids, byId, current }) + const persisted = this.selection.getSnapshot().sessionId + if (intent?.sessionId === current) { + if (persisted !== undefined) this.selection.set({}) + } else if (current !== undefined && byId[current] !== undefined && persisted !== current) { + this.selection.set({ sessionId: current }) + } + this.list.set({ ids, byId, current, intent, phase }) this.pruneScopes(byId) } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 0681e2bb5f..396d0aa798 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -4,14 +4,15 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, - SessionId, ToolEventView, + SessionId, ToolEventView, WorkspaceId, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ObservableSnapshot } from '../contract/store.ts' import type { - ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall, + ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, PendingPrompt, + PromptError, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget, } from './conversation.ts' import type { PendingInteraction } from './pending.ts' import { PendingWait } from './pending.ts' @@ -22,6 +23,12 @@ import { PartialAccumulator } from './partial.ts' /** Messages requested per history page. */ export const PAGE_MESSAGES = 50 +/** Optional frontend Intent and publication observer for a Session object. */ +export interface SessionOptions { + intent?: { target: SessionIntentTarget; prompt: string } + onPublished?(session: Session): void +} + /** * Owns a session's event window, derived conversation state, and observable * snapshot. React bindings remain outside this data layer. @@ -60,8 +67,18 @@ export class Session implements ObservableSnapshot { private frozenRev = 0 private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null private running = false + /** + * Sticky send marker, private input of the composerPhase derivation: set + * synchronously before prompt()'s first await, never reset — the blank → + * engaging edge of the phase machine (see ComposerPhase). + */ + private promptAttempted = false private removed = false private promptError: PromptError | null = null + private intent: SessionIntentSnapshot | null + private pendingPrompt: PendingPrompt | null + private intentGeneration = 0 + private published: boolean private lastAgentError: string | null = null /** Live events buffered during open/resync and stitched by sequence once history lands. */ private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = [] @@ -75,7 +92,23 @@ export class Session implements ObservableSnapshot { this.snapshotCache = this.buildSnapshot() }) - constructor(readonly sessionId: SessionId, private readonly api: IApiClient) { + /** + * @param sessionId - stable identity shared by the frontend Intent and Host entity. + * @param api - shared wire client. + * @param options - optional frontend-only initial state and publication observer. + */ + constructor( + readonly sessionId: SessionId, + private readonly api: IApiClient, + private readonly options: SessionOptions = {}, + ) { + this.intent = options.intent === undefined + ? null + : { target: options.intent.target, phase: 'ready' } + this.pendingPrompt = options.intent === undefined + ? null + : { text: options.intent.prompt, phase: 'editing', retry: 'send' } + this.published = options.intent === undefined this.snapshotCache = this.buildSnapshot() } @@ -90,6 +123,10 @@ export class Session implements ObservableSnapshot { async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise> { this.promptError = null this.lastAgentError = null + // Synchronous, before the first await: the blank → engaging edge must be + // visible on the session area's very first frame when a caller sends + // ahead of navigation (first-send flow). + this.promptAttempted = true this.notifier.markDirty() let result: RpcResult<{ accepted: true }> try { @@ -104,6 +141,60 @@ export class Session implements ObservableSnapshot { return result } + /** + * Update this Session's retained prompt while it remains editable. + * @param text - exact controlled value of this Session's retained prompt. + */ + updatePendingPrompt(text: string): void { + const pending = this.pendingPrompt + if (pending === null || pending.phase === 'sending') return + this.pendingPrompt = { ...pending, text } + this.notifier.notifyNow() + } + + /** + * Connect this frontend Session to a real Workspace and flush its retained prompt. + * @param workspaceId - real Workspace target. + */ + connect(workspaceId: WorkspaceId): void { + const intent = this.intent + const pending = this.pendingPrompt + if (intent === null || intent.phase === 'connecting' || pending === null || pending.text.trim() === '') return + const connecting: SessionIntentSnapshot = { + target: { kind: 'workspace', workspaceId }, + phase: 'connecting', + } + const queued: PendingPrompt = { + ...pending, + phase: 'sending', + retry: 'connect', + workspaceId, + } + delete queued.error + this.intent = connecting + this.pendingPrompt = queued + this.notifier.notifyNow() + void this.flushPendingPrompt() + } + + /** Stop a superseded frontend Intent from automatically sending after publication. */ + abandonIntent(): void { + if (this.intent === null) return + this.intentGeneration += 1 + } + + /** Retry this Session's retained prompt from its failed prerequisite. */ + retryPendingPrompt(): void { + const pending = this.pendingPrompt + if (pending === null || pending.phase === 'sending' || pending.text.trim() === '') return + const sending: PendingPrompt = { ...pending, phase: 'sending' } + delete sending.error + this.pendingPrompt = sending + this.promptError = null + this.notifier.markDirty() + void this.flushPendingPrompt() + } + /** * Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot). * @returns the cancel result. @@ -271,6 +362,11 @@ export class Session implements ObservableSnapshot { this.notifier.markDirty() } + /** Mark that Host publication is known without resolving an uncertain local create response. */ + handlePublished(): void { + this.markPublished() + } + /** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */ handleRemoved(): void { this.removed = true @@ -304,6 +400,112 @@ export class Session implements ObservableSnapshot { this.pendingRev++ } + /** Advance the retained prompt through Session attachment and submission. */ + private async flushPendingPrompt(): Promise { + const pending = this.pendingPrompt + if (pending?.phase === 'sending') { + const ready = pending.retry === 'connect' + ? await this.attachPendingPrompt(pending) + : pending + if (ready !== null) await this.sendPendingPrompt(ready) + } + } + + /** Complete the Host Session prerequisite and return the prompt's send step. */ + private async attachPendingPrompt(pending: PendingPrompt): Promise { + const workspaceId = pending.workspaceId + if (workspaceId === undefined) throw new Error('a Session attachment requires a Workspace id') + const originIntent = this.intent + const originGeneration = this.intentGeneration + let result: RpcResult<{ sessionId: SessionId }> + try { + result = (await this.api.sessions.create({ sessionId: this.sessionId, workspaceId })).result + } catch (error) { + result = transportError(error) + } + let ready: PendingPrompt | null = null + if (result.ok) { + ready = this.completePendingAttachment(pending, originIntent, originGeneration) + } else { + this.failPendingAttachment(pending, originIntent, originGeneration, result.error) + } + this.notifier.markDirty() + return ready + } + + /** Move a published Session to the send step unless its page intent was superseded. */ + private completePendingAttachment( + pending: PendingPrompt, + originIntent: SessionIntentSnapshot | null, + originGeneration: number, + ): PendingPrompt | null { + this.markPublished() + this.intent = null + this.promptAttempted = true + const superseded = originIntent !== null && originGeneration !== this.intentGeneration + const next: PendingPrompt = { + ...pending, + phase: superseded ? 'failed' : 'sending', + retry: 'send', + ...(superseded ? { error: 'Message was not sent because you navigated away.' } : {}), + } + if (!superseded) delete next.error + this.pendingPrompt = next + return superseded ? null : next + } + + /** Retain the prompt at the failed attachment step that owns the retry. */ + private failPendingAttachment( + pending: PendingPrompt, + originIntent: SessionIntentSnapshot | null, + originGeneration: number, + error: RpcError, + ): void { + const partiallyPublished = error.code === 'workspace-attach-failed' + if (partiallyPublished) { + this.markPublished() + this.intent = null + this.promptAttempted = true + } + const activeIntent = !partiallyPublished + && originIntent !== null + && originGeneration === this.intentGeneration + && this.intent === originIntent + if (activeIntent) { + this.intent = { + target: originIntent.target, + phase: 'ready', + error: { step: 'session', message: rpcErrorMessage(error) }, + } + this.pendingPrompt = { ...pending, phase: 'editing' } + } + if (!activeIntent && (partiallyPublished || originIntent === null) && this.pendingPrompt === pending) { + this.pendingPrompt = { ...pending, phase: 'failed', error: rpcErrorMessage(error) } + } + } + + /** Submit the retained prompt and keep it only when Host rejects the send. */ + private async sendPendingPrompt(pending: PendingPrompt): Promise { + const result = await this.prompt([{ type: 'text', text: pending.text.trim() }], 'queue') + if (this.pendingPrompt === pending) { + this.pendingPrompt = result.ok + ? null + : { + ...pending, + retry: 'send', + phase: 'failed', + error: rpcErrorMessage(result.error), + } + this.notifier.markDirty() + } + } + + private markPublished(): void { + if (this.published) return + this.published = true + this.options.onPublished?.(this) + } + /** @param generation - openGeneration at launch; every await re-checks it and a stale pass * drops all writes (resync superseded this open — its outcome belongs to a dead connection). */ private async doOpen(generation: number): Promise { @@ -520,21 +722,47 @@ export class Session implements ObservableSnapshot { if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) { this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] } } + const partial = this.partial?.toPartial() ?? null return { sessionId: this.sessionId, nodes, foldDegraded: degraded, - partial: this.partial?.toPartial() ?? null, + partial, runningCalls: this.callsCache.value, pending: this.pendingCache.value, running: this.running, + composerPhase: derivePhase( + nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0, + this.promptAttempted, + ), removed: this.removed, openState: this.openState, openError: this.openError, hasMore: this.hasMore, loadingOlder: this.loadingOlder, promptError: this.promptError, + intent: this.intent, + pendingPrompt: this.pendingPrompt, lastAgentError: this.lastAgentError, } } } + +function rpcErrorMessage(error: RpcError): string { + return `${error.code}: ${error.message}` +} + +/** + * The composerPhase judgment — the single site that knows the predicate + * (consumers switch on the result, never re-derive). Monotone per session + * object: `hasContent` only grows within a window and `promptAttempted` is + * sticky, so blank → engaging → active never steps back; a failed first + * prompt stays engaging (retry semantics — see ComposerPhase). + * @param hasContent - any conversation material exists (nodes, partial, running turn, pending waits). + * @param promptAttempted - a prompt was initiated on this session object. + * @returns the derived phase. + */ +function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPhase { + if (hasContent) return 'active' + return promptAttempted ? 'engaging' : 'blank' +} diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 0930787e0a..2a19dcef56 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -235,13 +235,17 @@ export class SlotsService extends Service { } } - /** Build (once) the host face the installed renderer reads; sessions resolve lazily at first render. */ + /** Build once after both object-layer services mount; session cells still resolve lazily. */ private hostFace(): SlotRendererHost { if (this._host !== undefined) return this._host const sessions = this.ctx.get('sessions') if (sessions === undefined) { throw new Error("renderSlot('root') before the sessions service mounted — boot order puts runtime apply first") } + const workspaces = this.ctx.get('workspaces') + if (workspaces === undefined) { + throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first") + } // Identity-stable view: current rides the list snapshot (arbitrated), but // the provider consumes it as its own observable; one cached object keeps // the renderer's per-source hook cache stable. @@ -262,6 +266,7 @@ export class SlotsService extends Service { current, cell: id => sessions.cell(id), }, + workspaces: { list: workspaces.list }, } return this._host } diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts new file mode 100644 index 0000000000..6db4e54c79 --- /dev/null +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -0,0 +1,243 @@ +/** Workspace baseline, incremental-frame, and unary-action owner. */ + +import type { + HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, WorkspaceView, +} from '@deepseek-ai/dsh-client-connection/client' +import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' +import { mergeOrderedBaseline } from '../ordered-baseline.ts' +import { Notifier } from '../sessions/notifier.ts' +import { + Workspace, type WorkspaceCreateInput, type WorkspaceIntentSnapshot, +} from './workspace.ts' + +export type { WorkspaceIntentSnapshot } from './workspace.ts' + +/** Monotone workspace-list arrival lifecycle. */ +export type WorkspaceListPhase = 'pending' | 'ready' + +/** Immutable workspace-list snapshot. */ +export interface WorkspaceListSnapshot { + items: readonly WorkspaceView[] + /** The sole page-local Workspace intent; never persisted or sent over the Host stream. */ + intent: WorkspaceIntentSnapshot | undefined + state: 'idle' | 'loading' | 'error' + phase: WorkspaceListPhase + error: RpcError | null +} + +/** Workspace object cluster driven by one list baseline and changed-frame upserts. */ +export class WorkspaceManager { + private items: Workspace[] = [] + private intent: Workspace | undefined + private itemViewsSource: readonly Workspace[] | null = null + private itemViewsCache: readonly WorkspaceView[] = [] + private state: WorkspaceListSnapshot['state'] = 'idle' + private phase: WorkspaceListPhase = 'pending' + private error: RpcError | null = null + private inflight: Promise | null = null + private refreshFrames: WorkspaceView[] | null = null + private snapshotCache: WorkspaceListSnapshot + private readonly notifier = new Notifier(() => { + this.snapshotCache = this.buildSnapshot() + }) + + /** @param api - shared wire client. */ + constructor(private readonly api: IApiClient) { + this.snapshotCache = this.buildSnapshot() + } + + /** + * Replace the current client-local Workspace intent object. + * @param name - directory/display name used if the intent is materialized. + * @returns the new intent snapshot. + */ + startIntent(name = 'workspace'): WorkspaceIntentSnapshot { + this.intent = new Workspace(this.api, { name }) + this.notifier.notifyNow() + return this.intent.getSnapshot().intent as WorkspaceIntentSnapshot + } + + /** Discard the current client-local Workspace intent. */ + discardIntent(): void { + if (this.intent === undefined) return + this.intent = undefined + this.notifier.notifyNow() + } + + /** + * Materialize the current Workspace intent through the ordinary Host create seam. + * A superseded intent is never cleared by an older completion. + * @returns the Host create result, or undefined when no intent exists. + */ + async materializeIntent(): Promise | undefined> { + const intent = this.intent + if (intent?.getSnapshot().intent?.phase !== 'ready') return undefined + const completion = intent.materialize() + if (completion === undefined) return undefined + this.notifier.notifyNow() + const result = await completion + if (result.ok) { + this.upsert(result.value.workspace, intent) + if (this.intent === intent) this.intent = undefined + } + this.notifier.markDirty() + return result + } + + /** + * Refresh from workspace.list. The first successful response establishes + * Host order; later responses update membership and values without moving + * identities already visible to the client. Frames arriving during the RPC + * are replayed over its response. + * @returns the shared in-flight refresh. + */ + refresh(): Promise { + if (this.inflight !== null) return this.inflight + this.state = 'loading' + this.error = null + const established = this.itemViews() + const frames: WorkspaceView[] = [] + this.refreshFrames = frames + this.notifier.markDirty() + this.inflight = (async () => { + try { + const { result } = await this.api.workspace.list({}) + if (result.ok) { + let items = this.phase === 'pending' + ? result.value.items + : mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId) + for (const workspace of frames) items = upsertWorkspace(items, workspace) + this.installViews(items) + this.state = 'idle' + this.phase = 'ready' + } else { + this.state = 'error' + this.error = result.error + } + } catch (error) { + this.state = 'error' + const folded = transportError(error) + /* v8 ignore next -- transportError always returns the failure branch. */ + this.error = folded.ok ? null : folded.error + } finally { + this.refreshFrames = null + this.inflight = null + this.notifier.markDirty() + } + })() + return this.inflight + } + + /** + * Create or resolve a real Workspace, then publish its returned snapshot + * without waiting for the changed frame. + * @param input - name under workspaceRoot or an existing absolute path. + * @returns the wire result. + */ + async create(input: WorkspaceCreateInput): Promise> { + const workspace = new Workspace(this.api, input) + const completion = workspace.materialize() + if (completion === undefined) throw new Error('a local Workspace must be materializable') + const result = await completion + if (result.ok) this.upsert(result.value.workspace, workspace) + return result + } + + /** + * Host-frame entry. Non-workspace frames are ignored so the runtime can + * fan one host stream out to both object managers. + * @param envelope - host stream envelope. + */ + handleHostEnvelope(envelope: RpcRequest): void { + if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace) + } + + /** Re-pull the baseline after each connection generation. */ + handleConnected(): void { + void this.refresh() + } + + /** + * Subscribe to workspace snapshot invalidation. + * @param listener - snapshot invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void { + return this.notifier.subscribe(listener) + } + + /** + * Read the cached workspace snapshot after flushing pending notifications. + * @returns the cached workspace snapshot. + */ + getSnapshot(): WorkspaceListSnapshot { + this.notifier.ensureFresh() + return this.snapshotCache + } + + private buildSnapshot(): WorkspaceListSnapshot { + return { + items: this.itemViews(), + intent: this.intent?.getSnapshot().intent, + state: this.state, + phase: this.phase, + error: this.error, + } + } + + /** Upsert one Host view, optionally retaining the local object that materialized it. */ + private upsert(view: WorkspaceView, identity?: Workspace): void { + this.refreshFrames?.push(view) + const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId) + if (identity !== undefined) { + this.items = index === -1 + ? [identity, ...this.items] + : this.items.map((item, position) => position === index ? identity : item) + } else if (index === -1) { + this.items = [new Workspace(this.api, view), ...this.items] + } else { + this.items[index]?.adopt(view) + this.items = [...this.items] + } + this.notifier.markDirty() + } + + private installViews(views: readonly WorkspaceView[]): void { + const existing = new Map( + this.items.flatMap((workspace) => { + const view = workspace.getSnapshot().view + return view === undefined ? [] : [[view.workspaceId, workspace] as const] + }), + ) + const installed = new Map() + for (const view of views) { + const duplicate = installed.get(view.workspaceId) + if (duplicate !== undefined) { + duplicate.adopt(view) + continue + } + const workspace = existing.get(view.workspaceId) ?? new Workspace(this.api, view) + workspace.adopt(view) + installed.set(view.workspaceId, workspace) + } + this.items = [...installed.values()] + } + + private itemViews(): readonly WorkspaceView[] { + if (this.itemViewsSource === this.items) return this.itemViewsCache + this.itemViewsSource = this.items + this.itemViewsCache = this.items.flatMap((workspace) => { + const view = workspace.getSnapshot().view + return view === undefined ? [] : [view] + }) + return this.itemViewsCache + } +} + +/** Known ids retain their position; a newly created Workspace enters first. */ +function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceView): WorkspaceView[] { + const index = items.findIndex(item => item.workspaceId === workspace.workspaceId) + return index === -1 + ? [workspace, ...items] + : items.map((item, position) => position === index ? workspace : item) +} diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts new file mode 100644 index 0000000000..854c53a75f --- /dev/null +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -0,0 +1,164 @@ +/** WorkspacesService projects the Workspace object manager for UI consumers. */ + +import type { Context } from 'cordis' +import type { + IApiClient, RpcError, WorkspaceId, WorkspaceView, +} from '@deepseek-ai/dsh-client-connection/client' +import type { SnapshotStore } from '../contract/store.ts' +import { createSnapshotStore } from '../contract/store.ts' +import type { SessionsService } from '../sessions/service.ts' +import { WorkspaceManager, type WorkspaceIntentSnapshot, type WorkspaceListPhase } from './manager.ts' + +/** Workspace list plus the two-baseline readiness and default-target projection. */ +export interface WorkspaceListState { + items: readonly WorkspaceView[] + /** Sole client-local Workspace projection; its state remains owned by Workspace. */ + intent: WorkspaceIntentSnapshot | undefined + state: 'idle' | 'loading' | 'error' + phase: WorkspaceListPhase + error: RpcError | null + /** True only after both workspace.list and session.list have succeeded. */ + baselinesReady: boolean + /** Most recently active Workspace, derived without changing `items` order. */ + recentWorkspaceId: WorkspaceId | undefined +} + +/** Real Workspace object layer and Host actions. */ +export class WorkspacesService { + /** UI-facing immutable projection; the manager remains wire truth. */ + readonly list: SnapshotStore + /** Workspace baseline and frame owner. */ + private readonly manager: WorkspaceManager + private initialSessionResolved = false + private composingIntent = false + + /** + * @param ctx - client root context. + * @param api - shared wire client. + * @param sessions - lower-level Session service used for recency and cross-domain intent orchestration. + */ + constructor(ctx: Context, api: IApiClient, private readonly sessions: SessionsService) { + this.manager = new WorkspaceManager(api) + this.list = createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'pending', error: null, + baselinesReady: false, recentWorkspaceId: undefined, + }) + this.manager.subscribe(() => { if (!this.composingIntent) this.project() }) + this.sessions.list.subscribe(() => { if (!this.composingIntent) this.project() }) + ctx.reflect.provide('workspaces', this, undefined) + } + + /** + * Start the sole Session intent, resolving the default Workspace here. + * @param workspaceId - optional explicit real Workspace target. + * @param prompt - optional prompt retained while retargeting. + */ + startSession(workspaceId?: WorkspaceId, prompt = ''): void { + const snapshot = this.list.getSnapshot() + const resolved = workspaceId ?? snapshot.recentWorkspaceId ?? snapshot.items[0]?.workspaceId + this.composingIntent = true + try { + if (resolved === undefined) { + this.manager.startIntent() + this.sessions.startIntent({ kind: 'workspace-intent' }, prompt) + } else { + this.manager.discardIntent() + this.sessions.startIntent({ kind: 'workspace', workspaceId: resolved }, prompt) + } + } finally { + this.composingIntent = false + this.project() + } + } + + /** Connect the current frontend Workspace and Session, then flush the Session-owned prompt. */ + sendSession(): void { + const session = this.sessions.intent() + const target = session?.getSnapshot().intent?.target + if (session === undefined || target === undefined) return + if (target.kind === 'workspace') { + session.connect(target.workspaceId) + return + } + if (session.getSnapshot().pendingPrompt?.text.trim() === '') return + void this.manager.materializeIntent().then((result) => { + if (this.sessions.intent() !== session) return + if (result?.ok) { + session.connect(result.value.workspace.workspaceId) + } + }) + } + + /** + * Create a Workspace by name or register an existing path. + * @param input - exactly one Host create spelling. + * @returns the created or idempotently resolved Workspace. + */ + async create(input: { name: string } | { path: string }): Promise { + const result = await this.manager.create(input) + if (!result.ok) throw new Error(`workspace create failed: ${result.error.code}: ${result.error.message}`) + return result.value.workspace + } + + /** + * Refresh the workspace baseline, reusing an in-flight pull. + * @returns completion of the current or newly started workspace baseline pull. + */ + refresh(): Promise { + return this.manager.refresh() + } + + /** + * Route a Host stream envelope into the Workspace object layer. + * @param envelope - validated Host stream envelope. + */ + handleHostEnvelope(envelope: Parameters[0]): void { + this.manager.handleHostEnvelope(envelope) + } + + /** Rebuild the Workspace baseline after connection. */ + handleConnected(): void { + this.manager.handleConnected() + } + + private project(): void { + const workspace = this.manager.getSnapshot() + const sessions = this.sessions.list.getSnapshot() + if (workspace.intent !== undefined && sessions.intent?.target.kind !== 'workspace-intent') { + this.manager.discardIntent() + return + } + const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready' + this.list.set({ + ...workspace, + baselinesReady, + recentWorkspaceId: baselinesReady ? recentWorkspace(workspace.items, sessions.byId) : undefined, + }) + if (!this.initialSessionResolved && baselinesReady) { + this.initialSessionResolved = true + if (sessions.current === undefined && sessions.intent === undefined) this.startSession() + } + } +} + +/** Stable tie-breaking follows Host Workspace order. */ +function recentWorkspace( + workspaces: readonly WorkspaceView[], + sessions: ReturnType['byId'], +): WorkspaceId | undefined { + let selected: WorkspaceId | undefined + let selectedTime = Number.NEGATIVE_INFINITY + for (const workspace of workspaces) { + let latest = Number.NEGATIVE_INFINITY + for (const sessionId of workspace.sessionIds) { + const session = sessions[sessionId] + if (session !== undefined) latest = Math.max(latest, session.updatedAt) + } + if (latest === Number.NEGATIVE_INFINITY) latest = Date.parse(workspace.createdAt) + if (selected === undefined || latest > selectedTime) { + selected = workspace.workspaceId + selectedTime = latest + } + } + return selected +} diff --git a/packages/client/runtime/src/client/workspaces/workspace.ts b/packages/client/runtime/src/client/workspaces/workspace.ts new file mode 100644 index 0000000000..afa4dd65b6 --- /dev/null +++ b/packages/client/runtime/src/client/workspaces/workspace.ts @@ -0,0 +1,143 @@ +/** React-free Workspace entity with a client-local materialization lifecycle. */ + +import type { + IApiClient, RpcResult, WorkspaceView, +} from '@deepseek-ai/dsh-client-connection/client' +import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { ObservableSnapshot } from '../contract/store.ts' +import { Notifier } from '../sessions/notifier.ts' + +/** Host input retained by a local Workspace until materialization succeeds. */ +export type WorkspaceCreateInput = { name: string } | { path: string } + +/** Observable state of a client-local Workspace intent. */ +export interface WorkspaceIntentSnapshot { + name: string + phase: 'ready' | 'creating' + error?: string +} + +/** A Workspace is either a local intent or a materialized Host view. */ +export interface WorkspaceSnapshot { + view: WorkspaceView | undefined + intent: WorkspaceIntentSnapshot | undefined +} + +interface WorkspaceIntent { + input: WorkspaceCreateInput + snapshot: WorkspaceIntentSnapshot +} + +/** + * Observable Workspace object whose identity survives Host materialization. + * Local instances retain their create input and failure state; materialized + * instances expose the latest Host view. + */ +export class Workspace implements ObservableSnapshot { + private view: WorkspaceView | undefined + private intent: WorkspaceIntent | undefined + private materialization: Promise> | null = null + private snapshotCache: WorkspaceSnapshot + private readonly notifier = new Notifier(() => { + this.snapshotCache = this.buildSnapshot() + }) + + /** + * @param api - shared wire client. + * @param source - local create input or an existing Host Workspace view. + */ + constructor(private readonly api: IApiClient, source: WorkspaceCreateInput | WorkspaceView) { + if ('workspaceId' in source) { + this.view = source + } else { + this.intent = { + input: source, + snapshot: { name: intentName(source), phase: 'ready' }, + } + } + this.snapshotCache = this.buildSnapshot() + } + + /** + * Materialize this local Workspace through the Host create seam. + * Re-entry shares the in-flight completion; a materialized instance returns undefined. + * @returns the Host result, or undefined when this Workspace is already materialized. + */ + materialize(): Promise> | undefined { + if (this.materialization !== null) return this.materialization + const intent = this.intent + if (intent === undefined) return undefined + intent.snapshot = { name: intent.snapshot.name, phase: 'creating' } + this.notifier.notifyNow() + const completion = this.completeMaterialization(intent).finally(() => { + if (this.materialization === completion) this.materialization = null + }) + this.materialization = completion + return completion + } + + /** + * Adopt a Host view without replacing this Workspace object. + * An existing materialized identity accepts updates only for the same Workspace id. + * @param view - latest Host projection. + */ + adopt(view: WorkspaceView): void { + if (this.view !== undefined && this.view.workspaceId !== view.workspaceId) { + throw new Error('cannot adopt a different Workspace id') + } + this.view = view + this.intent = undefined + this.notifier.markDirty() + } + + /** + * Subscribe to Workspace snapshot invalidation. + * @param listener - snapshot invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void { + return this.notifier.subscribe(listener) + } + + /** + * Read the cached Workspace snapshot after flushing pending notifications. + * @returns the cached Workspace snapshot. + */ + getSnapshot(): WorkspaceSnapshot { + this.notifier.ensureFresh() + return this.snapshotCache + } + + private async completeMaterialization( + intent: WorkspaceIntent, + ): Promise> { + let result: RpcResult<{ workspace: WorkspaceView; created: boolean }> + try { + result = (await this.api.workspace.create(intent.input)).result + } catch (error) { + result = transportError(error) + } + if (this.intent !== intent) return result + if (result.ok) { + this.adopt(result.value.workspace) + } else { + intent.snapshot = { + name: intent.snapshot.name, + phase: 'ready', + error: `${result.error.code}: ${result.error.message}`, + } + this.notifier.markDirty() + } + return result + } + + private buildSnapshot(): WorkspaceSnapshot { + return { view: this.view, intent: this.intent?.snapshot } + } +} + +function intentName(input: WorkspaceCreateInput): string { + if ('name' in input) return input.name + const trimmed = input.path.replace(/[\\/]+$/, '') + return trimmed.split(/[\\/]/).pop() ?? input.path +} diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 0baa2e8237..14fede564d 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -1,5 +1,5 @@ /** - * Runtime plugin browser-half apply: slots + sessions mounting over the + * Runtime plugin browser-half apply: slots + object services mounting over the * connection handle, stream-loop sink wiring into the object layer, and the * fiber-scoped loop teardown. */ @@ -34,14 +34,17 @@ async function mount(): Promise { } describe('runtime client apply', () => { - it('mounts ctx.slots + ctx.sessions and wires the stream sinks into the manager', async () => { + it('mounts slots, Sessions, and Workspaces and fans host frames into both managers', async () => { const bench = await mount() expect(bench.ctx.get('slots') !== undefined).toBe(true) // The built-in 'root' declaration ships with this package's SlotsService // (the SlotMap 'root' merge lives here since the slot-parity rework). expect(bench.ctx.slots.spec('root')).toEqual({ kind: 'single', scope: 'root' }) const sessions = bench.ctx.get('sessions') + const workspaces = bench.ctx.get('workspaces') expect(sessions !== undefined).toBe(true) + expect(workspaces !== undefined).toBe(true) + if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply') expect(bench.sinks).toBeDefined() // Frame sinks reach the object layer: a host session-added lands in the list store. @@ -51,6 +54,18 @@ describe('runtime client apply', () => { }) await Promise.resolve() expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new') + bench.sinks?.onHostEnvelope?.({ + rpcId: 'r-workspace' as never, + payload: { + type: 'host/workspace-changed', + workspace: { + workspaceId: 'w-new', path: '/w/new', title: 'new', sessionIds: [], + createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + }, + } as never, + }) + await Promise.resolve() + expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new') // Mux sink and onConnected route without throwing (manager semantics own the behavior). bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never }) bench.sinks?.onConnected?.() diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 25f12c2b70..45efcf9e36 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -3,9 +3,23 @@ // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, + WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' +/** Programmable-default workspace row (branded id, ISO-ish times). */ +function fakeWorkspace(id: string, over: Partial = {}): WorkspaceView { + return { + workspaceId: id as WorkspaceId, + path: '/f/ws', + title: 'ws', + sessionIds: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...over, + } +} + export interface Deferred { promise: Promise resolve(value: T): void @@ -74,6 +88,15 @@ export class FakeApiClient implements IApiClient { describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), } + onWorkspaceList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) + onWorkspaceCreate: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true })) + + readonly workspace: IApiClient['workspace'] = { + list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)), + create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), + } + /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ suppressStreamOpen = false diff --git a/packages/client/runtime/tests/lineage.spec.ts b/packages/client/runtime/tests/lineage.spec.ts index 9ef959c4b9..1963f9c261 100644 --- a/packages/client/runtime/tests/lineage.spec.ts +++ b/packages/client/runtime/tests/lineage.spec.ts @@ -13,7 +13,7 @@ const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({ }) describe('flattenLineage', () => { - it('sorts roots by updatedAt desc and expands children DFS with depth, children sorted too', () => { + it('keeps established root and sibling order while expanding children DFS with depth', () => { const out = flattenLineage([ s('old-root', 10), s('new-root', 30), @@ -22,7 +22,7 @@ describe('flattenLineage', () => { s('grandkid', 5, 'kid-new'), ]) expect(out.map(e => [e.sessionId, e.depth])).toEqual([ - ['new-root', 0], ['kid-new', 1], ['grandkid', 2], ['kid-old', 1], ['old-root', 0], + ['old-root', 0], ['new-root', 0], ['kid-old', 1], ['kid-new', 1], ['grandkid', 2], ]) }) diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index d37ef6dbce..c532454224 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -57,7 +57,7 @@ describe('instances', () => { }) describe('list lifecycle', () => { - it('single-flights refreshList and lands items sorted through lineage flattening', async () => { + it('single-flights refreshList and preserves the Host baseline order', async () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise @@ -65,12 +65,33 @@ describe('list lifecycle', () => { const first = manager.refreshList() const second = manager.refreshList() expect(manager.getListSnapshot().state).toBe('loading') - gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) + gate.resolve(ok({ items: [summary(S2, { updatedAt: 200 }), summary(S1)] as never[] })) await Promise.all([first, second]) expect(api.callsOf('session.list')).toHaveLength(1) const snapshot = manager.getListSnapshot() expect(snapshot.state).toBe('idle') - expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1]) // updatedAt desc + expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1]) + }) + + it('replays incremental frames over hydration and never batch-reorders established ids', async () => { + const api = new FakeApiClient() + const first = deferred>>() + api.onList = () => first.promise + const manager = new SessionManager(api) + const hydration = manager.refreshList() + manager.handleHostEnvelope({ + rpcId: 'during-first' as never, + payload: { type: 'host/session-added', sessionId: S2 }, + }) + first.resolve(ok({ items: [summary(S1)] as never[] })) + await hydration + expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) + + api.onList = () => Promise.resolve(ok({ + items: [summary(S1, { updatedAt: 900 }), summary(S2, { updatedAt: 800 })] as never[], + })) + await manager.refreshList() + expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) }) it('keeps the error in the list snapshot on failure', async () => { @@ -79,6 +100,26 @@ describe('list lifecycle', () => { const manager = new SessionManager(api) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } }) + // A failed pull does not step the arrival phase: still pending. + expect(manager.getListSnapshot().phase).toBe('pending') + }) + + it('phase steps pending → ready on the first successful pull and never returns', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(api) + expect(manager.getListSnapshot().phase).toBe('pending') + await manager.refreshList() + expect(manager.getListSnapshot().phase).toBe('ready') + // Sticky across later failures: the pull-activity axis reports the error, + // the arrival phase holds. + api.onList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} })) + await manager.refreshList() + expect(manager.getListSnapshot()).toMatchObject({ state: 'error', phase: 'ready' }) + // And across an empty re-pull (empty-with-ready = truly no sessions). + api.onList = () => Promise.resolve(ok({ items: [] as never[] })) + await manager.refreshList() + expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', phase: 'ready' }) + expect(manager.getListSnapshot().items).toEqual([]) }) it('merges create into the list immediately without waiting for a refresh', async () => { @@ -192,14 +233,14 @@ describe('remaining branches', () => { expect(session.getSnapshot().running).toBe(true) }) - it('create passes cwd through, folds transport throws, and skips the merge when already listed', async () => { + it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.resolve(ok({ sessionId: S1 })) const manager = new SessionManager(api) - await manager.create('/tmp/w') - expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w' }]) + await manager.create({ cwd: '/tmp/w', sessionId: S1 }) + expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }]) expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' }) - await manager.create('/tmp/w') // same id returned: no duplicate row + await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row expect(manager.getListSnapshot().items).toHaveLength(1) api.onCreate = () => Promise.reject(new Error('create wire down')) expect(await manager.create()).toMatchObject({ ok: false, error: { code: 'internal' } }) @@ -208,6 +249,42 @@ describe('remaining branches', () => { expect(await manager.create()).toMatchObject({ ok: false }) }) + it('publishes a real Ungrouped summary from workspace-attach-failed', async () => { + const api = new FakeApiClient() + api.onCreate = () => Promise.resolve(err({ + code: 'workspace-attach-failed', + message: 'published but unattached', + details: { sessionId: S1, workspaceId: 'w1' }, + } as never)) + const manager = new SessionManager(api) + const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) + expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) + expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })]) + expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd') + }) + + it('reconciles a preallocated id after an ordinary transport failure', async () => { + const api = new FakeApiClient() + api.onCreate = () => Promise.reject(new Error('response lost')) + const manager = new SessionManager(api) + const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) + expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } }) + expect(manager.getListSnapshot().items).toEqual([]) + + manager.handleHostEnvelope({ + rpcId: 'published-later' as never, + payload: { type: 'host/session-added', sessionId: S1, cwd: '/w/one' }, + }) + expect(manager.getListSnapshot().items).toEqual([ + expect.objectContaining({ sessionId: S1, cwd: '/w/one' }), + ]) + manager.handleHostEnvelope({ + rpcId: 'duplicate-frame' as never, + payload: { type: 'host/session-added', sessionId: S1, cwd: '/w/one' }, + }) + expect(manager.getListSnapshot().items).toHaveLength(1) + }) + it('subscribe notifies on list changes and stops after unsubscribe', async () => { const api = new FakeApiClient() const manager = new SessionManager(api) diff --git a/packages/client/runtime/tests/session-intents.spec.ts b/packages/client/runtime/tests/session-intents.spec.ts new file mode 100644 index 0000000000..c09bfa4ee4 --- /dev/null +++ b/packages/client/runtime/tests/session-intents.spec.ts @@ -0,0 +1,191 @@ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' +import { SessionsService } from '../src/client/sessions/service.ts' +import { WorkspacesService } from '../src/client/workspaces/service.ts' +import { FakeApiClient, deferred, err, ok } from './fake-api.ts' + +const sid = (id: string): SessionId => id as SessionId +const wid = (id: string): WorkspaceId => id as WorkspaceId + +function workspace(id: string, sessionIds: SessionId[] = []): WorkspaceView { + return { + workspaceId: wid(id), + path: `/w/${id}`, + title: id, + sessionIds, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + } +} + +async function ready( + api: FakeApiClient, + workspaces: WorkspacesService, + sessions: SessionsService, + workspaceRows: WorkspaceView[], + sessionRows: { sessionId: SessionId; updatedAt: number; running: boolean }[] = [], +): Promise { + api.onWorkspaceList = () => Promise.resolve(ok({ items: workspaceRows as never[] })) + api.onList = () => Promise.resolve(ok({ items: sessionRows as never[] })) + await Promise.all([workspaces.refresh(), sessions.refresh()]) + await Promise.resolve() +} + +function services(api: FakeApiClient): { sessions: SessionsService; workspaces: WorkspacesService } { + const ctx = new Context() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + return { sessions, workspaces } +} + +function pendingPrompt(sessions: SessionsService, sessionId: SessionId) { + return sessions.binding(sessionId)?.session.getSnapshot().pendingPrompt +} + +describe('frontend Session and Workspace intents', () => { + it('resolves the initial intent into the most recently active Workspace', async () => { + const api = new FakeApiClient() + const { sessions, workspaces } = services(api) + const old = workspace('old', [sid('s-old')]) + const recent = workspace('recent', [sid('s-recent')]) + await ready(api, workspaces, sessions, [old, recent], [ + { sessionId: sid('s-old'), updatedAt: 1, running: false }, + { sessionId: sid('s-recent'), updatedAt: 2, running: false }, + ]) + expect(sessions.list.getSnapshot().intent).toMatchObject({ + target: { kind: 'workspace', workspaceId: 'recent' }, + phase: 'ready', + }) + expect(workspaces.list.getSnapshot().intent).toBeUndefined() + }) + + it('materializes zero-state Workspace and Session intents and retains a rejected first prompt', async () => { + const api = new FakeApiClient() + const { sessions, workspaces } = services(api) + await ready(api, workspaces, sessions, []) + expect(workspaces.list.getSnapshot().intent).toMatchObject({ name: 'workspace', phase: 'ready' }) + sessions.updateIntent('first prompt') + api.onWorkspaceCreate = () => Promise.resolve(ok({ workspace: workspace('created'), created: true })) + api.onCreate = payload => Promise.resolve(ok({ + sessionId: (payload as { sessionId: SessionId }).sessionId, + })) + api.onPrompt = () => Promise.resolve(err({ code: 'internal', message: 'prompt offline', details: {} })) + workspaces.sendSession() + await vi.waitFor(() => { + const sessionId = sessions.list.getSnapshot().current as SessionId + expect(pendingPrompt(sessions, sessionId)).toMatchObject({ + text: 'first prompt', phase: 'failed', retry: 'send', + }) + }) + expect(api.callsOf('workspace.create')).toEqual([{ name: 'workspace' }]) + const create = api.callsOf('session.create')[0] as { workspaceId: WorkspaceId; sessionId: SessionId } + expect(create.workspaceId).toBe('created') + expect(api.callsOf('session.prompt')).toEqual([{ + sessionId: create.sessionId, + mode: 'queue', + content: [{ type: 'text', text: 'first prompt' }], + }]) + expect(workspaces.list.getSnapshot().intent).toBeUndefined() + }) + + it('turns Workspace attachment failure into a focused real Session and retries its prompt', async () => { + const api = new FakeApiClient() + const { sessions, workspaces } = services(api) + const target = workspace('target') + await ready(api, workspaces, sessions, [target]) + sessions.updateIntent('keep this') + api.onCreate = (payload) => { + const sessionId = (payload as { sessionId: SessionId }).sessionId + return Promise.resolve(err({ + code: 'workspace-attach-failed', + message: 'attach rejected', + details: { sessionId, workspaceId: target.workspaceId }, + })) + } + workspaces.sendSession() + await vi.waitFor(() => { + const snapshot = sessions.list.getSnapshot() + expect(snapshot.intent).toBeUndefined() + expect(pendingPrompt(sessions, snapshot.current as SessionId)).toMatchObject({ + text: 'keep this', phase: 'failed', retry: 'connect', + }) + }) + const published = sessions.list.getSnapshot().current as SessionId + const session = sessions.binding(published)!.session + session.updatePendingPrompt('retry this') + api.onCreate = () => Promise.resolve(ok({ sessionId: published })) + session.retryPendingPrompt() + await vi.waitFor(() => { + expect(pendingPrompt(sessions, published)).toBeNull() + }) + expect(api.callsOf('session.prompt').at(-1)).toMatchObject({ + sessionId: published, + content: [{ type: 'text', text: 'retry this' }], + }) + }) + + it('does not send after navigation while Session creation is in flight', async () => { + const api = new FakeApiClient() + const { sessions, workspaces } = services(api) + const target = workspace('target') + await ready(api, workspaces, sessions, [target]) + const gate = deferred>>() + api.onCreate = () => gate.promise + sessions.updateIntent('do not send yet') + workspaces.sendSession() + await vi.waitFor(() => { expect(api.callsOf('session.create')).toHaveLength(1) }) + const requested = (api.callsOf('session.create')[0] as { sessionId: SessionId }).sessionId + workspaces.startSession(target.workspaceId) + const replacement = sessions.list.getSnapshot().intent! + gate.resolve(ok({ sessionId: requested })) + await vi.waitFor(() => { + expect(pendingPrompt(sessions, requested)).toMatchObject({ + text: 'do not send yet', phase: 'failed', retry: 'send', + }) + }) + expect(api.callsOf('session.prompt')).toEqual([]) + expect(sessions.list.getSnapshot()).toMatchObject({ + current: replacement.sessionId, + intent: { sessionId: replacement.sessionId }, + }) + }) + + it('keeps a lost-response Intent and retries creation with its preallocated id', async () => { + const api = new FakeApiClient() + const { sessions, workspaces } = services(api) + const target = workspace('target') + await ready(api, workspaces, sessions, [target]) + sessions.updateIntent('preserve me') + api.onCreate = () => Promise.reject(new Error('response lost')) + workspaces.sendSession() + await vi.waitFor(() => { + expect(sessions.list.getSnapshot().intent?.error).toMatchObject({ step: 'session' }) + }) + const requested = sessions.list.getSnapshot().intent?.sessionId as SessionId + sessions.handleHostEnvelope({ + rpcId: 'published-later' as never, + payload: { type: 'host/session-added', sessionId: requested, cwd: target.path }, + }) + expect(sessions.list.getSnapshot()).toMatchObject({ + current: requested, + intent: { sessionId: requested, error: { step: 'session' } }, + }) + expect(sessions.intent()?.getSnapshot().pendingPrompt).toMatchObject({ + text: 'preserve me', phase: 'editing', + }) + + api.onCreate = payload => Promise.resolve(ok({ + sessionId: (payload as { sessionId: SessionId }).sessionId, + })) + workspaces.sendSession() + await vi.waitFor(() => { + expect(api.callsOf('session.create')).toHaveLength(2) + expect(api.callsOf('session.prompt')).toHaveLength(1) + expect(sessions.list.getSnapshot()).toMatchObject({ current: requested, intent: undefined }) + expect(pendingPrompt(sessions, requested)).toBeNull() + }) + expect(api.callsOf('session.create').map(call => (call as { sessionId: SessionId }).sessionId)) + .toEqual([requested, requested]) + }) +}) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index b980a674fe..136709b20c 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -217,19 +217,33 @@ describe('paging', () => { }) describe('prompt and cancel errors', () => { - it('sends content through session.prompt with the mode passed through', async () => { + it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => { const { api, session } = makeSession() - const result = await session.prompt([{ type: 'text', text: '要发的' }], 'queue') + // The blank → engaging edge fires before the RPC settles: the first-send + // flow reads the phase on the session area's first frame to keep the + // guidance hero from flashing back in. + expect(session.getSnapshot().composerPhase).toBe('blank') + const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue') + expect(session.getSnapshot().composerPhase).toBe('engaging') + const result = await inFlight expect(result.ok).toBe(true) + // Monotone: settlement alone does not step the phase anywhere. + expect(session.getSnapshot().composerPhase).toBe('engaging') expect(api.callsOf('session.prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] }]) + // First content lands (running turn): engaging → active. + session.handleRunning(true) + expect(session.getSnapshot().composerPhase).toBe('active') }) - it('business failure lands in promptError with op=send', async () => { + it('business failure lands in promptError with op=send; the phase stays engaging (retry, no hero bounce)', async () => { const { api, session } = makeSession() api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } })) const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue') expect(result.ok).toBe(false) expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } }) + // Failed first prompt: composer + error strip is the retry surface — + // blank is unreachable once a send was initiated. + expect(session.getSnapshot().composerPhase).toBe('engaging') }) it('lands cancel failures in promptError with op=stop', async () => { diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 2b469ca055..a6834071d0 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -9,7 +9,7 @@ import { Context } from 'cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' -import { SessionsService, scopeOf } from '../src/client/sessions/service.ts' +import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts' import { FakeApiClient, ok } from './fake-api.ts' const sid = (s: string): SessionId => s as SessionId @@ -36,14 +36,14 @@ async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: s ...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}), })), }) as never) - await b.svc.manager.refreshList() + await b.svc.refresh() await Promise.resolve() // manager notifier flush } describe('list store projection', () => { it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => { const b = bench() - b.svc.manager.handleMuxEnvelope({ + b.svc.handleMuxEnvelope({ rpcId: 'title' as never, payload: { type: 'session/title', sessionId: sid('s1'), title: 'Durable title', eventSeq: 2, updatedAt: 3 }, }) @@ -61,7 +61,7 @@ describe('list store projection', () => { it('reflects live increments (host stream via manager) into the store', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) - b.svc.manager.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', sessionId: sid('s2') } as never }) + b.svc.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', sessionId: sid('s2') } as never }) await Promise.resolve() expect(b.svc.list.getSnapshot().ids).toContain('s2') }) @@ -77,7 +77,7 @@ describe('scope tree', () => { expect(scopeOf(scoped as Context)).toBe('s1') expect(scopeOf(b.ctx)).toBeUndefined() const binding = b.svc.binding(sid('s1')) - expect(binding?.session).toBe(b.svc.manager.get(sid('s1'))) + expect(binding?.session).toBe(b.svc.cell('s1')?.session) expect(b.svc.binding(sid('s1'))).toBe(binding) expect(binding?.ctx).toBe(scoped) }) @@ -187,8 +187,8 @@ describe('cell (render-layer session kit)', () => { const cell = b.svc.cell('s1') expect(cell).toBeDefined() expect(cell?.sessionId).toBe('s1') - // Hook binding happens in React; the cell carries the observable itself. - expect(cell?.session).toBe(b.svc.manager.get(sid('s1'))) + // The cell carries the observable; hook binding happens in React. + expect(cell?.session).toBe(b.svc.binding(sid('s1'))?.session) expect(b.svc.cell('s1')).toBe(cell) expect(b.svc.cell('ghost')).toBeUndefined() }) @@ -284,36 +284,45 @@ describe('ancestry', () => { }) describe('create', () => { - it('returns the new id on ok and throws a coded error on failure', async () => { + it('passes a preallocated id and preserves it on ordinary failure', async () => { const b = bench() b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') })) - await expect(b.svc.create({ cwd: '/w' })).resolves.toBe('fresh') + await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh') + expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/w', sessionId: 'fresh' }]) b.api.onCreate = () => Promise.resolve({ rpcId: 'e' as never, result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } }, } as never) - await expect(b.svc.create()).rejects.toThrow(/internal: 爆了/) - }) -}) - -describe('createWorkspace', () => { - it('joins host.describe cwd with the name and creates there', async () => { - const b = bench() - b.api.onDescribe = () => Promise.resolve(ok({ version: '0', cwd: '/host/root', attachedSessions: 0 })) - b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('ws') })) - await expect(b.svc.createWorkspace('My Proj')).resolves.toBe('ws') - expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/host/root/My Proj' }]) + const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(SessionCreateError) + expect(failure).toMatchObject({ + requestedSessionId: 'candidate', publishedSessionId: undefined, + rpcError: { code: 'internal', message: '爆了' }, + }) }) - it('rejects empty names and path separators; surfaces describe failures', async () => { + it('surfaces the definitely published id after Workspace attachment fails', async () => { const b = bench() - await expect(b.svc.createWorkspace(' ')).rejects.toThrow(/name is required/) - await expect(b.svc.createWorkspace('a/b')).rejects.toThrow(/path separators/) - b.api.onDescribe = () => Promise.resolve({ - rpcId: 'e' as never, - result: { ok: false as const, error: { code: 'internal' as const, message: 'down', details: {} } }, + b.api.onCreate = () => Promise.resolve({ + rpcId: 'attach' as never, + result: { + ok: false, + error: { + code: 'workspace-attach-failed', message: 'ledger unavailable', + details: { sessionId: sid('published'), workspaceId: 'ws' }, + }, + }, } as never) - await expect(b.svc.createWorkspace('ok')).rejects.toThrow(/host.describe failed/) + const failure = await b.svc.create({ + workspaceId: 'ws' as never, + sessionId: sid('published'), + }).catch((error: unknown) => error) + await Promise.resolve() + expect(failure).toMatchObject({ + publishedSessionId: 'published', requestedSessionId: 'published', + rpcError: { code: 'workspace-attach-failed' }, + }) + expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published' }) }) }) diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index 12f4f1f05f..069cc788d5 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -85,11 +85,18 @@ function captureHost(bench: Bench, children?: object): SlotRendererHost { }) bench.erased.register({ name: 'root', ...(children !== undefined ? { children } : {}) }, C) bench.ctx.reflect.provide('sessions', fakeSessions()) + bench.ctx.reflect.provide('workspaces', fakeWorkspaces()) bench.erased.renderSlot('root', {}) if (host === undefined) throw new Error('renderer never received the host') return host } +/** Minimal independent Workspace list source for the renderer host seam. */ +function fakeWorkspaces() { + const state = { items: [], phase: 'ready' as const } + return { list: { getSnapshot: () => state, subscribe: () => () => undefined } } +} + /** Minimal sessions face for the host seam (list observable + cell). */ function fakeSessions() { const state = { ids: [], byId: {}, current: undefined as string | undefined } @@ -190,9 +197,18 @@ describe('renderer install seam', () => { bench.erased.install({ renderRoot }) bench.erased.register({ name: 'root' }, C) bench.ctx.reflect.provide('sessions', fakeSessions()) + bench.ctx.reflect.provide('workspaces', fakeWorkspaces()) expect(bench.erased.renderSlot('root', {})).toBe('tree') expect(renderRoot).toHaveBeenCalledTimes(1) }) + + it('fails before rendering when the Workspace object layer is absent', async () => { + const bench = await boot() + bench.erased.install({ renderRoot: () => null }) + bench.erased.register({ name: 'root' }, C) + bench.ctx.reflect.provide('sessions', fakeSessions()) + expect(() => bench.erased.renderSlot('root', {})).toThrow(/workspaces service mounted/) + }) }) describe('host face', () => { @@ -220,6 +236,12 @@ describe('host face', () => { expect(host.sessions.cell('known')).toMatchObject({ sessionId: 'known' }) expect(host.sessions.cell('ghost')).toBeUndefined() }) + + it('exposes the independent Workspace list source', async () => { + const bench = await boot() + const host = captureHost(bench) + expect(host.workspaces.list.getSnapshot()).toEqual({ items: [], phase: 'ready' }) + }) }) describe('store instance axis', () => { @@ -315,6 +337,7 @@ describe('entry-unload cascade', () => { renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' }, }) bench.ctx.reflect.provide('sessions', fakeSessions()) + bench.ctx.reflect.provide('workspaces', fakeWorkspaces()) // The declarer here is NOT the root occupant: root stays occupied by a // separate entry so disposing the declarer only kills its children. const disposeRoot = bench.erased.register({ name: 'root' }, C) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts new file mode 100644 index 0000000000..c2c2c62b86 --- /dev/null +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -0,0 +1,157 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' +import { SessionsService } from '../src/client/sessions/service.ts' +import { WorkspaceManager } from '../src/client/workspaces/manager.ts' +import { WorkspacesService } from '../src/client/workspaces/service.ts' +import { FakeApiClient, deferred, err, ok } from './fake-api.ts' + +const sid = (id: string): SessionId => id as SessionId +const wid = (id: string): WorkspaceId => id as WorkspaceId + +function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-01-01T00:00:00.000Z'): WorkspaceView { + return { + workspaceId: wid(id), path: `/w/${id}`, title: id, sessionIds, + createdAt, updatedAt: createdAt, + } +} + +describe('WorkspaceManager', () => { + it('owns, materializes, retries, supersedes, and discards Workspace objects with local intents', async () => { + const api = new FakeApiClient() + const manager = new WorkspaceManager(api) + manager.startIntent('first') + expect(manager.getSnapshot().intent).toEqual({ name: 'first', phase: 'ready' }) + + api.onWorkspaceCreate = () => Promise.resolve(err({ + code: 'workspace-name-conflict', message: 'taken', details: { name: 'first' }, + } as never)) + await expect(manager.materializeIntent()).resolves.toMatchObject({ ok: false }) + expect(manager.getSnapshot().intent).toMatchObject({ name: 'first', phase: 'ready' }) + expect(typeof manager.getSnapshot().intent?.error).toBe('string') + + const gate = deferred>>() + api.onWorkspaceCreate = () => gate.promise + const stale = manager.materializeIntent() + expect(manager.getSnapshot().intent?.phase).toBe('creating') + manager.startIntent('replacement') + gate.resolve(ok({ workspace: workspace('first'), created: true })) + await stale + expect(manager.getSnapshot().intent).toEqual({ name: 'replacement', phase: 'ready' }) + + api.onWorkspaceCreate = () => Promise.resolve(ok({ workspace: workspace('replacement'), created: true })) + await expect(manager.materializeIntent()).resolves.toMatchObject({ ok: true }) + expect(manager.getSnapshot().intent).toBeUndefined() + await expect(manager.materializeIntent()).resolves.toBeUndefined() + manager.discardIntent() + manager.startIntent('discarded') + manager.discardIntent() + expect(manager.getSnapshot().intent).toBeUndefined() + }) + + it('replays changed frames over hydration and keeps established order on refresh', async () => { + const api = new FakeApiClient() + const gate = deferred>>() + api.onWorkspaceList = () => gate.promise + const manager = new WorkspaceManager(api) + const hydration = manager.refresh() + manager.handleHostEnvelope({ + rpcId: 'changed' as never, + payload: { type: 'host/workspace-changed', workspace: workspace('new') }, + }) + gate.resolve(ok({ items: [workspace('old')] as never[] })) + await hydration + expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle' }) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old']) + + api.onWorkspaceList = () => Promise.resolve(ok({ + items: [workspace('old'), workspace('new')] as never[], + })) + await manager.refresh() + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old']) + }) + + it('single-flights refreshes and exposes result and transport failures independently of readiness', async () => { + const api = new FakeApiClient() + const gate = deferred>>() + api.onWorkspaceList = () => gate.promise + const manager = new WorkspaceManager(api) + const first = manager.refresh() + const second = manager.refresh() + expect(manager.getSnapshot().state).toBe('loading') + gate.resolve(ok({ items: [] })) + await Promise.all([first, second]) + expect(api.callsOf('workspace.list')).toHaveLength(1) + + api.onWorkspaceList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} })) + await manager.refresh() + expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'down' } }) + api.onWorkspaceList = () => Promise.reject(new Error('wire down')) + await manager.refresh() + expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } }) + }) + + it('creates by name/path, prepends a new row, and folds failures', async () => { + const api = new FakeApiClient() + const manager = new WorkspaceManager(api) + api.onWorkspaceCreate = payload => Promise.resolve(ok({ + workspace: workspace('created', [], '2026-02-01T00:00:00.000Z'), + created: true, + payload, + } as never)) + await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true }) + expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }]) + expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created') + + api.onWorkspaceCreate = () => Promise.reject(new Error('create transport')) + await expect(manager.create({ path: '/w/existing' })).resolves.toMatchObject({ + ok: false, error: { code: 'internal', message: 'create transport' }, + }) + }) +}) + +describe('WorkspacesService', () => { + it('feeds SessionManager readiness and recent-Workspace targeting without changing Host order', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onWorkspaceList = () => Promise.resolve(ok({ + items: [ + workspace('stable-first', [], '2026-01-03T00:00:00.000Z'), + workspace('active', [sid('s-active')], '2026-01-01T00:00:00.000Z'), + ] as never[], + })) + await workspaces.refresh() + await Promise.resolve() + expect(workspaces.list.getSnapshot()).toMatchObject({ baselinesReady: false, recentWorkspaceId: undefined }) + + api.onList = () => Promise.resolve(ok({ + items: [{ sessionId: sid('s-active'), updatedAt: Date.parse('2026-02-01'), running: false }] as never[], + })) + await sessions.refresh() + await Promise.resolve() + await Promise.resolve() + expect(workspaces.list.getSnapshot()).toMatchObject({ + baselinesReady: true, + recentWorkspaceId: 'active', + }) + expect(sessions.list.getSnapshot().intent).toMatchObject({ + target: { kind: 'workspace', workspaceId: 'active' }, + }) + expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active']) + }) + + it('returns created Workspaces and preserves Host business errors', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + await expect(workspaces.create({ path: '/w/existing' })).resolves.toMatchObject({ workspaceId: 'fk-ws' }) + expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/existing' }]) + api.onWorkspaceCreate = () => Promise.resolve(err({ + code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' }, + })) + await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/) + }) +}) diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ebd7474298..0711adcccb 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,13 +2,15 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). +The no-session hero renders the frontend Session Intent from the Session list projection, including its frontend Workspace Intent when no real Workspace exists. It declares `conversation.empty.workspace`, where ui-workspace registers the same picker used by the sidebar. WorkspacesService starts the cross-object flow; each Workspace or Session object owns its own materialization. The Session keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService. + The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). -Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, tab read face, details/paging callbacks, startSession chain). +Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging. `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 536859b59a..e14f110b7c 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -16,7 +16,7 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { EmptyState } from './skeleton/EmptyState.tsx' /** Services required by the conversation plugin. */ -export const inject = ['slots', 'layout', 'sessions'] +export const inject = ['slots', 'layout', 'sessions', 'workspaces'] /** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */ function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService { @@ -32,6 +32,7 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat */ export function apply(ctx: Context): void { const sessions = ctx.sessions + const workspaces = ctx.workspaces const layout = ctx.layout const slots = ctx.slots @@ -86,7 +87,9 @@ export function apply(ctx: Context): void { // Stop failure surfaces via snapshot.promptError; nothing to restore. }) }, - open: (target: SessionId) => { sessions.open(target) }, + open: (sessionId) => { sessions.open(sessionId) }, + updateSessionPrompt: (text) => { scoped.updatePendingPrompt(text) }, + retrySessionPrompt: () => { scoped.retryPendingPrompt() }, } }, }, ConversationRoot) @@ -103,13 +106,16 @@ export function apply(ctx: Context): void { label: 'Chat', children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } }, store: chatStore, - inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => ({ - openDetails: (target) => { - actions.select(target) - layout.openDetails() - }, - loadOlder: () => { void sessions.manager.get(sessionId).loadOlder() }, - }), + inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => { + const scoped = scopedConversation(sessions, sessionId) + return { + openDetails: (target) => { + actions.select(target) + layout.openDetails() + }, + loadOlder: () => { void scoped.loadOlder() }, + } + }, }, ChatView) // Class-plugin mount (packages/AGENTS.md service form): the service @@ -133,20 +139,11 @@ export function apply(ctx: Context): void { slots.register({ name: 'conversation.empty', + children: { 'conversation.empty.workspace': { kind: 'single', scope: 'root' } }, inject: (): EmptyStateInjected => ({ - // ctx.get, not ctx.conversation: the service mounts on this plugin's - // own child fiber, so it is not in the inject topology the property - // proxy enforces; get reads the global store and stays loud on a torn - // boot through the optional-chain throw below. - startSession: (opts) => { - const conversation = ctx.get('conversation') - if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable') - return conversation.startSession(opts) - }, - createWorkspaceSession: async (name) => { - const id = await sessions.createWorkspace(name) - sessions.open(id) - }, + startSession: (workspaceId, prompt) => { workspaces.startSession(workspaceId, prompt) }, + updateSessionPrompt: (text) => { sessions.updateIntent(text) }, + sendSession: () => { workspaces.sendSession() }, }), }, EmptyState) } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 9745c4518b..095b57a5f8 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,6 +1,7 @@ /** Conversation slot declarations and their composed component props. */ +import type { RefObject } from 'react' import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { createChatStore } from '../stores.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' @@ -30,6 +31,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * zero owner changes. */ 'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps } + /** Shared Workspace picker hole used by the page-local Session Intent hero. */ + 'conversation.empty.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps } } } @@ -94,7 +97,12 @@ export interface ConversationInjected { send(text: string, mode: 'queue' | 'steer'): void /** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */ stop(): void - open(id: SessionId): void + /** Select a real Session through the runtime navigation owner. */ + open(sessionId: SessionId): void + /** Update the scoped Session's retained prompt. */ + updateSessionPrompt(text: string): void + /** Retry the scoped Session's retained prompt. */ + retrySessionPrompt(): void } /** @@ -140,16 +148,24 @@ export interface DetailsInjected { /** Full details-slot component props: selection arrives through the shared store, call material through useSession. */ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & DetailsInjected -/** Injected share of the no-session empty-state slot. */ -export interface EmptyStateInjected { - /** The create → navigate → first-send chain, in one service call. */ - startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise - /** - * Create a workspace folder under the host cwd, mint a session there, and - * open it (Create-new modal success path). - */ - createWorkspaceSession(name: string): Promise +/** Owner share common to the empty hero's Workspace picker. */ +export interface EmptyWorkspaceOwnerProps { + open: boolean + anchorRef?: RefObject + onPick(workspaceId: WorkspaceId): void + onClose(): void } -/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */ -export type EmptyStateSlotProps = PropsRuntime<'conversation.empty'> & EmptyStateInjected +/** Runtime-owned actions injected into the empty-state occupant. */ +export interface EmptyStateInjected { + /** Replace the current Session intent, optionally preserving a prompt while retargeting. */ + startSession(workspaceId?: WorkspaceId, prompt?: string): void + /** Update the current Session intent's controlled prompt. */ + updateSessionPrompt(text: string): void + /** Materialize and send the current Session intent. */ + sendSession(): void +} + +/** Full empty-state component props: runtime projections, picker child slot, and injected actions. */ +export type EmptyStateSlotProps = + PropsRuntime<'conversation.empty'> & PropsRenderSlots<'conversation.empty.workspace'> & EmptyStateInjected diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index d55ba1bd1e..a48dfdad34 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -15,7 +15,7 @@ export type { ToolCallBlock } from './contract/tool-call-model.ts' export type { ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, - EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps, + EmptyStateInjected, EmptyStateSlotProps, EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 68fb7c4c31..5ea5ea96ee 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -1,5 +1,5 @@ /** - * Scope-addressed conversation send, cancel, and empty-state session startup. + * Scope-addressed conversation send, cancel, history, and retained-prompt orchestration. * * Scope addressing rides the cordis Service tracker: property access through * `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods @@ -44,37 +44,30 @@ export class ConversationService extends Service { if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`) } + /** Pull one older history page for the scoped Session. */ + async loadOlder(): Promise { + await this.scopedSession('loadOlder').loadOlder() + } + /** - * Empty-state first-send chain (root-context method; does not read scope): - * create the session, navigate to it, then send through the new scope. - * The create → open ordering is safe: the manager merges the new summary - * synchronously before create() resolves, so the list store is projected by - * the time open() validates against it (manager notification batching is - * microtask-based; SessionsService projects on the same flush that create - * awaited through the RPC round trip). - * @param opts - project directory, prompt text, and send mode. + * Update the scoped Session's retained pending prompt. + * @param text - exact controlled-input value to retain. */ - async startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise { - const sessions = this.requireSessions() - const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd }) - // The manager notifier flushes per microtask; one await guarantees the - // list-store projection landed before sessions.open validates against it. - await Promise.resolve() - sessions.open(id) - const scoped = sessions.scope(id) - if (scoped === undefined) throw new Error(`conversation.startSession: created session "${id}" resolved no scope`) - // ctx.get, not scoped.conversation: property access walks the fiber - // topology (a scope fiber never injects services), while get reads the - // global store and still binds this service to the scoped ctx. - const scopedConversation = scoped.get('conversation') - if (scopedConversation === undefined) throw new Error('conversation.startSession: conversation service unavailable through the new scope') - await scopedConversation.send(opts.text, opts.mode) + updatePendingPrompt(text: string): void { + this.scopedSession('updatePendingPrompt').updatePendingPrompt(text) + } + + /** Retry the scoped Session's retained pending prompt. */ + retryPendingPrompt(): void { + this.scopedSession('retryPendingPrompt').retryPendingPrompt() } /** Resolve the caller scope's Session or throw on root contexts. */ private scopedSession(op: string): Session { const id = this.scopeId(op) - return this.requireSessions().manager.get(id) + const binding = this.requireSessions().binding(id) + if (binding === undefined) throw new Error(`conversation.${op}: session "${id}" resolved no binding`) + return binding.session } /** Read the caller's session scope tag via the sessions service; root contexts fail loud. */ diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 59346a557b..a87f6e4aa9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -15,6 +15,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d import type { ConversationSlotProps } from '../contract/slots.ts' import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' +import { EmptyHero, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' import css from './ConversationRoot.module.css' /** Full props = the automatic shares & injected share — composed by reference @@ -37,8 +38,8 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session } export function ConversationRoot({ - sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain, - views, send, stop, open, + sessionId, useSession, useSessions, useWorkspaces, useStore, actions, renderSlot, renderSlotChain, + views, send, stop, open, updateSessionPrompt, retrySessionPrompt, }: ConversationRootProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() @@ -48,16 +49,60 @@ export function ConversationRoot({ const active = tabs.find(v => v.id === activeId) ?? tabs[0] const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual) - const draft = useStore(s => s.draft) - const running = useSession(s => s.running) + const pendingPrompt = useSession(s => s.pendingPrompt ?? undefined) + const storedDraft = useStore(s => s.draft) + const draft = pendingPrompt?.text ?? storedDraft + const sessionRunning = useSession(s => s.running) + const running = sessionRunning || pendingPrompt?.phase === 'sending' const removed = useSession(s => s.removed) const promptError = useSession(s => s.promptError) const turns = useSession(s => countTurns(s)) const pending = useSession(s => s.pending) + const openState = useSession(s => s.openState) + const composerPhase = useSession(s => s.composerPhase) + const cwd = useSessions(s => s.byId[sessionId]?.cwd) + const workspaceTitle = useWorkspaces(state => + state.items.find(workspace => workspace.sessionIds.includes(sessionId))?.title) + const error: InputBarError | null = pendingPrompt?.error !== undefined + ? { + op: pendingPrompt.retry === 'connect' ? 'session' : 'send', + message: pendingPrompt.retry === 'connect' + ? `Workspace attach failed: ${pendingPrompt.error}` + : `Message send failed: ${pendingPrompt.error}`, + } + : promptError === null + ? null + : { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` } + const status = pendingPrompt?.phase === 'sending' + ? pendingPrompt.retry === 'connect' ? 'Attaching session to workspace…' : 'Sending message…' + : undefined + const setDraft = (text: string): void => { + if (pendingPrompt === undefined) actions.setDraft(text) + else updateSessionPrompt(text) + } + const submit = (mode: 'queue' | 'steer'): void => { + if (pendingPrompt === undefined) send(draft, mode) + else retrySessionPrompt() + } - const error: InputBarError | null = promptError === null - ? null - : { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` } + // Blank-session guidance: phase-derived (the runtime snapshot owns the + // predicate — see ComposerPhase). Only `blank` renders the hero; `engaging` + // and `active` fall through to the conversation view, so an in-flight + // first send never bounces back here. Gated on the OPEN window: phase has + // no jurisdiction over loading/error frames (ChatView renders those). + if (openState === 'open' && composerPhase === 'blank') { + return ( + } + draft={draft} + disabled={removed || pendingPrompt?.phase === 'sending'} + error={error} + {...(status === undefined ? {} : { status })} + onDraftChange={setDraft} + onSend={submit} + /> + ) + } // The default composer doubles as the chain's all-decline fallback: a // pending wait with no registered takeover must still leave the input usable. @@ -67,9 +112,10 @@ export function ConversationRoot({ running={running} disabled={removed} error={error} + {...(status === undefined ? {} : { status })} variant="composer" - onDraftChange={actions.setDraft} - onSend={(mode) => { send(draft, mode) }} + onDraftChange={setDraft} + onSend={submit} onStop={stop} /> ) @@ -78,7 +124,7 @@ export function ConversationRoot({
-