From 9eb9c70a8a53a2196aa6201ed3c21a9ff23af372 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:04:48 +0800 Subject: [PATCH 1/6] feat(web): add workspace-aware session flow --- ...2-slot-type-chain-implementation.i18n.yaml | 4 +- ...26-07-22-slot-type-chain-implementation.md | 4 +- ...07-22-slot-type-chain-implementation.zh.md | 4 +- ...workspace-gui-and-session-drafts.i18n.yaml | 6 + ...-07-25-workspace-gui-and-session-drafts.md | 121 +++ ...-25-workspace-gui-and-session-drafts.zh.md | 121 +++ .gitignore | 1 + apps/cli/README.md | 2 +- apps/cli/cordis.yml | 19 + apps/cli/package.json | 5 + apps/cli/src/app-cli-entry.ts | 3 + apps/cli/src/web.ts | 2 + apps/web/tests/session-title.snapshot.ts | 5 +- apps/web/tests/workspace-flow.snapshot.ts | 317 +++++++ docs/capability-seams.md | 11 +- docs/config-catalog.md | 13 +- docs/cordis-catalog/services.md | 99 +- docs/event-producer-consumer.md | 4 +- docs/module-graph.md | 6 + packages/client/AGENTS.md | 14 +- packages/client/connection/README.md | 4 + packages/client/connection/src/client/api.ts | 1 + .../client/connection/src/client/fixture.ts | 152 +++- .../client/connection/src/client/index.ts | 2 +- packages/client/connection/tests/fake-api.ts | 8 + .../client/connection/tests/fixture.spec.ts | 227 ++++- packages/client/runtime/README.md | 12 +- packages/client/runtime/src/client/index.ts | 36 +- .../runtime/src/client/ordered-baseline.ts | 43 + .../src/client/sessions/conversation.ts | 56 +- .../runtime/src/client/sessions/lineage.ts | 13 +- .../runtime/src/client/sessions/manager.ts | 276 +++++- .../runtime/src/client/sessions/service.ts | 181 ++-- .../runtime/src/client/sessions/session.ts | 233 ++++- packages/client/runtime/src/client/slots.ts | 7 +- .../runtime/src/client/workspaces/manager.ts | 243 +++++ .../runtime/src/client/workspaces/service.ts | 164 ++++ .../src/client/workspaces/workspace.ts | 143 +++ .../client/runtime/tests/client-apply.spec.ts | 19 +- packages/client/runtime/tests/fake-api.ts | 23 + packages/client/runtime/tests/lineage.spec.ts | 4 +- packages/client/runtime/tests/manager.spec.ts | 91 +- .../runtime/tests/session-drafts.spec.ts | 191 ++++ packages/client/runtime/tests/session.spec.ts | 20 +- .../runtime/tests/sessions-service.spec.ts | 63 +- .../runtime/tests/slots-service.spec.ts | 23 + .../runtime/tests/workspaces-service.spec.ts | 157 ++++ packages/client/ui-conversation/README.md | 4 +- .../ui-conversation/src/client/apply.ts | 41 +- .../src/client/contract/slots.ts | 42 +- .../ui-conversation/src/client/index.ts | 2 +- .../ui-conversation/src/client/service.ts | 44 +- .../src/client/skeleton/ConversationRoot.tsx | 66 +- .../src/client/skeleton/EmptyHero.tsx | 153 ++++ .../src/client/skeleton/EmptyState.module.css | 27 +- .../src/client/skeleton/EmptyState.tsx | 363 ++------ .../src/client/skeleton/InputBar.module.css | 15 +- .../src/client/skeleton/InputBar.tsx | 28 +- .../tests/apply-inject.spec.tsx | 89 +- .../ui-conversation/tests/chat-apply.spec.tsx | 11 +- .../tests/chat-stats-bash-sample.spec.tsx | 6 +- .../tests/chat-toolview-slot.spec.tsx | 38 +- .../ui-conversation/tests/chat-view.spec.tsx | 17 +- .../tests/coverage-tails.spec.tsx | 4 +- .../tests/gate-branch-tails.spec.tsx | 13 +- packages/client/ui-conversation/tests/hook.ts | 20 - .../ui-conversation/tests/input-bar.spec.tsx | 26 +- .../tests/selection-survival.spec.ts | 87 +- .../tests/service-orchestration.spec.ts | 172 +--- .../tests/skeleton-branches.spec.tsx | 318 ------- .../ui-conversation/tests/skeleton.spec.tsx | 485 ++++------ packages/client/ui-layout/README.md | 6 +- .../client/ui-layout/src/client/AppFrame.tsx | 72 +- packages/client/ui-layout/src/client/index.ts | 21 +- .../client/ui-layout/src/client/stores.ts | 21 +- .../client/ui-layout/tests/app-frame.spec.tsx | 36 +- packages/client/ui-layout/tests/apply.spec.ts | 20 +- packages/client/ui-primitives/package.json | 2 + .../client/ui-primitives/src/Menu.module.css | 16 +- packages/client/ui-primitives/src/Menu.tsx | 82 +- .../client/ui-primitives/src/Modal.module.css | 32 +- packages/client/ui-primitives/src/Modal.tsx | 10 +- packages/client/ui-primitives/src/Tooltip.tsx | 16 +- .../client/ui-primitives/tests/atoms.spec.tsx | 65 ++ .../ui-primitives/tests/tooltip.spec.tsx | 20 + .../tests/question-composer.spec.tsx | 5 +- packages/client/ui-sidebar/README.md | 6 +- .../ui-sidebar/src/client/Rows.module.css | 12 + .../client/ui-sidebar/src/client/Rows.tsx | 102 ++- .../src/client/SidebarRoot.module.css | 31 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 146 ++- .../ui-sidebar/src/client/contract/slots.ts | 89 +- .../client/ui-sidebar/src/client/index.ts | 32 +- packages/client/ui-sidebar/src/client/tree.ts | 303 ++++--- .../client/ui-sidebar/tests/apply.spec.tsx | 131 +-- .../ui-sidebar/tests/sidebar-root.spec.tsx | 336 ++----- packages/client/ui-sidebar/tests/tree.spec.ts | 285 ++---- packages/client/ui-slots/README.md | 2 +- packages/client/ui-slots/src/index.ts | 4 +- packages/client/ui-slots/src/renderer.ts | 5 + .../client/ui-trajectory/tests/views.spec.tsx | 18 +- packages/client/ui-workspace/README.md | 20 + packages/client/ui-workspace/package.json | 65 ++ .../src/client/WorkspacePicker.module.css | 46 + .../src/client/WorkspacePicker.tsx | 196 ++++ .../ui-workspace/src/client/contract/slots.ts | 30 + .../client/ui-workspace/src/client/index.ts | 54 ++ .../client/ui-workspace/src/css-modules.d.ts | 6 + packages/client/ui-workspace/src/index.ts | 9 + packages/client/ui-workspace/src/invariant.ts | 32 + .../client/ui-workspace/tests/apply.spec.ts | 69 ++ .../ui-workspace/tests/invariant.spec.ts | 18 + .../tests/workspace-picker.spec.tsx | 123 +++ packages/client/ui-workspace/tsconfig.json | 33 + packages/client/ui-workspace/tsdown.config.ts | 3 + .../client/web-react/src/scoped-slots.tsx | 7 +- .../tests/scoped-slots-real-core.spec.tsx | 3 + .../web-react/tests/scoped-slots.spec.tsx | 17 + .../web-react/tests/session-provider.spec.tsx | 1 + .../tests/stale-authorization.spec.tsx | 3 + .../cordis/tool-cordis/src/api-catalog.ts | 78 +- packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/package.json | 3 + packages/host/apiproxy/src/api-proxy.ts | 270 +++++- .../host/apiproxy/src/api/events.schema.ts | 4 +- packages/host/apiproxy/src/api/events.ts | 13 +- packages/host/apiproxy/src/api/index.ts | 3 + packages/host/apiproxy/src/api/rpc-map.ts | 3 + packages/host/apiproxy/src/api/rpc.schema.ts | 5 + packages/host/apiproxy/src/api/rpc.ts | 5 + .../host/apiproxy/src/api/sessions.schema.ts | 18 +- packages/host/apiproxy/src/api/sessions.ts | 13 +- .../host/apiproxy/src/api/workspace.schema.ts | 46 + packages/host/apiproxy/src/api/workspace.ts | 55 ++ packages/host/apiproxy/src/fetch/client.ts | 15 + packages/host/apiproxy/src/fetch/handler.ts | 6 + packages/host/apiproxy/src/index.ts | 23 +- .../apiproxy/tests/api-proxy-cold.spec.ts | 4 +- .../apiproxy/tests/api-proxy-view.spec.ts | 8 +- .../tests/api-proxy-workspace.spec.ts | 246 +++++ .../apiproxy/tests/client-handler.spec.ts | 21 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 11 + .../host/apiproxy/tests/rpc-schemas.spec.ts | 37 + packages/host/apiproxy/tsconfig.json | 3 + packages/storage/README.md | 4 +- packages/storage/storage-domain/README.md | 4 +- packages/storage/storage-domain/src/index.ts | 37 +- .../storage-domain/tests/domain.spec.ts | 29 +- packages/storage/storage-json/src/index.ts | 3 +- .../storage-json/tests/json-backend.spec.ts | 4 +- packages/storage/storage-sqlite/src/index.ts | 3 +- .../tests/sqlite-backend.spec.ts | 4 +- packages/storage/storage/src/index.ts | 12 + .../storage/storage/tests/registry.spec.ts | 7 +- packages/workspace/workspace/README.md | 18 +- packages/workspace/workspace/package.json | 5 + packages/workspace/workspace/src/entity.ts | 86 +- packages/workspace/workspace/src/index.ts | 542 ++++++++--- packages/workspace/workspace/src/invariant.ts | 17 +- packages/workspace/workspace/src/spec.ts | 28 +- packages/workspace/workspace/src/types.ts | 38 +- .../workspace/tests/invariant.spec.ts | 9 +- .../workspace/tests/workspace.spec.ts | 858 ++++++++++++------ pnpm-lock.yaml | 60 ++ scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 14 +- .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.client.json | 1 + vitest.web.config.ts | 13 +- 170 files changed, 7573 insertions(+), 3006 deletions(-) create mode 100644 .agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.md create mode 100644 .agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.zh.md create mode 100644 apps/web/tests/workspace-flow.snapshot.ts create mode 100644 packages/client/runtime/src/client/ordered-baseline.ts create mode 100644 packages/client/runtime/src/client/workspaces/manager.ts create mode 100644 packages/client/runtime/src/client/workspaces/service.ts create mode 100644 packages/client/runtime/src/client/workspaces/workspace.ts create mode 100644 packages/client/runtime/tests/session-drafts.spec.ts create mode 100644 packages/client/runtime/tests/workspaces-service.spec.ts create mode 100644 packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx delete mode 100644 packages/client/ui-conversation/tests/hook.ts delete mode 100644 packages/client/ui-conversation/tests/skeleton-branches.spec.tsx create mode 100644 packages/client/ui-workspace/README.md create mode 100644 packages/client/ui-workspace/package.json create mode 100644 packages/client/ui-workspace/src/client/WorkspacePicker.module.css create mode 100644 packages/client/ui-workspace/src/client/WorkspacePicker.tsx create mode 100644 packages/client/ui-workspace/src/client/contract/slots.ts create mode 100644 packages/client/ui-workspace/src/client/index.ts create mode 100644 packages/client/ui-workspace/src/css-modules.d.ts create mode 100644 packages/client/ui-workspace/src/index.ts create mode 100644 packages/client/ui-workspace/src/invariant.ts create mode 100644 packages/client/ui-workspace/tests/apply.spec.ts create mode 100644 packages/client/ui-workspace/tests/invariant.spec.ts create mode 100644 packages/client/ui-workspace/tests/workspace-picker.spec.tsx create mode 100644 packages/client/ui-workspace/tsconfig.json create mode 100644 packages/client/ui-workspace/tsdown.config.ts create mode 100644 packages/host/apiproxy/src/api/workspace.schema.ts create mode 100644 packages/host/apiproxy/src/api/workspace.ts create mode 100644 packages/host/apiproxy/tests/api-proxy-workspace.spec.ts 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/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.i18n.yaml b/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.i18n.yaml new file mode 100644 index 0000000000..7ea5b2afb3 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.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-gui-and-session-drafts.md: 9e44e092ca584a285d5e49c109063aecbbac239d +2026-07-25-workspace-gui-and-session-drafts.zh.md: 7e13e7de281711227bf446e406e0e4a18394cb4f diff --git a/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.md b/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.md new file mode 100644 index 0000000000..9e44e092ca --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.md @@ -0,0 +1,121 @@ +# Agent Note: Workspace GUI and session drafts + +Status: proposed + +English | [中文](2026-07-25-workspace-gui-and-session-drafts.zh.md) + +## Problem + +[Domain KV storage and the Workspace entity](../architecture/2026-07-24-domain-kv-storage-and-workspace.md) define the persistent Workspace entity, path conventions, and ordered session ledger, but do not define Host wiring, historical-data initialization, or GUI flows. The GUI displays Workspaces and Sessions together, and users must be able to type immediately after entering the New Session page, even when no real Session or even real Workspace exists yet. + +Using one intent to represent both a pending Workspace and a pending Session would make explicit Create Workspace actions, the automatic empty state, sidebar draft rows, and first-send failures share an ambiguous state. Creating a Host Session in advance to support the empty state would instead produce an empty Session with no user input, no persisted data before its first event, and no survival across restarts. Existing historical Sessions also expose only `SessionHeader.cwd`, so the system needs to build an initial Workspace view without reading event bodies. + +## Proposal + +### State and ownership + +Workspace and Session are two real Host objects; WorkspaceDraft and SessionDraft are two page-local Client states: + +- A `Workspace` can be empty, persists durably, and always appears in the sidebar; +- A `Session` is a real object already created by the Host; +- A `WorkspaceDraft` exists only for the automatic empty state when the system has no Workspace at all and does not appear in the sidebar; +- A `SessionDraft` represents a pending Session and holds its target Workspace or WorkspaceDraft, preallocated SessionId, composer content, and send phase. + +At most one SessionDraft exists on a page. A draft under a real Workspace appears in the sidebar as “New session”; neither a WorkspaceDraft nor its SessionDraft appears there. A new draft replaces the old one; selecting a real Session or refreshing the page discards any unmaterialized draft and uncommitted input. Real Workspaces, real Sessions, and messages already accepted by the Host are unaffected. + +The Client represents the current page with the discriminated union `ConversationStage = Session | SessionDraft` instead of simulating a draft by clearing current and storing an intent elsewhere. Workspace, Session, and ConversationStage are separate object layers; only a real Session selection can be persisted. + +### End-to-end Host and wire flow + +The Host exposes the following GUI wiring over the existing Workspace entity: + +| RPC | Behavior | +| --- | --- | +| `workspace.list` | Returns real Workspaces in a stable order and filters out session ids that fail header validation | +| `workspace.create({ name })` | Creates a directory at `workspaceRoot/name` and a Workspace when the name is available; duplicate-name requests fail | +| `workspace.create({ path })` | Adopts an existing directory without creating directories for arbitrary input paths | +| `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a real Session with an optional preallocated id, and attaches it | +| `session.create({ cwd })` | Remains available to non-GUI callers and creates an Ungrouped Session | + +`workspaceRoot` is an independent Host configuration that falls back to the Host cwd when unset; it is unrelated to the `storageRoot` that stores Workspace domain data. The Host stream pushes incremental Workspace and Session updates, while reconnection uses `workspace.list` and `session.list` as its two baselines. + +The GUI preallocates a SessionId in SessionDraft but creates no Host intent before the first send. On the first send, the Client passes that id to `session.create`; the Host uses the same id to create both the real Session and its persistence create-intent. Retrying the same id with the same cwd is idempotent; an existing id with a different cwd fails loudly. This lets a lost response or partial attach failure reconcile to the same Session instead of creating a duplicate. + +A Workspace's `sessionIds` is an ordered candidate index. A Session is a member only when its id is present in the index and its canonicalized `SessionHeader.cwd` equals 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 with a missing header or mismatched cwd does not enter the projection. A Session appearing in two Workspace indexes is corrupt state and fails loudly. + +### One-time historical initialization + +The Workspace domain uses a durable marker to distinguish “never initialized” from “initialized but empty.” When the marker is absent, WorkspaceRegistry performs a reentrant bootstrap once: + +1. Call `SessionPersistence.list()` exactly once; JSONL reads only the first header line, SQLite reads only session metadata rows, and the bootstrap must not call `load`, `inspect`, history APIs, or parse event bodies. +2. Ignore headers with no cwd, a nonexistent path, a path that is not a directory, or a failed realpath lookup; these Sessions remain Ungrouped. +3. Group by canonical cwd, sort each group by header `createdAt` in descending order before writing `sessionIds`, and order Workspace groups stably by each group's maximum `createdAt`, also descending. +4. After a crash, reentry reuses Workspaces already written for the same canonical path and merges missing ids; write the marker last, after all records are durable. + +After the marker is written, the system no longer creates Workspaces or backfills their ledgers automatically from cwd. Subsequent call paths that omit `workspaceId` remain Ungrouped; this is a compatibility path, not a second source that continuously derives Workspaces. + +### User flows + +On initial entry, the Client waits until both Workspace and Session baselines are ready; it restores a still-existing real Session selection when possible and otherwise enters the New Session flow. When the user explicitly enters New Session, the Client does not restore the old selection: it selects the most recent Workspace and creates a SessionDraft. The most recent Workspace is determined by the maximum `updatedAt` among its validated member Sessions, with an empty Workspace falling back to `createdAt`. This value only chooses the default target for New Session; it neither changes the sidebar Workspace order nor triggers a second selection after the Session list arrives. + +When no Workspace exists at all, the page creates a WorkspaceDraft named `workspace` and its SessionDraft. Neither is written to the Host, but the composer always remains editable. The top-level New Session action reenters this empty-state selection flow without calling `session.create` immediately. + +The plus button in the Sidebar Workspace section header and the Workspace creation entry in the composer reuse the same picker and modal: + +- Select an existing Workspace: create only a SessionDraft targeting that Workspace; +- Use an existing folder: call `workspace.create({ path })`, then create a SessionDraft under it after success; +- Create new: use one input as both the directory name and title; the UI disables confirmation when an existing Workspace has that title, and the Host rejects duplicate-name requests caused by bypassing the UI or concurrent creation; after success, create a SessionDraft under it. + +Explicit Create Workspace creates a real Workspace as soon as the user confirms and immediately displays it in the sidebar; the empty Workspace remains even if the user never sends a message. The inline plus button on a Workspace row creates only a SessionDraft under that group: it neither creates another Workspace nor immediately creates a Host Session. + +Sending the first message performs these steps in order: create the Workspace when necessary, create the Session with the preallocated id, hand the stage and composer buffer off to the real Session, and call `session.prompt`. The Client clears the input only after the Host accepts the prompt. A Workspace remains if failure occurs after it is created; a real Session remains selected if failure occurs after it is published; a prompt failure retains the original input and retries the same Session. + +### Sidebar and ordering + +Workspace groups use the persistent stable order returned by the Host. Bootstrap establishes the historical order once, and explicitly created Workspaces go first; Session activity never moves Workspace groups. + +Within a group, Sessions render strictly in `Workspace.sessionIds` order. Historical Sessions are initialized from the header `createdAt`, and new Sessions go first; whenever a Session's `updatedAt` advances afterward, the Host moves only that id to the front of its Workspace and persists the change. The Client does not batch-sort by `updatedAt` after Session list hydration, so the page never displays the bootstrap order and then jumps as a whole. + +SessionDraft is a presentation-layer row appended without writing to `sessionIds`. When a real Workspace has a SessionDraft, the sidebar's page-derived session count temporarily increases by one; once the real Session with the same id appears, it must not be counted twice, and refreshing removes both the draft and its temporary count. `host/session-added` and `host/workspace-changed` may arrive in either order; the Client merges them by the preallocated SessionId and removes the draft once the real row can be located, without ever briefly showing two rows with the same id. + +### Client and UI boundaries + +A dedicated WorkspacesService manages the Workspace list phase, incremental upserts, reconnect refresh, creation, and recent-Workspace derivation. SessionsService manages only the real Session list, Session scope, history, running state, and real selection. A page-local conversation coordinator manages ConversationStage, SessionDraft, materialization phase, errors, and composer-buffer handoff. + +The existing sidebar layout, row styles, EmptyHero, composer styles, Menu/Modal/Tooltip, portal and slot infrastructure, and `ui-workspace` component skeleton can remain. The Workspace/Session state boundary, empty state, creation actions, first-send state machine, historical initialization, and component props need to be rewritten. The Sidebar and conversation-empty entry points must use the same Workspace data and creation actions; only their anchor direction, open state, and selection callback may differ. + +This phase uses English UI text and does not provide Workspace rename/delete, Session delete, cross-Workspace moves, drag ordering, manual adoption from Ungrouped, multiple SessionDrafts, draft restoration after refresh, or separate display-name and directory-name inputs. + +## Alternatives considered + +**Continue deriving Workspaces dynamically from cwd.** This cannot represent empty Workspaces, stable display names, or explicit order, and it would automatically adopt non-GUI Sessions. Derivation is allowed only for the one-time historical bootstrap; ownership must subsequently be written explicitly to the index. + +**Use one WorkspaceIntent to represent both WorkspaceDraft and SessionDraft.** Their visibility, persistence, and materialization timing differ. Combining them prevents explicit Create Workspace from taking effect immediately and prevents the sidebar from distinguishing a hidden WorkspaceDraft from a draft row under a real Workspace. + +**Create a Host Session or Host persistence intent immediately for the empty state.** A Session with no input would enter the Host lifecycle, while refresh semantics would conflict with a page-local draft. Only a Client SessionDraft exists before the first send. + +**Delay explicit Create Workspace until the first send.** The sidebar would still have no real empty Workspace after user confirmation, conflating “Create Workspace” with “prepare Session.” Only the automatic no-Workspace empty state allows delayed creation. + +**Batch-reorder on the Client by updatedAt after the Session list arrives.** The page would first show the bootstrap `createdAt` order and then jump as a whole, while reconnection could not restore the same order. The Host moves only the corresponding id when an individual Session becomes active. + +**Add workspaceId to SessionHeader.** This would create two persistent ownership fields alongside the Workspace index and require dual writes. The header retains the Session's own cwd fact, the Workspace index owns explicit membership, and reads validate both directions. + +## Acceptance criteria + +- Explicit Create Workspace immediately creates and displays an empty Workspace; the automatic empty state with no Workspace writes nothing to the Host and remains editable. +- New Session, selecting an existing Workspace, both Workspace creation methods, and the inline plus button on a Workspace row each produce the single SessionDraft and follow the sidebar visibility rules. +- The first send materializes Workspace, Session, and prompt in that order; successful stages are not rolled back, input is retained until the prompt is accepted, and retries use the same SessionId. +- The Workspace list performs one reentrant bootstrap using headers only; tests prove it never reads event bodies and that an initialized empty registry does not repeat the bootstrap after restart. +- Membership reads validate both the index and header cwd; cwd-only Sessions, invalid historical cwd values, and failed attaches become Ungrouped. +- Initial rendering waits for both baselines to be ready; Session activity does not move Workspace groups, arrival of the Session list does not trigger a full reorder, and activity in one Session moves only that Session to the front and preserves the order across reconnection. +- Workspace and Session updates arriving in either order never create duplicate Session rows; every first-send failure stage can recover to the same preallocated id. +- Create new rejects duplicate Workspace names at both the UI and Host layers; a SessionDraft under a real Workspace temporarily counts toward the sidebar total, and neither materialization nor refresh leaves a duplicate count. +- Real runnable keyless snapshots cover the empty state, explicit creation, successful first send, failed first send, refresh, and Ungrouped; package-level tests cover bootstrap, bidirectional membership validation, ordering, and idempotency. + +## Risks + +- Header-only bootstrap has no historical activity time and can initialize order only from `createdAt`; it does not batch-correct from the Session list afterward, and only new activity in individual Sessions progressively changes in-group order. +- Historical Sessions with a missing cwd or a path that cannot be resolved by realpath remain Ungrouped; this phase has no manual adoption entry point. +- Refreshing the page discards WorkspaceDraft, SessionDraft, and input not yet accepted by the Host; this is the page-local contract. +- Before its first event, a Host Session still has only a live object and a persistence create-intent; restarting the Host loses that empty Session. This design does not change the existing lazy-persistence semantics by persisting page drafts. +- Explicit Create Workspace persists immediately, so leaving without sending a message still leaves an empty Workspace; this is the cost of making the operation take effect immediately. diff --git a/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.zh.md b/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.zh.md new file mode 100644 index 0000000000..7e13e7de28 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.zh.md @@ -0,0 +1,121 @@ +# Agent Note: Workspace GUI and session drafts + +[English](2026-07-25-workspace-gui-and-session-drafts.md) | 中文 + +Status: proposed + +## Problem + +[Domain KV storage 与 Workspace entity](../architecture/2026-07-24-domain-kv-storage-and-workspace.md)定义了 Workspace 的持久实体、路径规范和有序 session 账本,但没有定义 Host 接线、历史数据初始化或 GUI 动线。GUI 同时显示 Workspace 和 Session,并且用户进入 New Session 页面后必须立即输入,即使此时还没有真实 Session,甚至没有真实 Workspace。 + +若用一个 intent 同时表示待创建 Workspace 和待创建 Session,显式 Create Workspace、自动零态、sidebar draft 行和首发失败会共享一组含混状态。若为了解决零态而提前创建 Host Session,又会产生没有用户输入、首个事件前不落盘且重启即消失的空 Session。现有历史 Session 还只有 `SessionHeader.cwd`,需要在不读取事件正文的前提下建立一次初始 Workspace 视图。 + +## Proposal + +### 状态与所有权 + +Workspace 与 Session 是两个真实 Host 对象;WorkspaceDraft 与 SessionDraft 是两个 page-local Client 状态: + +- `Workspace` 可以为空,持久存在并始终显示在 sidebar; +- `Session` 是已经由 Host 创建的真实对象; +- `WorkspaceDraft` 只用于“系统完全没有 Workspace”的自动零态,不显示在 sidebar; +- `SessionDraft` 表示一个待创建 Session,持有目标 Workspace 或 WorkspaceDraft、预分配 SessionId、composer 内容和发送 phase。 + +页面至多存在一个 SessionDraft。真实 Workspace 下的 draft 在 sidebar 显示为 “New session”;WorkspaceDraft 及其 SessionDraft 都不显示。新 draft 替换旧 draft;选择真实 Session 或刷新页面会丢弃未物化 draft 和未提交输入。真实 Workspace、真实 Session 和已经接受的消息不受影响。 + +Client 用判别联合 `ConversationStage = Session | SessionDraft` 表达当前页面,不再用“清空 current 再另存 intent”模拟草稿。Workspace、Session 和 ConversationStage 各有独立对象层;只有真实 Session selection 可以持久化。 + +### Host 与 wire 全链路 + +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 })` | 保留给非 GUI 调用方,创建 Ungrouped Session | + +`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 和 Session 增量,重连以 `workspace.list` 与 `session.list` 两份基线为准。 + +GUI 在 SessionDraft 中预分配 SessionId,但首次发送前不创建任何 Host intent。首次发送时,Client 才把该 id 传给 `session.create`;Host 用同一 id 创建真实 Session 和 persistence create-intent。相同 id、相同 cwd 的重试幂等;id 已存在但 cwd 不同则 fail loud。这样响应丢失和 attach 部分失败都能对账到同一个 Session,而不是重复创建。 + +Workspace 的 `sessionIds` 是有序候选索引。读取成员必须同时满足 id 在索引中且 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 仍是 Ungrouped,索引命中但 header 缺失或 cwd 不匹配的 id 不进入投影。同一 Session 出现在两个 Workspace 索引中属于损坏状态并 fail loud。 + +### 一次性历史初始化 + +Workspace domain 用 durable marker 区分“从未初始化”和“已初始化但为空”。marker 未设置时,WorkspaceRegistry 执行一次可重入 bootstrap: + +1. 只调用一次 `SessionPersistence.list()`;JSONL 只读 header 首行,SQLite 只读 session 元数据行,禁止调用 `load`、`inspect`、history 或解析事件正文。 +2. 忽略无 cwd、目录不存在、非目录或 realpath 失败的 header;这些 Session 留在 Ungrouped。 +3. 按 canonical cwd 分组,组内按 header `createdAt` 降序写入 `sessionIds`,Workspace 组按各组最大 `createdAt` 降序写入稳定顺序。 +4. 崩溃重入时按 canonical path 复用已经写入的 Workspace 并合并缺失 id;全部记录 durable 后最后写 marker。 + +marker 写入后不再按 cwd 自动建 Workspace 或补账。后续绕过 `workspaceId` 的调用链保持 Ungrouped;这是一条兼容路径,不是持续派生 Workspace 的第二写源。 + +### 用户动线 + +应用首次进入时,Client 等待 Workspace 与 Session 两份基线都 ready;仍存在的真实 Session selection 可以恢复,否则进入 New Session 流程。用户显式进入 New Session 时不恢复旧 selection,而是选择最近 Workspace 并创建 SessionDraft。最近 Workspace 取其已验证成员 Session 的最大 `updatedAt`;空 Workspace 回退到 `createdAt`。该值只决定 New Session 的默认目标,不改变 sidebar 的 Workspace 顺序,也不会在 Session list 到达后触发二次选择。 + +完全没有 Workspace 时,页面创建名为 `workspace` 的 WorkspaceDraft 和其 SessionDraft。它们不写 Host,但 composer 始终可输入。顶部 New Session 重新进入该零态选择流程,不立即调用 `session.create`。 + +Sidebar Workspace 区头加号和 composer 的 Workspace 创建入口复用同一个 picker 与 modal: + +- 选择已有 Workspace:只创建指向该 Workspace 的 SessionDraft; +- Use an existing folder:调用 `workspace.create({ path })`,成功后创建其下的 SessionDraft; +- Create new:用一个输入同时作为目录名和 title;UI 对已有 Workspace title 禁止确认,Host 拒绝绕过 UI 或并发产生的重名请求;成功后创建其下的 SessionDraft。 + +显式 Create Workspace 在用户确认时立即产生真实 Workspace,并立即显示在 sidebar;即使用户不发送消息,也会留下空 Workspace。Workspace 行内加号只创建该组下的 SessionDraft,不创建另一个 Workspace,也不立即创建 Host Session。 + +发送首条消息时依次执行:必要时创建 Workspace、以预分配 id 创建 Session、把 stage 和 composer buffer 转交给真实 Session、调用 `session.prompt`。只有 Host 接受 prompt 后才清空输入。Workspace 已创建后失败则保留 Workspace;Session 已发布后失败则保留并聚焦真实 Session;prompt 失败则保留原输入并重试同一 Session。 + +### Sidebar 与排序 + +Workspace 组使用 Host 返回的持久稳定顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放到首位;Session 活跃不会移动 Workspace 组。 + +组内严格按 `Workspace.sessionIds` 渲染。历史 Session 以 header `createdAt` 初始化,新 Session 放到首位;此后某个 Session 的 `updatedAt` 前进时,Host 只把该 id 移到所属 Workspace 的首位并持久化。Client 不在 Session list hydration 后按 `updatedAt` 批量排序,因此页面不会先显示 bootstrap 顺序再整体跳动。 + +SessionDraft 是渲染层附加行,不写入 `sessionIds`。真实 Workspace 下存在 SessionDraft 时,sidebar 的页面派生 session 数量临时加一;同 id 的真实 Session 出现后不能重复计数,刷新后 draft 与临时计数一起消失。`host/session-added` 与 `host/workspace-changed` 可能以任意顺序到达;Client 按预分配 SessionId 合并,并在真实行可定位后移除 draft,不能短暂显示两个同 id 行。 + +### Client 与 UI 边界 + +独立 WorkspacesService 管理 Workspace list phase、增量 upsert、重连 refresh、create 和最近 Workspace 派生;SessionsService 只管理真实 Session list、Session scope、history、running 状态与真实 selection;page-local conversation coordinator 管理 ConversationStage、SessionDraft、物化 phase、错误和 composer buffer 转交。 + +现有 sidebar 布局、行样式、EmptyHero、composer 样式、Menu/Modal/Tooltip、portal、slot 基建和 `ui-workspace` 组件骨架可以保留。需要重写的是 Workspace/Session 状态边界、零态、创建动作、首发状态机、历史初始化和组件 props。Sidebar 与 conversation empty 两个入口必须使用同一 Workspace 数据和创建动作,只允许锚点方向、开关状态与选中回调不同。 + +本期 UI 使用英文,不提供 Workspace rename/delete、Session delete、跨 Workspace 移动、拖拽排序、Ungrouped 手动收编、多 SessionDraft、draft 刷新恢复或显示名与目录名的双输入。 + +## Alternatives considered + +**继续按 cwd 动态派生 Workspace。** 该方案无法表示空 Workspace、稳定显示名或显式顺序,也会把非 GUI Session 自动收编;只允许一次历史 bootstrap,之后归属必须显式写入索引。 + +**用一个 WorkspaceIntent 同时表示 WorkspaceDraft 与 SessionDraft。** 两者的显示、持久化和物化时点不同;合并后显式 Create Workspace 无法立即生效,sidebar 也无法区分隐藏 WorkspaceDraft 与真实 Workspace 下的 draft 行。 + +**零态立即创建 Host Session 或 Host persistence intent。** 未输入的 Session 会进入 Host 生命周期,刷新语义与 page-local 草稿冲突;首次发送前只保留 Client SessionDraft。 + +**显式 Create Workspace 延迟到首次发送。** 用户确认后 sidebar 仍没有真实空 Workspace,“Create Workspace”与“准备 Session”语义混合;只有自动无 Workspace 零态允许延迟。 + +**Client 在 Session list 到达后按 updatedAt 批量重排。** 页面会先展示 bootstrap 的 `createdAt` 顺序再整体跳动,重连也无法恢复同一顺序;Host 只在单个 Session 活跃时前移对应 id。 + +**在 SessionHeader 中增加 workspaceId。** 它会与 Workspace 索引形成两个持久归属字段并要求双写;header 保留 session 自身的 cwd 事实,Workspace 索引负责显式归属,读取时双向校验。 + +## Acceptance criteria + +- 显式 Create Workspace 立即创建并显示空 Workspace;完全无 Workspace 的自动零态不写 Host 且允许输入。 +- New Session、选择已有 Workspace、两种 Workspace 创建方式和 Workspace 行内加号都产生唯一的 SessionDraft,并遵守 sidebar 可见性规则。 +- 首发按 Workspace、Session、prompt 顺序物化;各已成功阶段不回滚,输入在 prompt 接受前不丢失,重复创建使用同一 SessionId。 +- Workspace list 只用 header 完成一次可重入 bootstrap;测试证明不读取事件正文,initialized 的空 registry 重启也不重复执行。 +- 归属读取同时校验索引与 header cwd;cwd-only Session、无效历史 cwd 和 attach 失败进入 Ungrouped。 +- 首次渲染等待两份基线 ready;Workspace 组不因 Session 活跃移动,Session list 到达不触发整体重排,单个活跃 Session 只前移自身并在重连后保持顺序。 +- Workspace 与 Session 增量以任意顺序到达都不会产生重复 Session 行;首发各失败阶段都能恢复到同一个预分配 id。 +- Create new 在 UI 与 Host 两层拒绝重名 Workspace;真实 Workspace 下的 SessionDraft 临时计入 sidebar 数量,物化与刷新都不会留下重复计数。 +- 真实 runnable keyless snapshot 覆盖零态、显式创建、首发成功、首发失败、刷新和 Ungrouped;包级测试覆盖 bootstrap、双向归属、排序与幂等。 + +## Risks + +- Header-only bootstrap 没有历史活跃时间,只能用 `createdAt` 初始化顺序;初始化后不按 Session list 批量修正,只有新的单项活跃逐步改变组内顺序。 +- 历史 cwd 缺失或无法 realpath 的 Session 会留在 Ungrouped;本期没有手动收编入口。 +- 页面刷新会丢弃 WorkspaceDraft、SessionDraft 和尚未接受的输入;这是 page-local 契约。 +- Host Session 在首个事件前仍只有 live 对象和 persistence create-intent,Host 重启会丢失该空 Session;本设计不通过持久化页面 draft 改变现有懒持久化语义。 +- 显式 Create Workspace 立即落盘,因此用户不发送就离开也会留下空 Workspace;这是该操作真实生效的代价。 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 b637bcd2b8..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 and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project, 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`). +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 d8a75e2bb5..e1c07f90b5 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -32,6 +32,7 @@ "@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:^", @@ -49,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:^", @@ -68,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 ce26903551..29e20b87b8 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -77,6 +77,8 @@ export interface AppCLIEntryOptions { * browser). */ port?: number + /** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */ + workspaceRoot?: string } /** @@ -141,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. 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/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index 673e92f9ce..5fbb23814b 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(() => { diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts new file mode 100644 index 0000000000..6ebc3bb7c0 --- /dev/null +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -0,0 +1,317 @@ +// @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() +} + +/** The labelled chip and its adjacent plus button intentionally share a label. */ +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 +} + +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' }) + fireEvent.change(composer, { target: { value: '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' }) + fireEvent.change(composer, { target: { value: '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 }) + fireEvent.change(composer, { target: { value: '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 }) + fireEvent.change(composer, { target: { value: '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 }) + fireEvent.change(composer, { target: { value: '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 6363add08e..e501a5b277 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"] svc_sessionReferences["ctx.sessionReferences
Cross-session snapshot preparation"] @@ -180,6 +182,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 @@ -253,7 +256,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 @@ -282,6 +285,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 ``` @@ -293,8 +297,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) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service. | | `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 21e977e378..6c7627aca4 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` @@ -1217,7 +1219,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` @@ -2024,6 +2026,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)) @@ -2040,7 +2043,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) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 86fc08803c..2e30e8602b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1443,7 +1443,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` @@ -1907,49 +1950,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 6988c3b5c3..9e8b3adf8a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -147,6 +147,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 @@ -257,6 +258,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 @@ -814,6 +819,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/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..d860c69234 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,177 @@ 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('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 +482,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 +518,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..f1e13f007a 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 owns Workspace objects, list/actions, page-local Workspace Intent state, and default-target derivation. 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..8d9dd4d958 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,84 @@ 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 + } + + /** @returns the active frontend Session, if one remains selected. */ + getIntent(): Session | undefined { + return this.intentSessionId === undefined ? undefined : this.sessions.get(this.intentSessionId) + } + + /** @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 +178,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 +193,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 +216,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 +243,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 +254,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 +292,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 +388,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 +417,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 +446,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..f77717d7f0 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,81 @@ 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. + */ + startIntent(target: SessionIntentTarget, prompt = ''): Session { + return this.manager.startIntent(target, prompt) + } + + /** @returns the active frontend Session object, if one exists. */ + intent(): Session | undefined { + return this.manager.getIntent() + } + + /** @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 +318,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 +375,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 +390,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..4173318fc2 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,57 @@ export class Session implements ObservableSnapshot { return result } + /** @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 +359,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 +397,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 +719,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-drafts.spec.ts b/packages/client/runtime/tests/session-drafts.spec.ts new file mode 100644 index 0000000000..c09bfa4ee4 --- /dev/null +++ b/packages/client/runtime/tests/session-drafts.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..0b7e4ef247 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 and publishes the two intents. The Session object 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..6e1ca2b70e 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,27 @@ export class ConversationService extends Service { if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`) } - /** - * 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. - */ - 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) + /** Pull one older history page for the scoped Session. */ + async loadOlder(): Promise { + await this.scopedSession('loadOlder').loadOlder() + } + + /** Update the scoped Session's retained pending prompt. */ + 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({
-