Merge remote-tracking branch 'origin/master' into worktree/web-session-titles

# Conflicts:
#	.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
#	packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
#	packages/client/ui-conversation/tests/selection-survival.spec.ts
#	packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/client/ui-layout/tests/service.spec.ts
#	packages/client/ui-sidebar/tests/apply.spec.tsx
#	packages/client/ui-sidebar/tests/store.spec.ts
#	packages/client/ui-trajectory/tests/views.spec.tsx
#	packages/client/web/src/app.tsx
#	packages/client/web/tests/boot.spec.tsx
#	packages/host/runtime/README.md
#	packages/host/runtime/tests/host-runtime.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-23 18:39:48 +08:00
285 changed files with 11631 additions and 6072 deletions

View File

@@ -1,42 +1,51 @@
# AGENTS.md — Web client stack
Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md); read the two architecture notes linked below before structural changes.
Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md). Before touching slots, component props, stores, or plugin structure, read the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) (the definitive composition model) and the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) (loading chain, object layer, services).
Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-<name>`.
## Slot and props discipline
The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) owns the full design; these are the rules you must not violate when writing or reviewing client code:
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: `<domain>.<entry>.<hole>` (e.g. `'conversation.chat.toolview'`).
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + `useSessions`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (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.)
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.
## Export discipline (client plugin packages)
The `/client` surface of a UI plugin package is a contract face, not a convenience barrel. Three rules, enforced package-wide (do not restate them as per-file comments):
1. **A UI plugin exports no values beyond what cordis loading needs**`apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType<typeof createXXXStore>`). Types are the extra allowance: contract types (owner shares, injected shapes, composed props aliases) export freely. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer.
2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public surface to make a test compile.
3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself.
## ctx discipline (components never see ctx)
`ctx` belongs to the apply world only: the plugin body and the inject factories closed over it. Components — every `.tsx` under a feature domain — receive all data and callbacks **through the four props shares**; they never call a hook that reaches ctx, never import a service class to poke it, never read a React context (business components see zero contexts — `BindingContext` and its kin are renderer-internal). If a component needs something new, the answer is a prop threaded from its share's source (owner site, store declaration, or inject face), not a hook.
## Layering red lines
The stack is three layers with one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md):
The stack has one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md):
1. **Data object layer** (`web-runtime`, React-free): `ConnectionController``SessionManager``Session` own all business state (event windows, streaming accumulation, reconnect machine). Zero React imports — grep-assertable.
2. **Hooks layer** (`web-ui/src/hooks`, pure data): subscribes to object snapshots via `useSyncExternalStore`, exposes plain-data handles. No JSX, no DOM.
3. **Presentation components** (`web-ui`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; they receive data and callbacks through props only.
1. **Data object layer** (`runtime`, React-free): `ConnectionController``SessionManager``Session` own all business state (event windows, streaming accumulation, reconnect machine), and the snapshot-store engine (zustand/immer, `defineStore`, `shallowEqual`) lives here too — store products are bare observable sources with no hook members. Zero React imports — grep-assertable.
2. **Render machinery** (`web-react`, shell-only glue): the whole ctx↔React boundary — slot renderer/outlets, `SessionProvider`, the uSES bridge. Every hook is composed here at the binding site from bare sources; business plugin packages carry no web-react dependency at all.
3. **Presentation components** (plugin packages' `src/client/`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the four props shares.
Non-negotiables across the layers:
- **No business objects in the store.** zustand carries cross-view presentation state only (`rpcLog`, `ui`, `connection` slices). Sessions, frames, and connections live in the object layer. View-local facts (selection, expansion) stay in component state, not the store.
- **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer.
- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest<P>`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)).
- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `web-runtime/src/session/notifier.ts`.
- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `runtime/src/client/sessions/notifier.ts`.
- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
## Directory regime (`web-ui/src`)
## Directory regime (plugin packages)
> Shell restructure in progress: the tree is converging to this layout (today's `components/{conversation,sessions,panels}` migrate into it); the regime below is the target every new feature follows now.
Two-level feature directories, one contributor per directory — physical conflict avoidance:
```
web-ui/src/
shell/ # AppShell + the three slot registries + builtins
leftmenu/<bar>/ # one directory per left-nav bar (sessions, rpclog, …)
sessiontabs/<tab>/ # one directory per session tab (conversation, gantt, …)
components/ # shared leaves (MessageText, JsonBlock, …)
hooks/ utils/ style/ # cross-cutting; not feature-owned
```
- `leftmenu/<a>` must not import `leftmenu/<b>` or `sessiontabs/*` (and vice versa). Anything two features need sinks into `components/`.
- Bars, tabs, and detail blocks register through the `shell/` registries (module-level map, `register*()` returns the disposer — same shape as `toolCardRegistry`). v1 registration is static in `shell/builtins.ts`; plugin-driven registration later calls the same functions.
- **Claiming a placeholder slot**: pick a `placeholder: true` tab (or add a bar) in `shell/builtins.ts`, create your feature directory, and replace the placeholder component with your container. Don't build features outside this regime.
One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects.
## Styling
@@ -63,9 +72,9 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
## New component checklist
1. Claim the slot (see the directory regime above): one feature, one directory.
2. Build the container in your feature directory; keep leaves pure-props. Wire data through the hooks layer, not by importing business objects into components.
3. Copy a neighbouring jsdom spec into `web-ui/tests/`, keep it behavior-shaped: start from the happy path and the edge states, then widen until the component's branches are covered — the coverage gate applies; only the assertion style stays behavior-level.
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.
2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local.
3. Component tests feed props directly (`createXXXStore().create()` for the store share; plain stubs for framework hooks) — behavior-shaped assertions, no render machinery.
4. Tokens only in CSS; Chinese product copy; English comments.
5. `pnpm run test:gui` green (plus `test:web` if you touched the build surface).
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the three GUI notes above are the precedents to extend.
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend.

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-connection
Wire consumer layer (moved verbatim from web-runtime): IApiClient family (WebApiClient/FixtureApiClient), ConnectionController (SSE dual-stream + backoff reconnect), WEB_EVENTS. Contract: api-contracts v3 §3, export inventory in §3.2.
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.
## Model Experience

View File

@@ -75,8 +75,9 @@ function buildAlphaLog(): SessionEvent[] {
}
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
// Three view-sample turns (60-62) for the tool-card wire acceptance: one per built-in card
// type. `echo` above stays presenter-less on purpose — it is the no-view fallback sample.
// Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in
// turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above
// stays presenter-less as the unknown fallback.
const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
const callId = `fx-call-${turn}`
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
@@ -93,7 +94,8 @@ function buildAlphaLog(): SessionEvent[] {
}
toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'fx-note', '{"note":"三型卡验收样本"}', '已记录')
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
return events as unknown as SessionEvent[]
}
@@ -118,8 +120,10 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
card: 'diff', title: `Write ${str(args.path)}`,
diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
}
case 'fx-note':
return { card: 'generic', title: '记录笔记', kind: 'edit', rawInput: args }
case 'edit':
return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args }
case 'write':
return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args }
default:
return undefined // echo et al: the documented no-view fallback path
}

View File

@@ -21,16 +21,13 @@ export type {
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
} from './api.ts'
export { RpcId, AbstractApiClient, resultOf, transportError } from './api.ts'
export { RpcId, AbstractApiClient, transportError } from './api.ts'
// ---- Connection loop ----
export { ConnectionController } from './connection.ts'
// ---- Connection loop types (part of the ConnectionHandle.start contract;
// the controller class itself stays package-internal — apply owns the loop,
// tests reach it via src) ----
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
// ---- Platform client subclasses ----
export { WebApiClient } from './web-api-client.ts'
export { FixtureApiClient, createFixtureApi } from './fixture.ts'
/** Required services (none — this is the wire root). */
export const inject: string[] = []

View File

@@ -1,14 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
"outDir": "lib/types"
},
"include": [
"src"

View File

@@ -33,7 +33,7 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-web-react": "workspace:^"
"@deepseek-ai/dsh-client-runtime": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",

View File

@@ -5,8 +5,13 @@
* Contract: api-contracts v3 section 8.
*/
import type { Context } from 'cordis'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
// The snapshot-store engine lives in runtime (store relocation): framework
// data stores like this locale cell use it directly. The store carries no
// hook — a React consumer binds a selector hook via web-react's
// bindSnapshotSelector at its own seam (none exists today; the current
// consumers are translate() reads and test-side subscribe/set).
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { en } from '../locales/en.ts'
import { zh } from '../locales/zh.ts'

View File

@@ -1,14 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
"outDir": "lib/types"
},
"include": [
"src"
@@ -18,7 +12,7 @@
"path": "../../../vendor/cordis"
},
{
"path": "../web-react"
"path": "../runtime"
},
{
"path": "../../support/invariants"

View File

@@ -1,6 +1,6 @@
# @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), Session object layer, ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
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.
## Session title projection
@@ -17,5 +17,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
- **Scope teardown is watch-approximated** — the most recently resolved binding stands in for "who is watching"; a removed-while-watched session's scope survives until the watch moves away, not until true observer count reaches zero.
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`cell()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land.
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).

View File

@@ -37,10 +37,11 @@
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"immer": "^10.1.1",
"react": "^18.2.0",
"@deepseek-ai/dsh-session": "workspace:^"
"zustand": "~4.4.7"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",

View File

@@ -0,0 +1,244 @@
/**
* Snapshot store engine (zustand vanilla + immer + subscribeWithSelector +
* rafFlush middleware + opt-in persist + dev freeze) plus the declarative
* shell over it: {@link defineStore} bakes an init/persist/actions literal
* into a {@link StoreHandle}, the registration-side store seat of the slot
* terminal design (§4). Lives in the React-free runtime (store-migration
* ruling: the data layer owns its engine; web-react is shell-only React
* glue): engine products are bare observables — subscribe/getSnapshot/
* update/set, NO selector hook. Hook synthesis is web-react's (the one
* uSES bridge, cached per source at the binding site).
*/
import { createStore, type StoreApi } from 'zustand/vanilla'
import { subscribeWithSelector } from 'zustand/middleware'
import { shallow } from 'zustand/shallow'
import { produce } from 'immer'
import type {
ActionsDecl, BakedActions, StoreHandle, StoreInstance, StoreSpec,
} from '@deepseek-ai/dsh-client-ui-slots'
// Store contract types are ui-slots authority; re-exported beside the engine
// so store consumers get one import surface.
export type {
ActionsDecl, BakedActions, BoundActions, StoreFactory, StoreHandle, StoreInstance, StoreSpec,
} from '@deepseek-ai/dsh-client-ui-slots'
/** Minimal observable snapshot source: Session objects and snapshot stores both satisfy it. */
export interface ObservableSnapshot<T> { getSnapshot(): T; subscribe(fn: () => void): () => void }
/** Writable snapshot store (bare data face; React selector hooks are synthesized in web-react). */
export interface SnapshotStore<T> extends ObservableSnapshot<T> {
/**
* Mutate the state through an immer draft.
* @param mutator - draft mutator.
*/
update(mutator: (draft: T) => void): void
/**
* Replace the state wholesale.
* @param next - next state.
*/
set(next: T): void
}
/**
* Shallow equality for selector slices (zustand/shallow semantics; travels
* with the engine so hook consumers need no zustand dependency).
* @param a - left value.
* @param b - right value.
* @returns whether the values are shallowly equal.
*/
export function shallowEqual(a: unknown, b: unknown): boolean {
return shallow(a, b)
}
/** Batches subscriber notification into one flush per animation frame. */
function rafBatch(notify: () => void): () => void {
// Fall back to microtask batching where rAF is absent (node unit tests);
// both preserve the N-changes=1-notification contract within a tick.
const schedule: (fn: () => void) => void =
typeof requestAnimationFrame === 'function'
? (fn) => { requestAnimationFrame(() => { fn() }) }
: (fn) => { queueMicrotask(fn) }
let scheduled = false
return () => {
if (scheduled) return
scheduled = true
schedule(() => {
scheduled = false
notify()
})
}
}
/**
* Create a snapshot store.
*
* Flush default is 'sync' (controlled inputs need same-tick echo); frame-driven
* stores opt into 'raf', where a frame's worth of updates coalesces into one
* notification. Known raf-mode tradeoff: a component mounting mid-frame reads
* fresh state while existing subscribers hear it next flush — transient
* frame-level skew, same nature as the object layer's microtask batching.
*
* @param init - initial state.
* @param opts - flush mode and opt-in persistence (localStorage, keyed by name).
* @returns the store.
*/
export function createSnapshotStore<T>(
init: T, opts?: { flush?: 'raf' | 'sync'; persist?: { name: string } }): SnapshotStore<T> {
// Immer enters through produce() in update() below (identical semantics to
// the immer middleware without its setState-signature mutator generics).
const withSelector = subscribeWithSelector(() => init)
const api: StoreApi<T> = createStore<T>()(withSelector)
if (opts?.persist) attachPersistence(api, opts.persist.name)
let subscribe = (fn: () => void) => api.subscribe(fn)
if (opts?.flush === 'raf') {
const listeners = new Set<() => void>()
const flush = rafBatch(() => { for (const fn of [...listeners]) fn() })
api.subscribe(flush)
subscribe = (fn: () => void) => {
listeners.add(fn)
return () => { listeners.delete(fn) }
}
}
return {
getSnapshot: () => api.getState(),
subscribe: fn => subscribe(fn),
update: (mutator) => {
// Immer's produce (not setState's partial-merge path) so scalar and
// array roots replace correctly; produce also freezes in dev.
api.setState(produce(api.getState(), (draft) => { mutator(draft as T) }), true)
},
set: (next) => {
api.setState(devFreeze(next), true)
},
}
}
/**
* Whole-value JSON persistence to localStorage. Hand-rolled instead of the
* zustand persist middleware: its write path spreads state into an object
* (`partialize({ ...get() })`), exploding primitive state (a persisted string
* draft becomes {0:'h',1:'e',...}) — not fixable via merge/deserialize options
* because the corruption happens before serialization. Storage failures
* (quota, private mode) only disable persistence, never break the store.
*/
function attachPersistence<T>(api: StoreApi<T>, name: string): void {
// Non-browser runs (node e2e booting the client tree) have no localStorage:
// persistence silently disables — same contract as a storage failure, minus
// the per-store console noise a ReferenceError would produce.
if (typeof localStorage === 'undefined') return
try {
const raw = localStorage.getItem(name)
if (raw !== null) {
api.setState(devFreeze(JSON.parse(raw) as T), true)
}
} catch (error) {
console.error(`snapshot store '${name}' rehydration failed:`, error)
}
api.subscribe((state) => {
try {
localStorage.setItem(name, JSON.stringify(state))
} catch (error) {
console.error(`snapshot store '${name}' persistence failed:`, error)
}
})
}
/** Deep-freeze wholesale-set state outside production: set() bypasses immer's freeze. */
function devFreeze<T>(value: T): T {
if (process.env.NODE_ENV === 'production') return value
deepFreeze(value)
return value
}
function deepFreeze(value: unknown): void {
if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return
Object.freeze(value)
for (const key of Reflect.ownKeys(value)) {
deepFreeze((value as Record<PropertyKey, unknown>)[key])
}
}
// ---- defineStore shell (slot terminal design §4) ----
// The type authority is ui-slots' store family (create(scopeKey?) and
// clearPersisted() included); this module houses only the engine-backed
// implementation. The one engine-side widening left: instances expose the
// raw engine store for framework/test surfaces.
/** A live engine instance: the contract instance plus the raw engine store. */
export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {
/** The underlying engine store (framework/test surface; components never see it). */
readonly store: SnapshotStore<T>
}
/** The engine-backed handle: create() narrowed to the engine instance. */
export interface EngineStoreHandle<T, A extends ActionsDecl<T>> extends StoreHandle<T, A> {
/**
* Construct a live engine instance (see the contract JSDoc on
* {@link StoreHandle.create} for scopeKey/persist semantics).
*
* Known boundary: the persist key is the storage identity, so multiple live
* instances created under the same resolved key share (and cross-pollute)
* one localStorage entry. Instance uniqueness per key is the caller's
* responsibility — production is safe because the framework caches one
* instance per handle x scope key; tests wanting isolation use distinct
* scope keys or persist-free declarations (multi-create freedom is a
* feature there, so create() deliberately does not dedupe or throw).
* @param scopeKey - session id for session-scope instances; omitted for root scope.
* @returns the engine instance.
*/
create(scopeKey?: string): EngineStoreInstance<T, A>
}
/**
* Declare a store: initial state, optional persistence, and the full write
* set as pure draft mutators. The returned handle is the registration
* currency of the store seat — its identity keys instance sharing. Satisfies
* ui-slots' DefineStore contract (the handle/instance are the engine-extended
* subtypes).
*
* The `A & ActionsDecl<T>` actions position is load-bearing: T resolves from
* `init` in the first inference round, and the intersection then contextually
* types each mutator's draft parameter (context-sensitive functions defer),
* so call sites write `(d, x: X) => { ... }` with no draft annotation. If a
* future TS version breaks this single-literal inference, the design's
* documented fallback is currying (`defineStore(init).actions({...})`).
* @param decl - init lambda (fresh state per instance), optional persist key, actions table.
* @returns the store handle.
*/
export function defineStore<T, A extends ActionsDecl<T>>(
decl: StoreSpec<T, A> & { actions: A & ActionsDecl<T> }): EngineStoreHandle<T, A> {
return {
spec: decl,
create(scopeKey?: string): EngineStoreInstance<T, A> {
const persistKey = decl.persist === undefined
? undefined
: scopeKey === undefined ? decl.persist : `${decl.persist}.${scopeKey}`
const store = createSnapshotStore<T>(
decl.init(),
persistKey !== undefined ? { persist: { name: persistKey } } : undefined)
const actions = {} as Record<string, (...params: unknown[]) => void>
for (const key of Object.keys(decl.actions)) {
const mutate = decl.actions[key] as (draft: T, ...params: unknown[]) => void
actions[key] = (...params: unknown[]) => { store.update((draft) => { mutate(draft, ...params) }) }
}
return {
actions: actions as BakedActions<T, A>,
getSnapshot: () => store.getSnapshot(),
subscribe: fn => store.subscribe(fn),
store,
clearPersisted: () => {
if (persistKey === undefined || typeof localStorage === 'undefined') return
try {
localStorage.removeItem(persistKey)
} catch {
// Storage failures (private mode, quota teardown races) only skip
// cleanup — the same non-fatal contract as attachPersistence.
}
},
}
},
}
}

View File

@@ -1,30 +1,40 @@
/**
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
* SlotsService, SessionsService (list store + scope tree + object layer),
* the ClientLoader interface, and the cordis Context/Events merges. apply
* SlotsService (declaration ledger + renderer seam + store axis, built-in
* 'root'), SessionsService (list store + current selection + scope tree +
* object layer), the ClientLoader interface, and the cordis Context/Events
* merges. apply
* mounts ctx.slots + ctx.sessions and wires the connection stream loop into
* the object layer. The loader machinery implementation is NOT in the plugin
* bundle — it ships via the package's `./loader` subpath, statically held by
* the web shell (a loader cannot load itself).
*/
import type { Context } from 'cordis'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionBinding as GenericSessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore, UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from './contract/store.ts'
import { SlotsService } from './slots.ts'
import { SessionsService } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
export { SlotsService } from './slots.ts'
// RootOwnerProps rides the 'root' SlotMap row (both migrated here from
// ui-layout: the framework slot is declared by the framework package).
export type { RootOwnerProps } from './slots.ts'
export { SessionsService, scopeOf } from './sessions/service.ts'
export type { Session } from './sessions/session.ts'
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
export { SessionManager } from './sessions/manager.ts'
export type { SessionListSnapshot } from './sessions/manager.ts'
export { Session, PAGE_MESSAGES } from './sessions/session.ts'
export type { SessionListEntry } from './sessions/lineage.ts'
// The snapshot-store engine lives here since the store migration (the data
// layer owns its substrate; web-react is React glue only). The './client'
// main export is the single serving door — no store subpath.
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,
OpenState, PartialAssistant, PendingInteraction, PromptError, RunningToolCall, SteeringMessageNode,
PendingInteraction, RunningToolCall, SteeringMessageNode,
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
@@ -41,11 +51,8 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
*/
export type ClientContext = Context
/** SessionBinding narrowed to the client context (inject factories dot services directly). */
export type ClientSessionBinding = GenericSessionBinding<ClientContext>
/** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */
export type UseConversationSession = UseSession<ConversationSnapshot>
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
/**
* One tool call as the chat flow renders it: still-running (spinner card) or
@@ -54,6 +61,25 @@ export type UseConversationSession = UseSession<ConversationSnapshot>
*/
export type ToolCallBlock = RunningToolCall | ToolResultNode
declare module '@deepseek-ai/dsh-client-ui-slots' {
/**
* Session standard kit, real members (ui-slots declares the empty seat;
* the runtime — where the subjects live — merges the concrete types):
* every session-scope slot component receives these from the framework.
*/
interface SessionStandardProps {
/** Selector hook over this session's conversation snapshot. */
useSession: SnapshotSelectorHook<ConversationSnapshot>
/** The framework-resolved session id (owners never pass it). */
sessionId: SessionId
}
/** Global standard kit, real members: the session-list hook every slot component receives. */
interface GlobalStandardProps {
/** Selector hook over the session list snapshot (`current` included — the arbitrated selection seat). */
useSessions: SnapshotSelectorHook<SessionListState>
}
}
declare module 'cordis' {
interface Events {
/**

View File

@@ -17,7 +17,7 @@
* load one by one in inject topology.
*/
import type { Context } from 'cordis'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '../contract/store.ts'
import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'

View File

@@ -1,20 +1,24 @@
/**
* SessionsService: root sessions service — list snapshot store (manager
* projection), session scope tree (mintScope pattern: no-op plugin Fiber +
* ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
* projection; carries `current`, the persisted selection every
* session-scoped surface keys off — migrated here from ui-layout per the
* slot-parity design), session scope tree (mintScope pattern: no-op plugin
* Fiber + ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
*
* Scope lifecycle is watch-driven: a scope is minted lazily on first
* resolution; a session leaving the list tears its scope down only when
* nobody is watching it. "Watched" is approximated as the most recently
* resolved binding id — SessionProvider re-resolves on every selection
* change (keyed remount), so a switch away always re-evaluates the deferred
* teardown; a host-side death without list removal keeps the scope (frozen
* read-only view).
* Scope lifecycle is stage-driven: a scope is minted lazily on first
* resolution (pure — resolution has no side effects and is render-safe);
* the event window and deferred teardown key off the STAGED session, which
* follows `list.current` exactly. Staging is the open signal: the window
* opens ⟺ the session is on stage (today the stage is `current`; the staged
* state can widen to a multi-pane list later). A session leaving the list
* tears its scope down immediately unless it is the staged one, whose scope
* 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 { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
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 { Session } from './session.ts'
@@ -31,8 +35,12 @@ export interface SessionSummary {
updatedAt: number
}
/** Session list store shape. */
export interface SessionListState { ids: SessionId[]; byId: Record<SessionId, SessionSummary> }
/**
* Session list store shape. `current` rides the same snapshot (arbitrated:
* 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<SessionId, SessionSummary>; current: SessionId | undefined }
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
export interface SessionBinding {
@@ -73,19 +81,35 @@ interface ScopeRecord {
fiber: Fiber
ctx: Context
binding: SessionBinding
/** Render-layer standard kit (identity-stable per scope; the renderer's per-cell caches key off it). */
cell: SessionCell
}
/** Root sessions service: list store, object-layer manager, scope tree, bindings, ancestry. */
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
export class SessionsService {
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect). */
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
readonly list: SnapshotStore<SessionListState>
/** The object-layer instance cluster and frame dispatch entry (wired to the connection by the runtime apply). */
readonly manager: SessionManager
/**
* Persisted selection cell (the durable half of `list.current`). Private on
* purpose: reads go through the list snapshot; writes through {@link
* SessionsService.open}. Projection validates it against the live list
* instead of destructively pruning, so a selection survives transient list
* states (reconnect re-pull) and resurfaces when its session returns.
*/
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
private readonly scopes = new Map<SessionId, ScopeRecord>()
/** Most recently resolved binding id — the watch approximation for deferred teardown. */
/**
* The staged session id — follows `list.current` exactly, holding its last
* defined value across masked gaps (a transiently absent selection blanks
* `current` without moving the stage, so reconnect re-pulls and removals
* keep the staged scope's frozen view alive until the stage moves on).
*/
private watched: SessionId | undefined
/** Removed-while-watched sessions whose teardown waits for the watch to move away. */
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
private readonly deferredRemovals = new Set<SessionId>()
/**
@@ -94,13 +118,36 @@ export class SessionsService {
*/
constructor(private readonly rootCtx: Context, api: IApiClient) {
this.manager = new SessionManager(api)
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
{},
{ persist: { name: 'dsh.sessions.current' } })
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined })
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
this.manager.subscribe(() => { this.projectList() })
// Stage follower: every current write (open() and projection alike)
// re-evaluates staging, so startup restore (persisted selection validated
// by the projection) and reconnect resurfacing open their window with no
// dedicated code path. Safe to run synchronously inside the store notify:
// the follower writes no list state — session.open()'s synchronous prefix
// touches only session-side state and its own microtask-batched notifier.
this.list.subscribe(() => { this.followCurrent() })
rootCtx.reflect.provide('sessions', this, undefined)
}
/**
* Select a session as current. Unknown ids fail loud instead of navigating
* nowhere (the sole selection write path).
* @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 })
}
/**
* Create a session on the host.
* @param opts - creation options (project directory).
@@ -122,18 +169,50 @@ export class SessionsService {
}
/**
* Resolve the stable session binding (SessionProvider's resolveBinding feed).
* Resolve the stable session binding (scope-addressed assembly feed). Pure
* resolution — no staging, no window side effects.
* @param id - session id.
* @returns binding, or undefined for a session neither listed nor already scoped.
*/
binding(id: SessionId): SessionBinding | undefined {
const record = this.resolve(id)
if (record === undefined) return undefined
if (this.watched !== id) {
this.watched = id
this.sweepDeferred()
return this.resolve(id)?.binding
}
/**
* Resolve the render-layer session cell (SessionProvider's feed through
* the renderer host; ctx never enters the render layer). Pure resolution —
* render-safe: SessionProvider calls this during render, so no staging, no
* window side effects (StrictMode double-invokes and concurrent discarded
* passes must stay free).
* @param id - session id.
* @returns cell, or undefined for a session neither listed nor already scoped.
*/
cell(id: string): SessionCell | undefined {
return this.resolve(id as SessionId)?.cell
}
/**
* Move the stage to the list's current session: sweep teardowns deferred
* behind the previous occupant and pull the new occupant's history window.
* Staging IS the open signal — the window opens ⟺ the session is on stage
* — and open() is idempotent (an in-flight or completed open no-ops; a
* failed one retries the next time current is touched).
*/
private followCurrent(): void {
const current = this.list.getSnapshot().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
this.watched = current
this.sweepDeferred()
const record = this.resolve(current)
/* v8 ignore next 3 -- defensive: current is always a listed id (open()
* validates and the projection masks absent selections), so resolve
* cannot miss; kept so a future current writer cannot crash the notify. */
if (record !== undefined) {
void record.binding.session.open()
}
return record.binding
}
/**
@@ -162,10 +241,14 @@ export class SessionsService {
if (this.list.getSnapshot().byId[id] === undefined) return undefined
const fiber = this.rootCtx.plugin(sessionScope)
const ctx = fiber.ctx.extend({ [kScope]: id })
const session = this.manager.get(id)
const record: ScopeRecord = {
fiber,
ctx,
binding: { sessionId: id, session: this.manager.get(id), ctx },
binding: { sessionId: id, session, ctx },
// Bare source form (store migration): the Session object IS the
// observable; the React side binds the useSession hook per cell.
cell: { sessionId: id, session },
}
this.scopes.set(id, record)
return record
@@ -188,11 +271,15 @@ export class SessionsService {
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
}
}
this.list.set({ ids, byId })
// 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 })
this.pruneScopes(byId)
}
/** Tear down scopes for removed sessions nobody watches; the watched one defers until the watch moves. */
/** Tear down scopes for removed sessions off stage; the staged one defers until the stage moves. */
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
for (const [id, record] of this.scopes) {
if (byId[id] !== undefined) continue
@@ -202,15 +289,23 @@ export class SessionsService {
}
this.scopes.delete(id)
this.deferredRemovals.delete(id)
void record.fiber.dispose()
this.dropScope(id, record)
}
}
/** Run deferred teardowns whose session is no longer watched (called when the watch moves). */
/** Dispose a scope fiber and its session-keyed slot-store instances together (single lifecycle axis). */
private dropScope(id: SessionId, record: ScopeRecord): void {
void record.fiber.dispose()
// Optional lookup: slots and sessions are sibling services with no
// declared dependency; a slots-less boot (object-layer tests) skips.
this.rootCtx.get('slots')?.pruneStoreScope(id)
}
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
private sweepDeferred(): void {
for (const id of [...this.deferredRemovals]) {
/* v8 ignore next -- defensive: only the watched id ever defers, and every
* watch move sweeps first, so the set cannot contain the id the watch just
/* v8 ignore next -- defensive: only the staged id ever defers, and every
* stage move sweeps first, so the set cannot contain the id the stage just
* moved to; kept as a guard against future extra sweep call sites. */
if (id === this.watched) continue
// Still absent from the list? (A re-added id cancels the deferred teardown.)
@@ -225,7 +320,7 @@ export class SessionsService {
* future teardown path cannot double-dispose. */
if (record !== undefined) {
this.scopes.delete(id)
void record.fiber.dispose()
this.dropScope(id, record)
}
}
}

View File

@@ -7,8 +7,7 @@ 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 } from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { ObservableSnapshot } from '../contract/store.ts'
import type {
ConversationNode, ConversationSnapshot, OpenState, PendingInteraction, PromptError, RunningToolCall,
} from './conversation.ts'
@@ -19,11 +18,13 @@ import { PartialAccumulator } from './partial.ts'
/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */
export const PAGE_MESSAGES = 50
/** Per-session state owner: event window + fold + partial, snapshot out via uSES (see the web client architecture RFC). */
/**
* Per-session state owner: event window + fold + partial, snapshot out via
* subscribe/getSnapshot (see the web client architecture RFC). Bare source
* only (store migration): the React machinery binds the per-cell useSession
* hook at its own seam — no selector hook member lives on the data layer.
*/
export class Session implements ObservableSnapshot<ConversationSnapshot> {
/** Typed selector hook bound to this instance (the SessionBinding `useSession` source). */
readonly useSelector: SnapshotSelectorHook<ConversationSnapshot> = bindSnapshotSelector(this)
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
private events: SessionEvent[] = []
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).

View File

@@ -1,22 +1,84 @@
/**
* SlotsService: cordis Service wrapper over the pure SlotCore (ui-slots).
* Every mutation re-emits as the 'slots/changed' cordis event; define/register
* run through the caller's ctx.effect so a plugin's registrations are
* collected when its fiber unloads (cordis-native cascade).
* SlotsService: the cordis Service layer of the slot system over the pure
* SlotCore (ui-slots owns registration semantics, the declaration ledger,
* the load-time validations, and the unload cascade). This layer owns what
* needs the runtime: the 'slots/changed' event bridge, register through the
* caller's ctx.effect (fiber unload collects registrations), the renderer
* install seam (install()/renderSlot('root') + the SlotRendererHost face),
* and the store INSTANCE axis — handle x scope key -> create/cache, dropped
* with the last holding entry, session instances cleared (with persisted
* state) on scope death.
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in this compilation unit (intersection reads `never`) but consumers merge
* keys in; the rule fires on the empty-map view, not on real redundancy. */
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap only
* holds this package's 'root' row in this compilation unit, but consumers
* merge keys in; the rule fires on the narrow-map view, not on real
* redundancy. */
import { Service } from 'cordis'
import type { Context } from 'cordis'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposedProps, RegisterArgs, SlotComponent, SlotEntry, SlotEntryDef, SlotMap, SlotSpec } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from './index.ts'
import type {
OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
/** cordis Service wrapper over the pure SlotCore; mutations re-emit as 'slots/changed'. */
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** The built-in render-tree root hole (seeded by SlotCore): rendered only by the shell, occupied by a layout entry. */
'root': { kind: 'single'; scope: 'root'; owner: RootOwnerProps }
}
}
/** Root owner share: the shell supplies nothing — the frame is inject-assembled. */
export interface RootOwnerProps { children?: never }
/** Instance key for root-scoped store records (session records key by session id, so the literal cannot collide). */
const ROOT_INSTANCE_KEY = 'root'
// FIXME(slot-parity): the engine's arbitrated persist extensions — create()
// takes the scope key (per-session localStorage suffix) and instances expose
// clearPersisted() — are not yet on ui-slots' StoreHandle/StoreInstanceLike;
// these local structural faces bridge until fw-slots lifts them.
/** Store handle face as the engine actually ships it (scope-key-aware create). */
interface EngineStoreHandle { create(scopeKey?: string): EngineStoreInstance }
/** Engine instance face: the host-contract shape plus persisted-state cleanup. */
interface EngineStoreInstance extends StoreInstanceLike { clearPersisted(): void }
/** Store axis record: one per live handle, dropped when the last holding entry unloads. */
interface StoreAxisRecord {
/** Scope of the slot the handle mounted under (the core validated cross-scope conflicts). */
scope: SlotScope
/** Live registrations holding the handle. */
refs: number
/** Root scope: the single instance under {@link ROOT_INSTANCE_KEY}; session scope: one per session id. */
instances: Map<string, EngineStoreInstance>
}
/** Type-erased options view the implementation works with (the typed overloads proved the shares). */
interface ErasedRegisterOptions {
name: string
children?: Record<string, SlotSpec<SlotEntryDef>>
store?: StoreDecl
inject?: (...args: never[]) => Record<string, unknown>
key?: string
id?: string
order?: number
label?: string
registrant?: string
}
/** Erased core call face (the service re-erases at its own boundary; the core's typed face targets end callers). */
interface ErasedCore { register(options: object, component: unknown): () => void }
/** cordis Service layer of the slot system; see the module doc for the split with SlotCore. */
export class SlotsService extends Service {
private readonly _core = new SlotCore()
/** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */
private readonly _stores = new Map<EngineStoreHandle, StoreAxisRecord>()
private _renderer: SlotRenderer | undefined
private _host: SlotRendererHost | undefined
/**
* @param ctx - owning root context.
@@ -27,44 +89,92 @@ export class SlotsService extends Service {
}
/**
* Record a slot spec (delegates to SlotCore.define; disposal follows the caller's fiber).
* @param key - SlotMap key.
* @param spec - kind/scope spec.
* @returns disposer.
* The single registration API. The typed face IS the core's register
* (both overloads reused verbatim — one authority, no structural copy;
* see SlotCore.register for children declaration, store seat, inject
* face, load-time validation, and the unload cascade). This layer adds:
* disposal through the caller's ctx.effect (fiber unload = cascade),
* exclusive-factory minting (`store: createXxxStore` becomes a per-entry
* handle), the registrant diagnostics stamp, and store-instance lifecycle
* on the entry axis.
*
* Declared here, implemented by prototype assignment below the class: it
* MUST stay a prototype method (never an instance arrow) — the cordis
* service proxy binds `this.ctx` to the CALLER's context at call time,
* which is what routes the effect (and the unload cascade) into the
* caller's fiber. An arrow property would freeze `this` to the service's
* own root ctx and silently break per-plugin disposal.
*/
define<K extends keyof SlotMap & string>(key: K, spec: SlotSpec<SlotMap[K]>): () => void {
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(() => this._core.define(key, spec), 'slots.define()')
declare readonly register: SlotCore['register']
/**
* Install the shell's renderer (web-react's createSlotRenderer product).
* Boot-once: a second install throws. Runs through the caller's ctx.effect,
* so shell fiber unload uninstalls the renderer.
* @param renderer - the outlet machinery implementing SlotRenderer.
*/
install(renderer: SlotRenderer): void {
if (this._renderer !== undefined) throw new Error('slot renderer already installed (install() is boot-once)')
this.ctx.effect(() => {
this._renderer = renderer
return () => {
if (this._renderer === renderer) this._renderer = undefined
}
}, 'slots.install()')
}
/**
* Contribute a component (delegates to SlotCore.register; disposal follows the caller's fiber).
* @param key - SlotMap key.
* @param component - contributed component.
* @param args - kind-shaped options (mandatory for keyed/list kinds); the
* inject factory's binding is pinned to ClientContext.
* @returns disposer.
* The single ctx-level render entry: the shell renders 'root'; every other
* key renders inside components through the props renderSlot face. All
* three guards are fail-loud boot-order checks, no fallback.
* @param key - must be 'root' (runtime-enforced for dynamically composed callers).
* @param owner - owner share for the root entry (the shell supplies {}).
* @returns the rendered root tree.
*/
register<K extends keyof SlotMap & string, I extends object = Record<string, unknown>>(
// Client-context registrations have exactly one ctx shape: pin Ctx to
// ClientContext so inject factories dot services without a cast.
key: K, component: SlotComponent<ComposedProps<K, NoInfer<I>>>,
...args: RegisterArgs<SlotMap[K], I, ClientContext>): () => void {
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(() => this._core.register<K, I, ClientContext>(key, component, ...args), 'slots.register()')
renderSlot<K extends keyof SlotMap & string>(key: K, owner: OwnerOf<K>): ReturnType<SlotRenderer['renderRoot']> {
// Widened: in this package's own program SlotMap holds only 'root', which
// would fold the guard to constant-false; the check exists for plain-JS
// and cross-program callers where K is wider.
if ((key as string) !== 'root') {
throw new Error(`ctx-level renderSlot only renders 'root' (got "${key}"); child slots render through the component props face`)
}
if (this._renderer === undefined) {
throw new Error("slot renderer not installed — boot must call ctx.slots.install(createSlotRenderer()) before rendering 'root'")
}
if (this._core.entries('root').length === 0) {
throw new Error("'root' has no registration — a layout entry must register into 'root' before the shell renders it")
}
return this._renderer.renderRoot(this.hostFace(), owner)
}
/**
* Snapshot entries for a key.
* @param key - SlotMap key.
* @returns registered entries (stable reference between mutations).
* Drop the per-session store instances of a dead session (the sessions
* service calls this on scope teardown; root-scoped records are untouched).
* Persisted state goes with the session — a never-rendered dead session can
* still own keys from an earlier page load, so the instance is materialized
* transiently just to clear storage (no-op for unpersisted stores).
* @param sessionId - the torn-down session.
*/
entries<K extends keyof SlotMap & string>(key: K): readonly SlotEntry<SlotMap[K]>[] {
pruneStoreScope(sessionId: string): void {
for (const [handle, record] of this._stores) {
if (record.scope !== 'session') continue
const instance = record.instances.get(sessionId) ?? handle.create(sessionId)
instance.clearPersisted()
record.instances.delete(sessionId)
}
}
/**
* Snapshot entries for a key (render-erased view; stable reference between mutations).
* @param key - SlotMap key.
* @returns registered entries.
*/
entries(key: keyof SlotMap & string): readonly StoredEntry[] {
return this._core.entries(key)
}
/**
* Look up a defined spec.
* Look up a declared spec (register-declared or the built-in 'root').
* @param key - SlotMap key.
* @returns spec or undefined.
*/
@@ -72,15 +182,6 @@ export class SlotsService extends Service {
return this._core.spec(key)
}
/**
* Dynamic-key escape hatch for spec lookup (renderer-side string keys).
* @param key - candidate slot key.
* @returns wide-typed spec or undefined.
*/
specDynamic(key: string): SlotSpec<SlotEntryDef> | undefined {
return this._core.specDynamic(key)
}
/**
* Subscribe to a key's registration changes (microtask-batched).
* @param key - SlotMap key.
@@ -100,8 +201,114 @@ export class SlotsService extends Service {
return this._core.getVersion(key)
}
/** The wrapped pure core (web-react's scopedSlots outlet reads through this). */
get core(): SlotCore {
return this._core
/** Delegating registration path: factory minting + registrant stamp + core write + instance-axis bookkeeping. */
private _register(options: ErasedRegisterOptions, component: unknown): () => void {
// Exclusive stores pass the factory itself: minted here into a per-entry
// handle so the stored entry always carries a resolvable handle (the
// core's shared-handle scope pinning applies to it harmlessly).
const store = typeof options.store === 'function' ? options.store() : options.store
const registrant = options.registrant ?? (this.ctx.fiber as { name?: string } | undefined)?.name
const erased: ErasedRegisterOptions = {
...options,
...(store !== undefined ? { store } : {}),
...(registrant !== undefined ? { registrant } : {}),
}
// Core write first: all load-time validation (undeclared target,
// duplicate declaration, kind conflicts, cross-scope handle) throws
// there before this layer commits anything.
const dispose = (this._core as unknown as ErasedCore).register(erased, component)
if (store !== undefined) {
// Register succeeded, so the target's spec is on the ledger.
const scope = (this._core.specDynamic(options.name) as SlotSpec<never>).scope
this._acquire(store, scope)
}
let disposed = false
return () => {
if (disposed) return
disposed = true
dispose()
if (store !== undefined) this._release(store)
}
}
/** Build (once) the host face the installed renderer reads; sessions resolve lazily at first render. */
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")
}
// 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.
const current = {
getSnapshot: () => sessions.list.getSnapshot().current as string | undefined,
subscribe: (fn: () => void) => sessions.list.subscribe(fn),
}
this._host = {
subscribe: (key, fn) => this._core.subscribe(key, fn),
getVersion: key => this._core.getVersion(key),
entriesOf: key => this._core.entries(key),
specOf: key => this._core.specDynamic(key),
isLive: entry => this._core.isLive(entry),
storeOf: (entry, scopeKey) =>
entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
sessions: {
list: sessions.list,
current,
cell: id => sessions.cell(id),
},
}
return this._host
}
/** Resolve (create or reuse) the store instance for a registered handle under a scope key. */
private resolveStore(handle: EngineStoreHandle, sessionId: string | undefined): StoreInstanceLike {
const record = this._stores.get(handle)
if (record === undefined) throw new Error('store handle is not registered (entry unloaded, or the handle never went through register)')
const key = record.scope === 'session' ? sessionId : ROOT_INSTANCE_KEY
if (key === undefined) throw new Error('session-scoped store resolution requires a session id')
let instance = record.instances.get(key)
if (instance === undefined) {
// Session instances get the scope key (the engine suffixes the persist
// key per session); root instances stay keyless.
instance = record.scope === 'session' ? handle.create(key) : handle.create()
record.instances.set(key, instance)
}
return instance
}
/** Bind (or re-reference) a handle on the axis; cross-scope conflicts already threw in the core. */
private _acquire(handle: EngineStoreHandle, scope: SlotScope): void {
const record = this._stores.get(handle)
if (record === undefined) {
this._stores.set(handle, { scope, refs: 1, instances: new Map() })
return
}
record.refs += 1
}
/** Drop one reference; the last holder's unload drops the record (instances go with it — engine stores need no explicit dispose). */
private _release(handle: EngineStoreHandle): void {
const record = this._stores.get(handle)
/* v8 ignore next -- defensive: release only runs from a disposer whose
* register acquired the same handle, so the record must exist; kept so a
* future call site cannot underflow the axis. */
if (record === undefined) return
record.refs -= 1
if (record.refs === 0) this._stores.delete(handle)
}
}
// register's implementation (prototype assignment pairs with the `declare`
// inside the class — see its JSDoc for why it must live on the prototype).
// Element access reaches the private _register legally and keeps it a
// TS-visible read.
;(SlotsService.prototype as { register: (options: object, component: unknown) => () => void }).register
= function register(this: SlotsService, rawOptions: object, component: unknown): () => void {
// The core's overloads proved the shares; the implementation works on
// the erased view (same pattern as the core's own implementation arm).
const options = rawOptions as ErasedRegisterOptions
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(() => this['_register'](options, component), 'slots.register()')
}

View File

@@ -37,6 +37,9 @@ describe('runtime client apply', () => {
it('mounts ctx.slots + ctx.sessions and wires the stream sinks into the manager', 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')
expect(sessions !== undefined).toBe(true)
expect(bench.sinks).toBeDefined()

View File

@@ -1,77 +0,0 @@
/**
* Real-bundle smoke: the actual tsdown client bundle of ui-layout runs
* through the loader chain (execute → handoff → factory(require) → apply →
* export re-registration). Skips when the bundle is not built (lib/client.js is a
* build product; `pnpm --filter @deepseek-ai/dsh-client-ui-layout build`).
*/
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import * as uiSlots from '@deepseek-ai/dsh-client-ui-slots'
import * as webReact from '@deepseek-ai/dsh-client-web-react'
import { createClientLoader } from '../src/client/loader/index.ts'
import type { ClientPluginHandoff } from '../src/client/loader/index.ts'
import { SessionsService } from '../src/client/sessions/service.ts'
import { SlotsService } from '../src/client/slots.ts'
import { FakeApiClient } from './fake-api.ts'
const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; window?: unknown }
afterEach(() => {
delete (globalThis as Win).DSHClientProxy
delete (globalThis as Win).window
})
function readLayoutBundle(): string | undefined {
try {
const require = createRequire(import.meta.url)
return readFileSync(require.resolve(`${LAYOUT_ID}/client`), 'utf8')
} catch {
return undefined
}
}
describe('real tsdown bundle through the loader', () => {
const code = readLayoutBundle()
it.skipIf(code === undefined)('loads ui-layout lib/client.js: handoff, DI require, apply, export surface', async () => {
// The bundle banner addresses window.DSHClientProxy; node has no window —
// alias it to globalThis so the loader-installed proxy is reachable.
;(globalThis as Win).window = globalThis
const ctx = new Context()
// The layout apply consumes the slots + sessions services; the real chain
// loads the runtime bundle first — stand both up directly here.
ctx.plugin(SlotsService)
await ctx.fiber.await()
new SessionsService(ctx, new FakeApiClient())
const loader = createClientLoader({
ctx,
// The real bundle externals resolved from the seeded table. React is a
// type-only import in the layout bundle today, but jsx-runtime is real.
modules: {
'react': await import('react'),
'react/jsx-runtime': await import('react/jsx-runtime'),
'@deepseek-ai/dsh-client-ui-slots': uiSlots,
'@deepseek-ai/dsh-client-web-react': webReact,
},
boot: { plugins: [{ id: LAYOUT_ID, url: `/plugins/${LAYOUT_ID}/client.js`, inject: [] }] },
fetchBundle: () => Promise.resolve(code as string),
// node has no DOM: evaluate the bundle body directly (same synchronous
// handoff contract as the <script> path).
executeBundle: (bundleCode) => {
// Node has no <script>: Function-evaluating the built bundle IS the
// system under test (same synchronous handoff as the browser path).
// eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call
new Function(bundleCode)()
},
})
loader.start()
await loader.settled()
expect(loader.status.getSnapshot()[LAYOUT_ID]).toBe('active')
const surface = loader.requireModule(LAYOUT_ID) as Record<string, unknown>
expect(typeof surface.apply).toBe('function')
})
})

View File

@@ -25,9 +25,11 @@ describe('runtime slots/changed invariant', () => {
const ctx = await setup()
expect(() => { emit(ctx, 'unrelated/event', 'x') }).not.toThrow()
await ctx.plugin(SlotsService).await() // fiber must reach ACTIVE — the audit reads strict ctx.get
// A real define bumps the version first and re-emits through onMutate —
// the audit sees version > 0 and stays quiet.
expect(() => ctx.slots.define('t-single', { kind: 'single', scope: 'root' })).not.toThrow()
// A real registration bumps the version first and re-emits through
// onMutate — the audit sees version > 0 and stays quiet. (Erased call:
// the typed register face rides the wave-1 ui-slots types.)
const slots = ctx.slots as unknown as { register(options: object, component: unknown): () => void }
expect(() => slots.register({ name: 'root' }, () => null)).not.toThrow()
})
it('fails loud on a missing key and on an emission with no applied mutation', async () => {

View File

@@ -1,10 +1,13 @@
/**
* SessionsService: list store projection (manager → {ids, byId} with derived
* titles), scope-tree lifecycle (lazy mint / frozen survival / removed
* teardown with watch deferral), binding identity, ancestry walk, create.
* SessionsService: list store projection (manager → {ids, byId, current}
* with derived titles), the migrated current-selection account (open
* validation, persisted mask semantics, cell resolution), scope-tree
* lifecycle (lazy mint / frozen survival / removed teardown with staged
* deferral — the stage follows list.current), binding identity, ancestry
* walk, create.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
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 { FakeApiClient, ok } from './fake-api.ts'
@@ -79,21 +82,21 @@ describe('scope tree', () => {
expect(binding?.ctx).toBe(scoped)
})
it('tears down an unwatched removed session but defers the watched one until the watch moves', async () => {
it('tears down an off-stage removed session but defers the staged one until the stage moves', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
const ctx1 = b.svc.scope(sid('s1'))
b.svc.binding(sid('s1')) // s1 is watched
b.svc.scope(sid('s2')) // s2 scoped but not watched
b.svc.open(sid('s1')) // s1 staged (current)
b.svc.scope(sid('s2')) // s2 scoped but off stage
await feedList(b, [{ id: 's1' }]) // s2 removed, unwatched: torn down
await feedList(b, [{ id: 's1' }]) // s2 removed, off stage: torn down
expect(b.svc.scope(sid('s2'))).toBeUndefined()
await feedList(b, []) // s1 removed while watched: deferred, scope survives
await feedList(b, []) // s1 removed while staged (current masks): deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBe(ctx1)
await feedList(b, [{ id: 's3' }])
b.svc.binding(sid('s3')) // watch moves: deferred teardown sweeps s1
b.svc.open(sid('s3')) // stage moves: deferred teardown sweeps s1
expect(b.svc.scope(sid('s1'))).toBeUndefined()
})
@@ -109,14 +112,143 @@ describe('scope tree', () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const scoped = b.svc.scope(sid('s1'))
b.svc.binding(sid('s1'))
await feedList(b, []) // removed while watched → deferred
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears
b.svc.binding(sid('s2')) // watch moves; sweep must NOT tear down the re-listed s1
b.svc.open(sid('s1'))
await feedList(b, []) // removed while staged → deferred
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears (current resurfaces, stage unchanged)
b.svc.open(sid('s2')) // stage moves; sweep must NOT tear down the re-listed s1
expect(b.svc.scope(sid('s1'))).toBe(scoped)
})
})
describe('current selection (migrated from ui-layout, arbitrated into the list snapshot)', () => {
afterEach(() => { vi.unstubAllGlobals() })
it('open() writes list.current; unknown ids fail loud', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
expect(b.svc.list.getSnapshot().current).toBeUndefined()
b.svc.open(sid('s1'))
expect(b.svc.list.getSnapshot().current).toBe('s1')
expect(() => { b.svc.open(sid('ghost')) }).toThrow(/unknown session ghost/)
expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone
})
it('masks (not destroys) the selection while its session is off the list', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.open(sid('s1'))
await feedList(b, [{ id: 's2' }]) // s1 removed → current falls to the empty state
expect(b.svc.list.getSnapshot().current).toBeUndefined()
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // s1 returns → selection resurfaces
expect(b.svc.list.getSnapshot().current).toBe('s1')
})
it('persists the selection under dsh.sessions.current and rehydrates it into a fresh service', async () => {
const storage = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (k: string) => storage.get(k) ?? null,
setItem: (k: string, v: string) => { storage.set(k, v) },
})
const first = bench()
await feedList(first, [{ id: 's1' }])
first.svc.open(sid('s1'))
expect(storage.get('dsh.sessions.current')).toContain('s1')
// A fresh boot (same storage) recovers the selection once the list holds the session.
const second = bench()
await feedList(second, [{ id: 's1' }])
expect(second.svc.list.getSnapshot().current).toBe('s1')
})
})
describe('cell (render-layer session kit)', () => {
it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const cell = b.svc.cell('s1')
expect(cell).toBeDefined()
expect(cell?.sessionId).toBe('s1')
// Bare-source form (store migration): the cell carries the Session
// observable itself; hook binding happens in the React machinery.
expect(cell?.session).toBe(b.svc.manager.get(sid('s1')))
expect(b.svc.cell('s1')).toBe(cell)
expect(b.svc.cell('ghost')).toBeUndefined()
})
it('cell()/binding() are pure resolution: no staging, no deferred sweep', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.open(sid('s1')) // staged
b.svc.cell('s2') // resolution only — must NOT move the stage
b.svc.binding(sid('s2'))
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBeDefined()
})
it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
// Resolution is addressing, not staging: no window pull.
b.svc.scope(sid('s1'))
b.svc.cell('s1')
b.svc.binding(sid('s1'))
expect(historyCalls()).toHaveLength(0)
b.svc.open(sid('s1'))
expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
// Same current again: no second pull.
b.svc.open(sid('s1'))
expect(historyCalls()).toHaveLength(1)
// Stage moves: the new occupant opens.
b.svc.open(sid('s2'))
expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1', 's2'])
})
it('startup restore: a persisted selection validated by the first projection opens its window unprompted', async () => {
const storage = new Map<string, string>([
['dsh.sessions.current', JSON.stringify({ sessionId: 's1' })],
])
vi.stubGlobal('localStorage', {
getItem: (k: string) => storage.get(k) ?? null,
setItem: (k: string, v: string) => { storage.set(k, v) },
})
try {
const b = bench()
expect(b.api.calls.filter(c => c.method === 'session.history')).toHaveLength(0)
await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows
const historyCalls = b.api.calls.filter(c => c.method === 'session.history')
expect(historyCalls.map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
} finally {
vi.unstubAllGlobals()
}
})
})
describe('slot-store scope prune hook', () => {
it('notifies ctx.slots.pruneStoreScope when a scope dies (both teardown paths)', async () => {
const b = bench()
const pruneStoreScope = vi.fn()
b.ctx.reflect.provide('slots', { pruneStoreScope })
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.scope(sid('s1'))
b.svc.scope(sid('s2'))
b.svc.open(sid('s2')) // s2 staged
await feedList(b, []) // s1 off stage → immediate drop; s2 staged → deferred
expect(pruneStoreScope).toHaveBeenCalledWith('s1')
expect(pruneStoreScope).not.toHaveBeenCalledWith('s2')
await feedList(b, [{ id: 's3' }])
b.svc.open(sid('s3')) // stage moves → deferred sweep drops s2
expect(pruneStoreScope).toHaveBeenCalledWith('s2')
})
it('tolerates a slots-less boot (object-layer benches carry no slot service)', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.scope(sid('s1'))
await feedList(b, []) // teardown without ctx.slots must not throw
expect(b.svc.scope(sid('s1'))).toBeUndefined()
})
})
describe('ancestry', () => {
it('walks parentId links root-first including self; broken links stop the walk', async () => {
const b = bench()
@@ -155,44 +287,46 @@ describe('coverage tails (branch duals)', () => {
expect(byId[sid('no-base')]?.title).toBeUndefined()
})
it('binding for an unknown session returns undefined without moving the watch', async () => {
it('binding for an unknown session returns undefined and leaves the staged scope intact', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.binding(sid('s1'))
b.svc.open(sid('s1'))
expect(b.svc.binding(sid('ghost'))).toBeUndefined()
// Watch unchanged: removing s1 defers (still watched), proving the ghost lookup did not steal the watch.
// Stage unchanged: removing s1 defers (still staged), proving the ghost lookup touched nothing.
await feedList(b, [])
expect(b.svc.scope(sid('s1'))).toBeDefined()
})
it('sweep skips the id that is itself still watched and tolerates a scope record already gone', async () => {
it('a masked current gap holds the stage (no teardown, no re-open) until the stage moves', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.binding(sid('s1'))
await feedList(b, []) // deferred removal of the watched id
// Re-resolving the SAME watched id: sweep runs but must skip it (watched-continue branch).
expect(b.svc.binding(sid('s1'))).toBeDefined()
b.svc.open(sid('s1'))
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
expect(historyCalls()).toHaveLength(1)
await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred
expect(b.svc.scope(sid('s1'))).toBeDefined()
// Resurfacing re-projects current = s1: same stage occupant, no second pull.
await feedList(b, [{ id: 's1' }])
expect(historyCalls()).toHaveLength(1)
expect(b.svc.list.getSnapshot().current).toBe('s1')
})
it('sweep hits both deferral edges: watched-id skip and an already-vacated scope record', async () => {
it('sweep hits both deferral edges: staged-id skip and an already-vacated scope record', async () => {
const b = bench()
await feedList(b, [{ id: 'a' }, { id: 'b' }])
b.svc.binding(sid('a'))
b.svc.binding(sid('b')) // watch: b; both scoped
await feedList(b, []) // a removed unwatched → torn immediately; b removed watched → deferred
// Move the watch to a THIRD id while b stays deferred: sweep now walks a
// set containing b (torn) — and the watched-continue branch fires when the
// deferral set still holds the current watch target.
b.svc.scope(sid('a'))
b.svc.open(sid('b')) // stage: b; both scoped
await feedList(b, []) // a removed off stage → torn immediately; b removed staged → deferred
// Move the stage to a THIRD id while b stays deferred: sweep walks a set
// containing b (torn).
await feedList(b, [{ id: 'c' }])
b.svc.binding(sid('c'))
b.svc.open(sid('c'))
expect(b.svc.scope(sid('b'))).toBeUndefined()
// Deferral for an id whose record was never minted: force-add via removed
// list state (scope teardown raced) — sweep must tolerate the missing record.
await feedList(b, [])
b.svc.binding(sid('c')) // c now watched+removed → deferred
// Deferral for an id whose record was never minted: force the deferral
// via removed list state — sweep must tolerate the missing record.
await feedList(b, []) // c removed while staged → deferred (scope exists)
await feedList(b, [{ id: 'd' }])
b.svc.binding(sid('d')) // sweep tears c
b.svc.open(sid('d')) // sweep tears c
expect(b.svc.scope(sid('c'))).toBeUndefined()
})

View File

@@ -1,80 +1,385 @@
/**
* SlotsService: cordis Service wrapper semantics — core delegation, the
* 'slots/changed' event bridge, and fiber-scoped registration disposal.
* SlotsService terminal-design account (design.md §11-3 main landing):
* built-in 'root', the three load-time throws (duplicate declaration /
* undeclared contribution / cross-scope store handle), the renderer install
* seam (double install / not installed / non-root key), store instance
* resolution and lifecycle on the ledger axis, and the entry-unload cascade.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from '../src/client/slots.ts'
// Test-only slot keys (SlotMap is empty in this package; the service is generic over it).
// Test-only slot keys (merged so the typed entries/spec faces accept them).
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
't-single': { kind: 'single'; scope: 'root'; props: object }
't-list': { kind: 'list'; scope: 'root'; props: object }
't.host': { kind: 'single'; scope: 'root' }
't.panel': { kind: 'single'; scope: 'session' }
't.rows': { kind: 'list'; scope: 'root' }
}
}
const C: FC<object> = () => null
async function boot(): Promise<Context> {
/**
* Register/install/renderSlot through a type-erased view: the typed register
* face rides wave-1 ui-slots types (red until that wave lands); the runtime
* semantics under test are final.
*/
interface ErasedService {
register(options: object, component: unknown): () => void
install(renderer: object): void
renderSlot(key: string, owner: object): unknown
}
interface Bench {
ctx: Context
svc: SlotsService
erased: ErasedService
}
async function boot(): Promise<Bench> {
const ctx = new Context()
ctx.plugin(SlotsService)
await ctx.fiber.await()
return ctx
// Service accessor (ctx.get reads the reflect store, which Service-class
// plugins do not write; the accessor is the product path).
const svc = ctx.slots
return { ctx, svc, erased: svc as unknown as ErasedService }
}
describe('SlotsService', () => {
it('proxies define/register/entries/spec/getVersion to the core', async () => {
const ctx = await boot()
ctx.slots.define('t-single', { kind: 'single', scope: 'root' })
expect(ctx.slots.spec('t-single')).toEqual({ kind: 'single', scope: 'root' })
const v0 = ctx.slots.getVersion('t-single')
ctx.slots.register('t-single', C)
expect(ctx.slots.entries('t-single')).toHaveLength(1)
expect(ctx.slots.getVersion('t-single')).toBeGreaterThan(v0)
expect(ctx.slots.core.spec('t-single')).toBeDefined()
/** Engine-shaped instance stub (bare-source form: subscribe/getSnapshot + baked actions + clearPersisted). */
interface FakeInstance {
getSnapshot: () => undefined
subscribe: () => () => void
actions: Record<string, never>
clearPersisted: ReturnType<typeof vi.fn>
}
/** Fake store handle factory (create-count and clearPersisted observable). */
function fakeHandle() {
const created: FakeInstance[] = []
const handle = {
create: vi.fn((_scopeKey?: string): FakeInstance => {
const instance: FakeInstance = {
getSnapshot: () => undefined, subscribe: () => () => undefined,
actions: {}, clearPersisted: vi.fn(),
}
created.push(instance)
return instance
}),
}
return { handle, created }
}
/**
* Install a capturing renderer, occupy 'root' (declaring `children` in the
* same call — 'root' is single, so the one occupant is also the declarer),
* and pull the host face out through renderSlot('root').
*/
function captureHost(bench: Bench, children?: object): SlotRendererHost {
let host: SlotRendererHost | undefined
bench.erased.install({
renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
})
bench.erased.register({ name: 'root', ...(children !== undefined ? { children } : {}) }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.erased.renderSlot('root', {})
if (host === undefined) throw new Error('renderer never received the host')
return host
}
/** Minimal sessions face for the host seam (list observable + cell). */
function fakeSessions() {
const state = { ids: [], byId: {}, current: undefined as string | undefined }
return {
list: { getSnapshot: () => state, subscribe: () => () => undefined },
cell: (id: string) => (id === 'known'
? { sessionId: id, session: { getSnapshot: () => undefined, subscribe: () => () => undefined } }
: undefined),
}
}
describe("built-in 'root'", () => {
it('is declared at construction: spec readable, occupancy open, no plugin needed', async () => {
const bench = await boot()
expect(bench.svc.spec('root')).toEqual({ kind: 'single', scope: 'root' })
expect(() => bench.erased.register({ name: 'root' }, C)).not.toThrow()
expect(bench.svc.entries('root')).toHaveLength(1)
})
it("re-emits every mutation as 'slots/changed' with the key", async () => {
const ctx = await boot()
const seen: string[] = []
ctx.on('slots/changed', (key) => { seen.push(key) })
ctx.slots.define('t-list', { kind: 'list', scope: 'root' })
ctx.slots.register('t-list', C, { id: 'a' })
expect(seen).toEqual(['t-list', 't-list'])
it('rejects a second declaration of root, attributing the built-in row', async () => {
const bench = await boot()
expect(() => bench.erased.register({
name: 'root', children: { 'root': { kind: 'single', scope: 'root' } },
}, C)).toThrow(/already declared.*built-in/)
})
})
describe('load-time validation', () => {
it('throws on contributing into an undeclared slot', async () => {
const bench = await boot()
expect(() => bench.erased.register({ name: 't.host' }, C)).toThrow(/slot "t.host" is not declared/)
})
it('collects a plugin fiber\'s registrations when the fiber unloads (cascade)', async () => {
const ctx = await boot()
ctx.slots.define('t-single', { kind: 'single', scope: 'root' })
const fiber = ctx.plugin({
it('throws on a duplicate declaration, naming the slot and the prior declarant', async () => {
const bench = await boot()
bench.erased.register({ name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } } }, C)
bench.erased.register({
name: 't.host', children: { 't.rows': { kind: 'list', scope: 'root' } },
}, C)
expect(() => bench.erased.register({
name: 't.rows', id: 'r1', children: { 't.rows': { kind: 'list', scope: 'root' } },
}, C)).toThrow(/slot "t.rows" is already declared.*"t.host"/)
})
it('throws when one store handle is bound to two scopes', async () => {
const bench = await boot()
bench.erased.register({
name: 'root',
children: {
't.host': { kind: 'single', scope: 'root' },
't.panel': { kind: 'single', scope: 'session' },
},
}, C)
const { handle } = fakeHandle()
bench.erased.register({ name: 't.host', store: handle }, C)
expect(() => bench.erased.register({ name: 't.panel', store: handle }, C))
.toThrow(/one handle, one scope/)
})
it('commits nothing when the core rejects the entry (children stay undeclared)', async () => {
const bench = await boot()
bench.erased.register({ name: 'root' }, C) // 'root' single slot now occupied
expect(() => bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)).toThrow(/already has a registration/)
// The failing call's declaration must not have landed.
expect(() => bench.erased.register({ name: 't.host' }, C)).toThrow(/is not declared/)
})
})
describe('renderer install seam', () => {
it('throws on renderSlot before install (boot-order guidance)', async () => {
const bench = await boot()
expect(() => bench.erased.renderSlot('root', {})).toThrow(/renderer not installed/)
})
it('throws on double install', async () => {
const bench = await boot()
bench.erased.install({ renderRoot: () => null })
expect(() => { bench.erased.install({ renderRoot: () => null }) }).toThrow(/already installed/)
})
it('throws on any non-root key (single ctx-level entry)', async () => {
const bench = await boot()
bench.erased.install({ renderRoot: () => null })
expect(() => bench.erased.renderSlot('t.host', {})).toThrow(/only renders 'root'/)
})
it("throws on renderSlot('root') before any root registration", async () => {
const bench = await boot()
bench.erased.install({ renderRoot: () => null })
expect(() => bench.erased.renderSlot('root', {})).toThrow(/no registration/)
})
it('renders through the installed renderer and returns its product', async () => {
const bench = await boot()
const renderRoot = vi.fn(() => 'tree')
bench.erased.install({ renderRoot })
bench.erased.register({ name: 'root' }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
expect(bench.erased.renderSlot('root', {})).toBe('tree')
expect(renderRoot).toHaveBeenCalledTimes(1)
})
})
describe('host face', () => {
it('serves entriesOf/specOf/isLive off the ledger and flips isLive on disposal', async () => {
const bench = await boot()
const host = captureHost(bench, { 't.host': { kind: 'single', scope: 'root' } })
const dispose = bench.erased.register({ name: 't.host' }, C)
const rootEntry = host.entriesOf('root')[0]
expect(rootEntry).toBeDefined()
expect(rootEntry?.component).toBe(C)
expect(host.specOf('root')).toEqual({ kind: 'single', scope: 'root' })
expect(host.specOf('t.host')).toEqual({ kind: 'single', scope: 'root' })
const childEntry = host.entriesOf('t.host')[0]
expect(host.isLive(childEntry as never)).toBe(true)
dispose()
expect(host.isLive(childEntry as never)).toBe(false)
expect(host.entriesOf('t.host')).toHaveLength(0)
})
it('exposes sessions list/current/cell (current riding the list snapshot)', async () => {
const bench = await boot()
const host = captureHost(bench)
expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
expect(host.sessions.current.getSnapshot()).toBeUndefined()
expect(host.sessions.cell('known')).toMatchObject({ sessionId: 'known' })
expect(host.sessions.cell('ghost')).toBeUndefined()
})
})
describe('store instance axis', () => {
/** Boot with 'root' occupied and the three test children declared. */
async function storeBench() {
const bench = await boot()
const host = captureHost(bench, {
't.host': { kind: 'single', scope: 'root' },
't.rows': { kind: 'list', scope: 'root' },
't.panel': { kind: 'single', scope: 'session' },
})
return { bench, host }
}
it('resolves one instance per (handle x root scope) shared across entries', async () => {
const { bench, host } = await storeBench()
const { handle } = fakeHandle()
bench.erased.register({ name: 't.host', store: handle }, C)
bench.erased.register({ name: 't.rows', id: 'a', store: handle }, C)
const [hostEntry] = host.entriesOf('t.host')
const [rowEntry] = host.entriesOf('t.rows')
const a = host.storeOf(hostEntry as never, undefined)
const b = host.storeOf(rowEntry as never, undefined)
expect(a).toBeDefined()
expect(a).toBe(b) // shared handle, same scope key = same instance
expect(handle.create).toHaveBeenCalledTimes(1)
expect(handle.create).toHaveBeenCalledWith() // root scope: keyless create
})
it('resolves per-session instances keyed by session id, created with the scope key', async () => {
const { bench, host } = await storeBench()
const { handle } = fakeHandle()
bench.erased.register({ name: 't.panel', store: handle }, C)
const [entry] = host.entriesOf('t.panel')
const s1 = host.storeOf(entry as never, 's1')
const s2 = host.storeOf(entry as never, 's2')
expect(s1).not.toBe(s2)
expect(host.storeOf(entry as never, 's1')).toBe(s1) // cached per key
expect(handle.create).toHaveBeenCalledWith('s1')
expect(handle.create).toHaveBeenCalledWith('s2')
expect(() => host.storeOf(entry as never, undefined)).toThrow(/requires a session id/)
})
it('mints a fresh handle per register for the factory (exclusive) form', async () => {
const { bench, host } = await storeBench()
const factory = vi.fn(() => fakeHandle().handle)
bench.erased.register({ name: 't.host', store: factory }, C)
bench.erased.register({ name: 't.rows', id: 'a', store: factory }, C)
expect(factory).toHaveBeenCalledTimes(2)
const a = host.storeOf(host.entriesOf('t.host')[0] as never, undefined)
const b = host.storeOf(host.entriesOf('t.rows')[0] as never, undefined)
expect(a).not.toBe(b) // two mints, two instances
})
it('drops instances with the last holding entry and refuses stale resolution', async () => {
const { bench, host } = await storeBench()
const { handle } = fakeHandle()
const d1 = bench.erased.register({ name: 't.host', store: handle }, C)
bench.erased.register({ name: 't.rows', id: 'a', store: handle }, C)
const rowEntry = host.entriesOf('t.rows')[0]
const hostEntry = host.entriesOf('t.host')[0]
const shared = host.storeOf(rowEntry as never, undefined)
d1() // one holder left: record (and instance) survive
expect(host.storeOf(rowEntry as never, undefined)).toBe(shared)
expect(() => host.storeOf(hostEntry as never, undefined)).not.toThrow() // handle still live via the row entry
// Note: dropping the row entry would sever the last reference; stale
// resolution is covered through the cascade spec below.
})
it('pruneStoreScope clears persisted state per dead session, including never-materialized ones', async () => {
const { bench, host } = await storeBench()
const { handle, created } = fakeHandle()
bench.erased.register({ name: 't.panel', store: handle }, C)
const [entry] = host.entriesOf('t.panel')
const s1 = host.storeOf(entry as never, 's1')
expect(s1).toBe(created[0]) // the resolved instance is the fake the handle minted
bench.svc.pruneStoreScope('s1')
expect(created[0]?.clearPersisted).toHaveBeenCalledTimes(1)
expect(host.storeOf(entry as never, 's1')).not.toBe(s1) // instance dropped, next resolve mints anew
// Never-rendered dead session: a transient instance is created just to clear storage.
const before = created.length
bench.svc.pruneStoreScope('s-never')
expect(created.length).toBe(before + 1)
expect(created[created.length - 1]?.clearPersisted).toHaveBeenCalledTimes(1)
})
})
describe('entry-unload cascade', () => {
it('kills declared children, their contributions, and the ledger rows with the entry', async () => {
const bench = await boot()
let host: SlotRendererHost | undefined
bench.erased.install({
renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
})
bench.ctx.reflect.provide('sessions', fakeSessions())
// 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)
bench.erased.renderSlot('root', {})
if (host === undefined) throw new Error('renderer never received the host')
disposeRoot()
const disposeDeclarer = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
bench.erased.register({ name: 't.host' }, C)
const [childEntry] = host.entriesOf('t.host')
expect(childEntry).toBeDefined()
disposeDeclarer()
expect(bench.svc.spec('t.host')).toBeUndefined() // ledger row gone
expect(host.specOf('t.host')).toBeUndefined() // outlets now render empty
expect(bench.svc.entries('t.host')).toHaveLength(0) // contribution cleared
expect(host.isLive(childEntry as never)).toBe(false) // stale bindings will throw upstream
// The freed key is re-declarable by a new entry (no residue).
expect(() => bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)).not.toThrow()
})
it('cascades through cordis fiber disposal (plugin unload = full cleanup)', async () => {
const bench = await boot()
bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
const fiber = bench.ctx.plugin({
name: 'occupant',
inject: ['slots'],
apply: (pluginCtx: Context) => {
pluginCtx.slots.register('t-single', C)
;(pluginCtx.slots as unknown as ErasedService).register({ name: 't.host' }, C)
},
})
await fiber.await()
expect(ctx.slots.entries('t-single')).toHaveLength(1)
expect(bench.svc.entries('t.host')).toHaveLength(1)
await fiber.dispose()
expect(ctx.slots.entries('t-single')).toHaveLength(0)
// The slot definition (registered from root) survives; a new occupant may register.
expect(() => ctx.slots.register('t-single', C)).not.toThrow()
expect(bench.svc.entries('t.host')).toHaveLength(0)
expect(bench.svc.spec('t.host')).toBeDefined() // declarer still live; slot stays declared
})
it('proxies specDynamic/subscribe/getVersion through the core', async () => {
const ctx = await boot()
ctx.slots.define('t-list', { kind: 'list', scope: 'root' })
expect(ctx.slots.specDynamic('t-list')).toEqual({ kind: 'list', scope: 'root' })
expect(ctx.slots.specDynamic('never-defined')).toBeUndefined()
let notified = 0
const unsubscribe = ctx.slots.subscribe('t-list', () => { notified += 1 })
ctx.slots.register('t-list', C, { id: 'row' })
await new Promise(resolve => setTimeout(resolve, 0)) // microtask-batched flush
expect(notified).toBeGreaterThan(0)
expect(ctx.slots.getVersion('t-list')).toBeGreaterThan(0)
unsubscribe()
it('disposer is idempotent (stale second call is a no-op)', async () => {
const bench = await boot()
const dispose = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
dispose()
expect(() => { dispose() }).not.toThrow()
expect(() => bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)).not.toThrow()
})
})
describe('event bridge', () => {
it("re-emits entry writes and child declarations as 'slots/changed'", async () => {
const bench = await boot()
const seen: string[] = []
bench.ctx.on('slots/changed', (key) => { seen.push(key) })
bench.erased.register({
name: 'root', children: { 't.rows': { kind: 'list', scope: 'root' } },
}, C)
bench.erased.register({ name: 't.rows', id: 'a' }, C)
expect(seen).toEqual(['root', 't.rows', 't.rows'])
})
})

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSnapshotStore, shallowEqual } from '@deepseek-ai/dsh-client-web-react/store'
import { createSnapshotStore, defineStore, shallowEqual } from '../src/client/contract/store.ts'
interface State {
a: { n: number }
@@ -124,6 +124,98 @@ describe('createSnapshotStore', () => {
})
})
describe('defineStore', () => {
const declare = () => defineStore({
init: () => ({ selection: null as string | null, draft: '' }),
actions: {
select: (d, target: string) => { d.selection = target },
setDraft: (d, text: string) => { d.draft = text },
clearDraft: (d) => { d.draft = '' },
},
})
it('create() yields a live instance: fresh init state, selector-visible action writes', () => {
const inst = declare().create()
expect(inst.store.getSnapshot()).toEqual({ selection: null, draft: '' })
inst.actions.setDraft('hello')
inst.actions.select('m1')
expect(inst.store.getSnapshot()).toEqual({ selection: 'm1', draft: 'hello' })
inst.actions.clearDraft()
expect(inst.store.getSnapshot().draft).toBe('')
})
it('bakes draft-stripped actions that write through update (draft mutation, not replacement)', () => {
const inst = declare().create()
const before = inst.store.getSnapshot()
inst.actions.setDraft('x')
const after = inst.store.getSnapshot()
expect(after).not.toBe(before)
expect(after.selection).toBe(before.selection) // untouched branch preserved (immer path)
})
it('creates independent instances per create() call (the handle is a spec, not a singleton)', () => {
const handle = declare()
const a = handle.create()
const b = handle.create()
a.actions.setDraft('only-a')
expect(b.store.getSnapshot().draft).toBe('')
})
it('suffixes the persist key with the scope key: per-session persistence plus clearPersisted cleanup', () => {
const backing = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (k: string) => backing.get(k) ?? null,
setItem: (k: string, v: string) => { backing.set(k, v) },
removeItem: (k: string) => { backing.delete(k) },
})
const handle = defineStore({
init: () => ({ draft: '' }),
persist: 'spec.chat',
actions: { setDraft: (d, text: string) => { d.draft = text } },
})
handle.create('s1').actions.setDraft('one')
handle.create('s2').actions.setDraft('two')
handle.create().actions.setDraft('root')
expect(JSON.parse(backing.get('spec.chat.s1')!)).toEqual({ draft: 'one' })
expect(JSON.parse(backing.get('spec.chat.s2')!)).toEqual({ draft: 'two' })
expect(JSON.parse(backing.get('spec.chat')!)).toEqual({ draft: 'root' })
// Rehydration honors the same suffixed key.
expect(handle.create('s1').store.getSnapshot().draft).toBe('one')
// Scope-death cleanup removes exactly the suffixed key.
handle.create('s1').clearPersisted()
expect(backing.has('spec.chat.s1')).toBe(false)
expect(backing.has('spec.chat.s2')).toBe(true)
expect(backing.has('spec.chat')).toBe(true)
})
it('clearPersisted is a no-op without a persist declaration or without storage', () => {
const inst = declare().create('s1') // no persist key declared
expect(() => { inst.clearPersisted() }).not.toThrow()
const persisting = defineStore({
init: () => ({ n: 0 }),
persist: 'spec.nostorage',
actions: { inc: (d) => { d.n += 1 } },
}).create()
// jsdom-less lane: localStorage may exist here, so simulate its absence.
vi.stubGlobal('localStorage', undefined)
expect(() => { persisting.clearPersisted() }).not.toThrow()
})
it('swallows storage failures in clearPersisted (same non-fatal contract as persistence)', () => {
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => {},
removeItem: () => { throw new Error('quota / private mode') },
})
const inst = defineStore({
init: () => ({ n: 0 }),
persist: 'spec.throwing',
actions: { inc: (d) => { d.n += 1 } },
}).create()
expect(() => { inst.clearPersisted() }).not.toThrow()
})
})
describe('shallowEqual', () => {
it('matches one-level-equal objects and rejects deeper drift', () => {
const leaf = { deep: 1 }

View File

@@ -1,14 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
"outDir": "lib/types"
},
"include": [
"src"

View File

@@ -36,7 +36,6 @@ export const CLIENT_EXTERNALS = [
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-web-react',
'@deepseek-ai/dsh-client-web-react/store',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-connection/client',
'@deepseek-ai/dsh-client-runtime/client',
@@ -81,6 +80,21 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
dts: false,
clean: false,
external: CLIENT_EXTERNALS,
// Browser bundles inline node-idiom deps (zustand/immer read
// process.env.NODE_ENV; zustand's esm build also probes
// import.meta.env.MODE, which a CJS output cannot carry — rolldown flags
// EMPTY_IMPORT_META). vite defined both on the seed path; tsdown inlining
// needs the substitutions here or the factory throws ReferenceError at
// boot / the build gate reds. Both keys honor the build's NODE_ENV so a
// dev build keeps the dev-branch semantics; artifacts default to production.
// The bare `import.meta.env` key is required alongside the precise MODE
// key: zustand probes `import.meta.env ? import.meta.env.MODE : ...`, and
// the truthiness probe would otherwise survive as an empty import.meta.
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
},
// tsdown auto-externalizes package dependencies; anything NOT in the
// loader module table must inline instead (wire/type layers, zod, clsx —
// every non-shared dep). A require() the table cannot answer is a

View File

@@ -1,8 +1,16 @@
# @deepseek-ai/dsh-client-ui-conversation
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7.
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).
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains.
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: <active id>`), 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 · <path>` or `Edit · <path>` 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: '<tool>', 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).
`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).
## Model Experience
@@ -19,4 +27,3 @@ None; this package neither assembles nor sends a provider request.
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **Approval/question cards are display-only placeholders** — web-side answering (composer takeover panel) is the P-II approvals project.
- **Module-level toolview caches are single-bundle state** — the inject cache and registry maps must reach cross-bundle consumers through the package export surface and loader module table, never by a second bundle copy.

View File

@@ -34,12 +34,10 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-i18n": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},

View File

@@ -1,56 +1,40 @@
/**
* Client plugin body: provide the conversation service and toolview registry,
* register the conversation/details slot occupants and the no-session empty
* state, and mount the chat view with its samples. Assembly only — components
* receive everything through inject factories; nothing here renders directly.
* Client plugin body: register the conversation/details slot occupants and
* the no-session empty state, contribute the chat entry into the
* 'conversation.view' ring that the conversation registration declares, then
* mount the conversation service (class plugin) and the bash toolview sample.
* Assembly only — components receive everything through props: the framework
* standard kit and store faces arrive automatically from the declarations
* below; the inject factories contribute the plain-data-and-callbacks
* business face (design §5). Tool rows are ordinary keyed-slot registrations
* into 'conversation.chat.toolview' — no dedicated registry exists.
*/
import { createElement, Fragment, type ReactNode } from 'react'
import type { Context } from 'cordis'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import { scopedSlots, shallowEqual } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ViewTab } from './contract/views.ts'
import type {
SessionId, SessionListState, SessionsService, SlotsService,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
import type { ConvViewProps, SelectionTarget, ViewEntry, ViewId } from './contract/views.ts'
import type { ConversationInjected, DetailsInjected, EmptyStateInjected } from './contract/slots.ts'
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
} from './contract/slots.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import { ToolViewRegistry } from './toolviews/registry.ts'
import { childSessionScope, registerChat } from './chat/register.ts'
import { registerBashSamples } from './toolviews/bash-sample.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { EmptyState } from './skeleton/EmptyState.tsx'
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
export const inject = ['slots', 'layout', 'sessions', 'i18n']
export const inject = ['slots', 'layout', 'sessions']
/** Resolve a service via ctx.get, failing loud. Property access is reserved
* for contexts whose fiber declares the inject (scope fibers do not). */
// T is the caller-named cast target; inlining `as T` per call site would scatter the budgeted cast.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
function need<T>(ctx: Context, name: string): T {
const value = ctx.get(name) as T | undefined
if (value === undefined) throw new Error(`ui-conversation: ${name} service unavailable`)
return value
}
/** Per-list-state cwd set (deduped, list order) for the empty-state picker. */
const cwdsCache = new WeakMap<SessionListState, readonly string[]>()
function cwdsOf(state: SessionListState): readonly string[] {
let cached = cwdsCache.get(state)
if (cached === undefined) {
const seen = new Set<string>()
for (const id of state.ids) {
const cwd = state.byId[id]?.cwd
if (cwd !== undefined && cwd !== '') seen.add(cwd)
}
cached = [...seen]
cwdsCache.set(state, cached)
}
return cached
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
const scoped = sessions.scope(id)
if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`)
const conversation = scoped.get('conversation')
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable through the session scope')
return conversation
}
/**
@@ -58,128 +42,119 @@ function cwdsOf(state: SessionListState): readonly string[] {
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
const sessions = need<SessionsService>(ctx, 'sessions')
const layout = need<LayoutService>(ctx, 'layout')
const i18n = need<I18nService>(ctx, 'i18n')
const slots = need<SlotsService>(ctx, 'slots')
const sessions = ctx.sessions
const layout = ctx.layout
const slots = ctx.slots
const conversation = new ConversationService(ctx)
const toolviews = new ToolViewRegistry()
ctx.provide('toolviews', toolviews)
// Shared store handle, constructed here so its identity lives and dies with
// this fiber (a module-level handle would be a de-facto singleton). The
// conversation, chat-view, and details registrations all declare it; same
// scope key = same instance, so chat-view selection writes and details
// reads meet in one store.
const chatStore = createChatStore()
const t = i18n.bind('conversation')
// Chat view + StatsLine footer; bash samples assembled here (apply is the
// only cross-domain point — chat consumes the resolver face, samples come
// from the toolviews domain). registerView inside registerChat is already
// effect-scoped; the raw sample registrations need the effect wrapper to
// ride the fiber cascade.
ctx.effect(
() => registerChat({ conversation, toolviews, t }),
'ui-conversation: chat view')
ctx.effect(
() => registerBashSamples(toolviews, childSessionScope(sessions.list)),
'ui-conversation: bash toolview samples')
// ConvViewProps.slots is ScopedSlots<never>: a real outlet with an empty
// whitelist (uncallable by type, correct runtime shape for future grants).
const emptySlots = scopedSlots<never>(slots.core)
/** conversation slot: skeleton surface assembled once per (entry x session). */
const conversationInject = (b: SessionBinding): ConversationInjected => {
const bctx = b.ctx as Context
const scoped = need<ConversationService>(bctx, 'conversation')
const id = b.sessionId as SessionId
const useSession = b.session.useSelector as UseSession
const selectionStore = scoped.selection
const draftsStore = scoped.drafts
const session = sessions.manager.get(id)
// Watch-driven history pull: assembling the surface IS the watch signal
// (once per entry x session; open() is idempotent and self-recovers).
void session.open()
const viewProps: Omit<ConvViewProps, 'slots'> = {
sessionId: id,
useSession,
useSelection: selectionStore.useSelector,
actions: {
openDetails: (target: SelectionTarget) => { scoped.openDetails(target) },
loadOlder: () => { void session.loadOlder() },
},
// Tab projection over the view ring's ledger (list entries carry id/order/
// label as registration options; the ledger keeps them order-sorted).
const viewTabs = (): ViewTab[] => {
const tabs: ViewTab[] = []
for (const entry of slots.entries('conversation.view')) {
/* v8 ignore next -- unreachable: list registration validates id at load. */
if (entry.options.id === undefined) continue
tabs.push({ id: entry.options.id, label: entry.options.label ?? entry.options.id })
}
return tabs
}
const injected: ConversationInjected = {
useAncestry: () => sessions.list.useSelector(
() => sessions.ancestry(id),
(a, b) => shallowEqual(a, b)),
views: {
list: () => conversation.views(),
subscribe: fn => conversation.subscribeViews(fn),
version: () => conversation.viewsVersion(),
},
// layout's viewFor value type is its own looser ViewId; the registry is
// the runtime validator (unknown ids fall back to the first view).
useActiveView: () => layout.current.useSelector(s => s.viewFor[id]) as ViewId | undefined,
composer: {
useDraft: () => draftsStore.useSelector(s => s),
setDraft: (text) => { draftsStore.set(text) },
send: (mode) => {
const text = draftsStore.getSnapshot().trim()
if (text === '') return
// Conversation occupant. Declaring the view ring here is claiming it:
// ConversationRoot is the only component authorized to render the ring.
slots.register({
name: 'conversation',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ConversationInjected => {
// History pull is NOT triggered here: the runtime sessions service opens
// the event window when the watch lands on the session (cell/binding
// resolution) — an inject factory assembles callbacks, it has no side
// effect on session state.
const scoped = scopedConversation(sessions, sessionId)
return {
views: {
list: viewTabs,
subscribe: fn => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
},
send: (text, mode) => {
const trimmed = text.trim()
if (trimmed === '') return
// Optimistic clear with failure restore (choreography lives with the
// sender; the business failure also lands in snapshot.promptError).
draftsStore.set('')
void scoped.send(text, mode).catch(() => {
if (draftsStore.getSnapshot() === '') draftsStore.set(text)
})
// The store write path stays inside the declared actions set:
// restoreDraft itself no-ops once the user typed something new.
actions.clearDraft()
void scoped.send(trimmed, mode).catch(() => { actions.restoreDraft(trimmed) })
},
stop: () => {
scoped.cancel().catch(() => {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
})
},
},
actions: {
openView: (view: ViewId) => { layout.openView(id, view) },
open: (target: SessionId) => { layout.open(target) },
},
renderView: (entry: ViewEntry): ReactNode => {
const children: ReactNode[] = []
if (entry.chrome?.header !== undefined) {
children.push(createElement(entry.chrome.header, { key: 'header', sessionId: id, useSession }))
}
children.push(createElement(entry.component, { key: 'view', ...viewProps, slots: emptySlots }))
if (entry.chrome?.footer !== undefined) {
children.push(createElement(entry.chrome.footer, { key: 'footer', sessionId: id, useSession }))
}
return createElement(Fragment, null, ...children)
},
}
return injected
}
open: (target: SessionId) => { sessions.open(target) },
}
},
}, ConversationRoot)
/** details slot: minimal selection-driven panel. */
const detailsInject = (b: SessionBinding): DetailsInjected => {
const bctx = b.ctx as Context
const scoped = need<ConversationService>(bctx, 'conversation')
const injected: DetailsInjected = {
useSelection: scoped.selection.useSelector,
actions: { closeDetails: () => { layout.closeDetails() } },
}
return injected
}
// The chat view: first entry of the ring this package just declared.
// Declaring the keyed toolview hole here is claiming it: ChatView is the
// only component authorized to render per-tool rows. Shares the chat
// store, so its selection writes land in the same per-session instance the
// details panel reads.
slots.register({
name: 'conversation.view',
id: 'chat',
order: 0,
label: 'Chat',
children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } },
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => ({
openDetails: (target) => {
actions.select(target)
layout.openDetails()
},
loadOlder: () => { void sessions.manager.get(sessionId).loadOlder() },
}),
}, ChatView)
/** conversation.empty root slot: the NEW SESSION hero. */
const emptyInject = (): EmptyStateInjected => {
const useCwds: SnapshotSelectorHook<readonly string[]> = (sel, eq) =>
sessions.list.useSelector(s => sel(cwdsOf(s)), eq)
const injected: EmptyStateInjected = {
useCwds,
actions: { startSession: opts => conversation.startSession(opts) },
}
return injected
}
// Class-plugin mount (packages/AGENTS.md service form): the service
// registers itself as `conversation` and lives on its own child fiber.
// Mounted AFTER the chat entry register above — construction guarantee for
// toolview registrants using `inject: ['conversation']` as their load-order
// seam: the service being present implies the chat entry (and with it the
// 'conversation.chat.toolview' declaration) is on the ledger.
ctx.plugin(ConversationService)
slots.register('conversation', ConversationRoot, { inject: conversationInject })
slots.register('details', DetailsPanel, { inject: detailsInject })
slots.register('conversation.empty', EmptyState, { inject: emptyInject })
// The bash sample rides that exact seam, in third-party posture.
ctx.plugin(bashToolviewSample)
slots.register({
name: 'details',
store: chatStore,
inject: (): DetailsInjected => ({
closeDetails: () => { layout.closeDetails() },
}),
}, DetailsPanel)
slots.register({
name: 'conversation.empty',
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)
},
}),
}, EmptyState)
}

View File

@@ -1,8 +1,9 @@
// AssistantMarkdown: renders assistant blocks in order — markdown text body,
// reasoning as the figma Think summary row (expand = indented gray text),
// other-block JSON fallback. Tool-call heads are NOT rendered here: the chat
// view groups them into tool rows via the toolview outlet (figma step-summary
// flow). Shared by finalized nodes and the streaming partial (pulse marker).
// view groups them into tool rows through its keyed toolview slot (figma
// step-summary flow). Shared by finalized nodes and the streaming partial
// (pulse marker).
import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -32,6 +33,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
summary={firstLine(text)}
body={text}
state={running ? 'running' : 'ok'}
expandOnRowClick
/>
)
}

View File

@@ -1,53 +1,55 @@
// ChatView: the default conversation view — message flow with user bubbles,
// assistant narration, tool summary rows grouped into step runs, pending
// cards, paging and bottom-follow. Created via factory so plugin deps
// (toolviews registry, i18n) arrive by closure, never by import.
// cards, paging, bottom-follow, and the session stats line under the flow
// (chrome dissolved into the view: the footer is part of what a chat view
// IS, not registration metadata). Pure component registered directly; its
// registration declares the keyed 'conversation.chat.toolview' hole, so tool
// rows render through the props renderSlot share (entryKey = tool name,
// GenericToolCard as the render-site fallback).
//
// Render economics (architecture RFC performance model): the list parent
// subscribes to snapshot segments that do NOT change per streaming chunk
// (nodes/runningCalls/pending keep their references across chunk batches), so
// during a token storm only StreamingTail re-renders; history rows hold via
// memo on cache-stable node slices. Selection changes re-render the parent
// map but only rows whose own selected bit flipped.
// map but only rows whose own selected bit flipped. renderSlot is
// entry-identity-stable (framework binding cache), so passing it through
// memoized rows never churns them.
import {
memo, useLayoutEffect, useMemo, useRef, useState, type FC, type ReactNode,
memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} from 'react'
import type {
ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode,
ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ConvViewProps, SelectionTarget, Translate } from '../contract/views.ts'
import type { ToolViewProps } from '../contract/toolview.ts'
import type { ToolViewResolver } from '../contract/toolview.ts'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { SelectionTarget } from '../contract/views.ts'
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
import { MessageItem } from './MessageItem.tsx'
import { PendingCard } from './PendingCard.tsx'
import { ToolViewOutlet } from './ToolViewOutlet.tsx'
import { StatsLine } from './StatsLine.tsx'
import css from './ChatView.module.css'
/** Plugin-supplied closure deps (assembled in registerChat, apply world). */
export interface ChatViewDeps {
toolviews: ToolViewResolver
t: Translate
}
const FOLLOW_THRESHOLD = 24
type OpenDetails = (target: SelectionTarget) => void
/** web-react's UseSession is deliberately wide (dependency direction); the
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
type RenderToolRow = ChatViewSlotProps['renderSlot']
/** ui-slots' UseSession is deliberately wide (dependency direction); the
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
/** One tool call row (result or running): builds the bound ToolViewProps. */
const CallRow = memo(function CallRow({ registry, sessionId, useSession, t, callId, toolName, block, seq, onOpenDetails, selected }: {
registry: ToolViewResolver
sessionId: SessionId
useSession: ConvViewProps['useSession']
t: Translate
/** One tool call row (result or running): dispatches through the keyed
* toolview slot with the owner payload; unregistered tools fall back to
* GenericToolCard at this render site. */
const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected }: {
renderSlot: RenderToolRow
callId: string
toolName: string
block: ToolResultNode | RunningToolCall
@@ -56,24 +58,23 @@ const CallRow = memo(function CallRow({ registry, sessionId, useSession, t, call
onOpenDetails: OpenDetails
selected: boolean
}) {
const viewProps = useMemo<ToolViewProps>(() => ({
callId, toolName, block, useSession,
actions: { openDetails: () => onOpenDetails({ turnSeq: seq, callId, toolName }) },
t,
}), [callId, toolName, block, useSession, seq, onOpenDetails, t])
const owner = useMemo(() => ({
callId, toolName, block,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) },
}), [callId, toolName, block, seq, onOpenDetails])
return (
<div className={css.callRow} data-selected={selected || undefined}>
<ToolViewOutlet registry={registry} sessionId={sessionId} toolName={toolName} viewProps={viewProps} />
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} />,
})}
</div>
)
})
/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */
const ToolGroup = memo(function ToolGroup({ registry, sessionId, useSession, t, results, onOpenDetails, selectedCallId }: {
registry: ToolViewResolver
sessionId: SessionId
useSession: ConvViewProps['useSession']
t: Translate
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId }: {
renderSlot: RenderToolRow
results: readonly ToolResultNode[]
onOpenDetails: OpenDetails
/** Only set when the selected call lives in THIS group (memo economy). */
@@ -84,10 +85,7 @@ const ToolGroup = memo(function ToolGroup({ registry, sessionId, useSession, t,
{results.map((node) => (
<CallRow
key={node.callId}
registry={registry}
sessionId={sessionId}
useSession={useSession}
t={t}
renderSlot={renderSlot}
callId={node.callId}
toolName={node.call?.name ?? ''}
block={node}
@@ -114,180 +112,166 @@ function StreamingTail({ useSession, onGrow }: {
return <AssistantMarkdown blocks={partial.blocks} streaming />
}
/**
* Build the chat view component over plugin deps.
* @param deps - toolview registry and bound translator.
* @returns the ConvViewProps component registered as the chat view.
*/
export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
const { toolviews, t } = deps
/** The chat view slot entry: pure component over the composed props (tool rows render through the declared keyed hole's renderSlot share). */
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
const nodes = useSession((s) => s.nodes)
const runningCalls = useSession((s) => s.runningCalls)
const pending = useSession((s) => s.pending)
const openState = useSession((s) => s.openState)
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}${s.openError.code}`)
const hasMore = useSession((s) => s.hasMore)
const loadingOlder = useSession((s) => s.loadingOlder)
const selectedCallId = useStore((s) => s.selection?.callId)
return function ChatView({ sessionId, useSession: useSessionWide, useSelection, actions }: ConvViewProps) {
const useSession = useSessionWide as UseConversation
const nodes = useSession((s) => s.nodes)
const runningCalls = useSession((s) => s.runningCalls)
const pending = useSession((s) => s.pending)
const openState = useSession((s) => s.openState)
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}${s.openError.code}`)
const hasMore = useSession((s) => s.hasMore)
const loadingOlder = useSession((s) => s.loadingOlder)
const selectedCallId = useSelection((sel) => sel?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
const listRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
const [atBottom, setAtBottom] = useState(true)
/** Paging anchor: height/position at click, compensated after the prepend lands. */
const anchorRef = useRef<{ h: number; t: number } | null>(null)
const firstSeqRef = useRef<number | null>(null)
const openedRef = useRef(false)
const lastKeyRef = useRef<string | null>(null)
const listRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
const [atBottom, setAtBottom] = useState(true)
/** Paging anchor: height/position at click, compensated after the prepend lands. */
const anchorRef = useRef<{ h: number; t: number } | null>(null)
const firstSeqRef = useRef<number | null>(null)
const openedRef = useRef(false)
const lastKeyRef = useRef<string | null>(null)
const firstSeq = nodes[0]?.seq ?? null
const lastItem = items[items.length - 1]
const firstSeq = nodes[0]?.seq ?? null
const lastItem = items[items.length - 1]
const toBottom = (el: HTMLDivElement): void => {
el.scrollTop = el.scrollHeight
atBottomRef.current = true
setAtBottom(true)
}
useLayoutEffect(() => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
if (el === null) return
// Open completed: jump to the bottom once.
if (openState === 'open' && !openedRef.current) {
openedRef.current = true
toBottom(el)
firstSeqRef.current = firstSeq
lastKeyRef.current = lastItem?.key ?? null
return
}
// Prepend (head seq decreased): compensate by the height delta.
if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) {
el.scrollTop = anchorRef.current.t + (el.scrollHeight - anchorRef.current.h)
anchorRef.current = null
firstSeqRef.current = firstSeq
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
lastKeyRef.current = lastItem?.key ?? null
return
}
firstSeqRef.current = firstSeq
// Own words must be visible: a new trailing user node force-scrolls
// (send lives in the composer, so arrival is detected here, not armed there).
const lastKey = lastItem?.key ?? null
const appendedUser = lastKey !== lastKeyRef.current
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
lastKeyRef.current = lastKey
if (appendedUser || atBottomRef.current) toBottom(el)
})
const onScroll = (): void => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the handler only fires on the mounted element. */
if (el === null) return
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
}
// Follow streaming growth the parent never re-renders for (stable ref).
// The ref starts null and is assigned every render, so the placeholder
// initializer a function initial value would need never exists.
const followRef = useRef<(() => void) | null>(null)
followRef.current = () => {
const el = listRef.current
if (el !== null && atBottomRef.current) el.scrollTop = el.scrollHeight
}
const onGrow = useRef(() => followRef.current?.()).current
const loadOlder = (): void => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
if (el !== null) anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
actions.loadOlder()
}
const renderItem = (item: ChatFlowItem): ReactNode => {
if (item.kind === 'tool-group') {
const inGroup = selectedCallId !== undefined
&& item.results.some((r) => r.callId === selectedCallId)
return (
<ToolGroup
key={item.key}
registry={toolviews}
sessionId={sessionId}
useSession={useSession}
t={t}
results={item.results}
onOpenDetails={actions.openDetails}
selectedCallId={inGroup ? selectedCallId : undefined}
/>
)
}
const node: ConversationNode = item.node
if (node.kind === 'assistant') {
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return <MessageItem key={item.key} node={node} />
}
return (
<div className={css.root}>
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
<div className={css.column}>
{openState === 'loading' && <div className={css.hint}></div>}
{openState === 'error' && <div className={css.openError}>{openErrorMessage}</div>}
{hasMore && (
<div className={css.older}>
<button type="button" disabled={loadingOlder} onClick={loadOlder}>
{loadingOlder ? '加载中…' : '加载更早'}
</button>
</div>
)}
{items.map(renderItem)}
<StreamingTail useSession={useSession} onGrow={onGrow} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map((call) => (
<CallRow
key={call.callId}
registry={toolviews}
sessionId={sessionId}
useSession={useSession}
t={t}
callId={call.callId}
toolName={call.name}
block={call}
seq={call.turn}
onOpenDetails={actions.openDetails}
selected={call.callId === selectedCallId}
/>
))}
</div>
)}
{pending.map((item) => <PendingCard key={item.rpcId} item={item} />)}
</div>
</div>
{!atBottom && (
<button
type="button"
className={css.toBottom}
aria-label="回到底部"
onClick={() => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
if (el !== null) toBottom(el)
}}
>
<IconChevronDownOutline14 />
</button>
)}
</div>
)
const toBottom = (el: HTMLDivElement): void => {
el.scrollTop = el.scrollHeight
atBottomRef.current = true
setAtBottom(true)
}
useLayoutEffect(() => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
if (el === null) return
// Open completed: jump to the bottom once.
if (openState === 'open' && !openedRef.current) {
openedRef.current = true
toBottom(el)
firstSeqRef.current = firstSeq
lastKeyRef.current = lastItem?.key ?? null
return
}
// Prepend (head seq decreased): compensate by the height delta.
if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) {
el.scrollTop = anchorRef.current.t + (el.scrollHeight - anchorRef.current.h)
anchorRef.current = null
firstSeqRef.current = firstSeq
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
lastKeyRef.current = lastItem?.key ?? null
return
}
firstSeqRef.current = firstSeq
// Own words must be visible: a new trailing user node force-scrolls
// (send lives in the composer, so arrival is detected here, not armed there).
const lastKey = lastItem?.key ?? null
const appendedUser = lastKey !== lastKeyRef.current
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
lastKeyRef.current = lastKey
if (appendedUser || atBottomRef.current) toBottom(el)
})
const onScroll = (): void => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the handler only fires on the mounted element. */
if (el === null) return
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
}
// Follow streaming growth the parent never re-renders for (stable ref).
// The ref starts null and is assigned every render, so the placeholder
// initializer a function initial value would need never exists.
const followRef = useRef<(() => void) | null>(null)
followRef.current = () => {
const el = listRef.current
if (el !== null && atBottomRef.current) el.scrollTop = el.scrollHeight
}
const onGrow = useRef(() => followRef.current?.()).current
const loadOlderAnchored = (): void => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
if (el !== null) anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
loadOlder()
}
const renderItem = (item: ChatFlowItem): ReactNode => {
if (item.kind === 'tool-group') {
const inGroup = selectedCallId !== undefined
&& item.results.some((r) => r.callId === selectedCallId)
return (
<ToolGroup
key={item.key}
renderSlot={renderSlot}
results={item.results}
onOpenDetails={openDetails}
selectedCallId={inGroup ? selectedCallId : undefined}
/>
)
}
const node: ConversationNode = item.node
if (node.kind === 'assistant') {
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return <MessageItem key={item.key} node={node} />
}
return (
<div className={css.root}>
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
<div className={css.column}>
{openState === 'loading' && <div className={css.hint}></div>}
{openState === 'error' && <div className={css.openError}>{openErrorMessage}</div>}
{hasMore && (
<div className={css.older}>
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
{loadingOlder ? '加载中…' : '加载更早'}
</button>
</div>
)}
{items.map(renderItem)}
<StreamingTail useSession={useSession} onGrow={onGrow} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map((call) => (
<CallRow
key={call.callId}
renderSlot={renderSlot}
callId={call.callId}
toolName={call.name}
block={call}
seq={call.turn}
onOpenDetails={openDetails}
selected={call.callId === selectedCallId}
/>
))}
</div>
)}
{pending.map((item) => <PendingCard key={item.rpcId} item={item} />)}
</div>
</div>
<StatsLine useSession={useSession} />
{!atBottom && (
<button
type="button"
className={css.toBottom}
aria-label="回到底部"
onClick={() => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
if (el !== null) toBottom(el)
}}
>
<IconChevronDownOutline14 />
</button>
)}
</div>
)
}

View File

@@ -1,13 +1,15 @@
// GenericToolCard: the registry-miss fallback toolview — classifies the tool
// into one of the five figma row variants and renders the summary row. Also
// the shared base the bash sample builds on: any ToolViewProps consumer.
// GenericToolCard: the default tool row — classifies the tool into one of
// the five figma row variants and renders the summary row. Supplied by the
// chat view as the keyed toolview slot's render-site fallback (an
// unregistered tool name lands here); registrants may also compose it as a
// base, feeding the same owner payload through.
import type { ReactNode } from 'react'
import {
IconApiOutline14, IconBrowseOutline16, IconSearchOutline16, IconThinkOutline14,
IconApiOutline14, IconBrowseOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolViewProps } from '../contract/toolview.ts'
import { toolRowModel, type ToolCallBlock, type ToolRowVariant } from '../contract/tool-call-model.ts'
import type { ToolRowOwnerProps } from '../contract/slots.ts'
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
import { ToolRow } from './ToolRow.tsx'
import { IconSparkle16 } from './IconSparkle16.tsx'
@@ -17,11 +19,13 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
search: <IconSearchOutline16 />,
read: <IconBrowseOutline16 />,
bash: <IconApiOutline14 size={16} />,
write: <IconEditOutline16 />,
edit: <IconEditOutline16 />,
others: <IconSparkle16 />,
}
export function GenericToolCard({ toolName, block, actions }: ToolViewProps) {
const model = toolRowModel(toolName, block as ToolCallBlock)
export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) {
const model = toolRowModel(toolName, block)
return (
<ToolRow
variant={model.variant}
@@ -30,7 +34,7 @@ export function GenericToolCard({ toolName, block, actions }: ToolViewProps) {
summary={model.summary}
body={model.body}
state={model.state}
onOpenDetails={actions.openDetails}
onOpenDetails={openDetails}
/>
)
}

View File

@@ -1,14 +1,13 @@
// StatsLine: the session stats row (figma 122:11212 "cache hit 92% · 1,284
// tokens · 45.2s · 5 turns · 32 steps"), mounted as the chat view's
// chrome.footer — the first chrome-attachment consumer. Duration has no data
// source in P-I (ledger). Subscribes to `nodes` only: chunk batches never swap
// that reference, so the row renders zero times during streaming (the RFC
// performance model's acceptance row).
// tokens · 45.2s · 5 turns · 32 steps"), rendered by ChatView under the flow
// (part of the chat view body — the chrome attachment mechanism retired with
// the view ring). Duration has no data source in P-I (ledger). Subscribes to
// `nodes` only: chunk batches never swap that reference, so the row renders
// zero times during streaming (the RFC performance model's acceptance row).
import { memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ChromeProps } from '../contract/views.ts'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import css from './StatsLine.module.css'
interface UsageTotals {
@@ -55,8 +54,11 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
}
}
export const StatsLine = memo(function StatsLine({ useSession }: ChromeProps) {
const nodes = (useSession as SnapshotSelectorHook<ConversationSnapshot>)((s) => s.nodes)
/** Props: the conversation-snapshot selector hook (handed down by ChatView). */
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
const nodes = useSession((s) => s.nodes)
const stats = useMemo(() => deriveStats(nodes), [nodes])
if (stats.steps === 0) return null
const parts: string[] = []

View File

@@ -4,7 +4,7 @@
// no inline output (full results live in the details panel). Expand state is
// component-local view state; row click hands the selection off to the owner.
import { useState, type ReactNode } from 'react'
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -20,6 +20,8 @@ export interface ToolRowProps {
/** Expanded-body text; null = not expandable (leading slot never toggles). */
body: string | null
state: ToolRowState
/** Makes the row itself the expand control instead of only its leading icon. */
expandOnRowClick?: boolean | undefined
/** Selection handoff (row click), already bound to this call by the owner. */
onOpenDetails?: (() => void) | undefined
}
@@ -35,31 +37,56 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
}
}
export function ToolRow({ variant, icon, title, summary, body, state, onOpenDetails }: ToolRowProps) {
export function ToolRow({
variant,
icon,
title,
summary,
body,
state,
expandOnRowClick = false,
onOpenDetails,
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const expandable = body !== null
const open = expanded && expandable
const rowExpands = expandable && expandOnRowClick
const toggleExpand = () => {
setExpanded((v) => !v)
}
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
toggleExpand()
}
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
toggleExpand()
}
return (
<div className={css.root} data-variant={variant} data-state={state}>
<div
className={css.row}
data-clickable={onOpenDetails !== undefined || undefined}
onClick={onOpenDetails}
data-clickable={rowExpands || onOpenDetails !== undefined || undefined}
role={rowExpands ? 'button' : undefined}
tabIndex={rowExpands ? 0 : undefined}
aria-expanded={rowExpands ? open : undefined}
onClick={rowExpands ? toggleExpand : onOpenDetails}
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
>
{expandable ? (
{expandable && !rowExpands ? (
<button
type="button"
className={css.leading}
aria-expanded={open}
onClick={(e) => {
e.stopPropagation()
setExpanded((v) => !v)
}}
onClick={toggleFromLeading}
>
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
</button>
) : (
<span className={css.leading}>{leadingFor(state, icon)}</span>
<span className={css.leading}>
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
</span>
)}
<span className={css.title}>{title}</span>
{!open && (

View File

@@ -1,89 +0,0 @@
// ToolViewOutlet: resolves the toolview for one call through ctx.toolviews
// (uSES over the registry version so unload falls back live) and renders it
// behind a per-row error boundary. GenericToolCard is the render-side
// fallback for both a registry miss and a crashed custom row. A registrant
// inject factory is called once per (registration x binding) and cached,
// mirroring the scoped-slots injection discipline.
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import { useSessionBinding } from '@deepseek-ai/dsh-client-web-react'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolViewInject, ToolViewProps, ToolViewResolver } from '../contract/toolview.ts'
import { GenericToolCard } from './GenericToolCard.tsx'
export interface ToolViewOutletProps {
registry: ToolViewResolver
sessionId: SessionId
toolName: string
viewProps: ToolViewProps
}
/** Inject cache: per inject-factory (stable per registration) x binding object. */
const injectCache = new WeakMap<ToolViewInject<object>, WeakMap<object, object>>()
function cachedInject(inject: ToolViewInject<object>, binding: SessionBinding): object {
let perBinding = injectCache.get(inject)
if (!perBinding) {
perBinding = new WeakMap()
injectCache.set(inject, perBinding)
}
let props = perBinding.get(binding)
if (!props) {
props = inject(binding)
perBinding.set(binding, props)
}
return props
}
class RowErrorBoundary extends Component<
{ resetKey: unknown; fallback: ReactNode; children: ReactNode }, { failed: boolean }
> {
override state = { failed: false }
// Fallback state MUST flip here (render phase): a boundary whose derived
// state does not change re-renders the crashing children and React gives
// up after the second throw, escalating past the boundary.
static getDerivedStateFromError(): { failed: boolean } {
return { failed: true }
}
override componentDidCatch(error: unknown): void {
console.error('toolview row crashed:', error)
}
// A re-registration (resetKey bump) retries the custom row.
override componentDidUpdate(prev: { resetKey: unknown }): void {
if (this.state.failed && prev.resetKey !== this.props.resetKey) {
this.setState({ failed: false })
}
}
override render(): ReactNode {
if (this.state.failed) return this.props.fallback
return this.props.children
}
}
/** Split component: only inject-carrying registrations need the session
* binding hook (keeps injectless rendering free of the Provider requirement). */
function InjectedRow({ Row, inject, viewProps }: {
Row: FC<ToolViewProps & object>; inject: ToolViewInject<object>; viewProps: ToolViewProps
}) {
const binding = useSessionBinding()
const injected = cachedInject(inject, binding)
return <Row {...{ ...injected, ...viewProps }} />
}
export function ToolViewOutlet({ registry, sessionId, toolName, viewProps }: ToolViewOutletProps) {
const version = useSyncExternalStore(
(fn) => registry.subscribe(fn),
() => registry.getVersion(),
)
const resolved = registry.resolve(toolName, sessionId)
if (resolved === undefined) return <GenericToolCard {...viewProps} />
const Row = resolved.component
return (
<RowErrorBoundary resetKey={version} fallback={<GenericToolCard {...viewProps} />}>
{resolved.inject === undefined
? <Row {...viewProps} />
: <InjectedRow Row={Row} inject={resolved.inject} viewProps={viewProps} />}
</RowErrorBoundary>
)
}

View File

@@ -1,52 +0,0 @@
/**
* Chat-side registration entry, called from the plugin apply (the assembly
* point): registers the chat view with the stats-line footer chrome. The
* chat domain touches the tool ring only through the contract resolver face;
* bash sample registration moved to apply (cross-domain assembly).
*/
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationService } from '../service.ts'
import type { Translate } from '../contract/views.ts'
import type { ToolViewResolver } from '../contract/toolview.ts'
import { createChatView } from './ChatView.tsx'
import { StatsLine } from './StatsLine.tsx'
/** Read face of the sessions list store (subscription not needed: the filter
* reads the latest snapshot at each resolve). */
export interface SessionListReader { getSnapshot(): SessionListState }
/**
* Default scoped-sample filter: the sub-session family. Sub-agent rows
* rendering differently is the registry's canonical product scenario, and
* forking gives W5 acceptance a real entry point to observe the differential.
* @param list - injected sessions list read face.
* @returns filter matching sessions with a parent.
*/
export function childSessionScope(list: SessionListReader): (sessionId: SessionId) => boolean {
return sessionId => list.getSnapshot().byId[sessionId]?.parentId !== undefined
}
/** Assembly inputs for {@link registerChat} (resolved by apply, not here). */
export interface RegisterChatDeps {
conversation: ConversationService
/** Toolview read face consumed by the chat rows' outlet. */
toolviews: ToolViewResolver
/** Translator bound to the conversation namespace. */
t: Translate
}
/**
* Register the chat view (footer chrome included).
* @param deps - assembled service instances.
* @returns disposer removing the registration.
*/
export function registerChat(deps: RegisterChatDeps): () => void {
const { conversation, toolviews, t } = deps
return conversation.registerView({
id: 'chat',
label: 'Chat',
order: 0,
component: createChatView({ toolviews, t }),
chrome: { footer: StatsLine },
})
}

View File

@@ -1,63 +1,149 @@
/**
* Slot-ring contract for the conversation package: the composed props shapes
* its registrants mount into the layout-owned slots (conversation / details /
* conversation.empty — the SlotMap declarations live with ui-layout, the
* slot owner). Per the share-ownership rule, the owner share is REFERENCED
* from ui-layout and each registrant's injected share is declared here, next
* to the component that receives it; full component props = owner share &
* standard share & own injected share.
* Slot-ring contract for the conversation package: the 'conversation.view'
* slot this package declares (the view ring — one list entry per conversation
* view tab), the chat view's per-tool row hole ('conversation.chat.toolview',
* keyed on the wire tool name), and the composed props shapes its registrants
* mount into the layout-owned slots (conversation / details /
* conversation.empty) plus its own slots. Terminal slot design (§3): full
* component props are the automatic shares — PropsRuntime<K> (framework
* standard kit) & PropsRenderSlots<S> (declared children) & PropsStore<H>
* (declared store's read/write faces) & the injected business face declared
* here.
*/
import type { ReactNode } from 'react'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConvOwnerProps, DetailsOwnerProps, EmptyOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SelectionTarget, ViewEntry, ViewId } from './views.ts'
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
/** Injected share of the conversation slot (assembled by apply's inject factory). */
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
* The conversation view ring: one list entry per view tab (chat here;
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
* ConversationRoot via `only: <active id>`. Declared by this package's
* 'conversation' entry (declaring is claiming). Session scope: views read
* the conversation snapshot through the standard kit.
*/
'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps }
/**
* The chat view's per-tool row hole: keyed dispatch on the wire tool name
* (the key space is runtime-open — SlotMap declares slots, never keys).
* Declared by the chat view entry (declaring is claiming); the render
* site dispatches via `entryKey: toolName` with GenericToolCard as the
* `fallback` for unregistered tools.
*/
'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps }
}
}
/**
* View-slot owner share: deliberately empty — ConversationRoot supplies
* nothing at its renderSlot site (sessionId and the snapshot hook arrive as
* framework-standard props; tool rows go through each view's own declared
* toolview hole). Kept as the named owner seat so a future cross-view
* payload has a home.
*/
export interface ConvViewOwnerProps {}
/**
* Owner share of a per-view toolview slot: the call material the rendering
* view supplies per row. Uniform across views — the trajectory/waterfall
* toolview slots (same kind/scope/owner, names fixed by the slot-naming
* discipline) land with their own row render sites; today only the chat slot
* is declared (RendersCheck rejects a declaration nobody renders).
*/
export interface ToolRowOwnerProps {
/** Tool call identity (details linkage; stable across running → settled). */
callId: CallId
/** Wire tool name (also the keyed dispatch key at the render site). */
toolName: string
/** Frozen call slice: the running call or the settled result node. */
block: ToolCallBlock
/** Open the details panel for this call (session-level facility, supplied by the view). */
openDetails(): void
}
/**
* Full props of a registered tool-row component: the slot's runtime share
* (owner payload + session standard kit + global seat). Registrants type
* their component `FC<ToolRowProps & I>` with `I` inferred from their inject
* factory. Declared against the chat slot; the three per-view toolview slots
* share one declaration shape, so this alias serves them all.
*/
export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
/**
* Base props of a conversation view entry: the framework standard kit for the
* session-scope 'conversation.view' slot (useSession narrowed to the
* conversation snapshot by the runtime merge, sessionId, useSessions).
* Entries declaring the shared store or an inject face compose their shares
* on top (the chat entry's {@link ChatViewSlotProps}); store-less pure
* readers (ui-trajectory) take this base alone.
*/
export type ConvViewProps = PropsRuntime<'conversation.view'>
/** The shared chat store handle type (apply constructs one; the conversation, details, and chat-view registrations all declare it). */
export type ChatStore = ReturnType<typeof createChatStore>
/**
* Injected share of the conversation slot: plain data and callbacks only
* (design §5 — hooks are framework-made). The store lines that used to ride
* here live in the declared {@link ChatStore}; ancestry derives from the
* standard useSessions hook in-component; views render through the declared
* 'conversation.view' child slot, with this face projecting the tab strip.
*/
export interface ConversationInjected {
/** Breadcrumb chain (root ancestor first, self last; ancestry(list) feed). */
useAncestry: () => readonly SessionSummary[]
/** View registry read face (uSES triple from the conversation service). */
/** View tab read face (uSES triple over the 'conversation.view' slot ledger). */
views: {
list(): readonly ViewEntry[]
list(): readonly ViewTab[]
subscribe(fn: () => void): () => void
version(): number
}
/** Active view accessor (layout.viewFor backed; undefined falls to 'chat'). */
useActiveView: () => ViewId | undefined
/** Composer surface: draft store hook pair + send/stop choreography. */
composer: {
useDraft: () => string
setDraft(text: string): void
send(mode: 'queue' | 'steer'): void
stop(): void
}
actions: {
openView(view: ViewId): void
open(id: SessionId): void
}
/** Renders the active view's body (the owner closes over ConvViewProps assembly). */
renderView: (entry: ViewEntry) => ReactNode
/** Send choreography: trims, clears the draft optimistically, restores it on failure. */
send(text: string, mode: 'queue' | 'steer'): void
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
stop(): void
/** Navigate to another session (breadcrumb ancestors). */
open(id: SessionId): void
}
/** Full conversation-slot component props: owner share & standard share & injected share. */
export type ConversationSlotProps = ConvOwnerProps & { useSession: UseSession } & ConversationInjected
/** Full conversation-slot component props: runtime share & view-slot render share & store share & injected share. */
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view'> & PropsStore<ChatStore> & ConversationInjected
/** Injected share of the details slot. */
/**
* Injected share of the chat view entry: the two callbacks whose targets live
* outside the view (layout orchestration; the session object layer).
*/
export interface ChatViewInjected {
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
openDetails(target: SelectionTarget): void
/** Pull one older history page. */
loadOlder(): void
}
/** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */
export type ChatViewSlotProps =
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview'>
& PropsStore<ChatStore> & ChatViewInjected
/**
* Injected share of the details slot: the panel is otherwise a pure reader of
* the shared chat store, but its close button is a layout orchestration call.
*/
export interface DetailsInjected {
useSelection: SnapshotSelectorHook<SelectionTarget | null>
actions: { closeDetails(): void }
/** Close the details panel (layout geometry stays with ctx.layout). */
closeDetails(): void
}
/** Full details-slot component props. */
export type DetailsSlotProps = DetailsOwnerProps & { useSession: UseSession } & DetailsInjected
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected
/** Injected share of the no-session empty-state slot (root slot: no standard share). */
/** Injected share of the no-session empty-state slot. */
export interface EmptyStateInjected {
/** cwd options derived from sessions.list (deduped; assembled by the inject factory). */
useCwds: SnapshotSelectorHook<readonly string[]>
actions: { startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void> }
/** The create → navigate → first-send chain, in one service call. */
startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void>
}
/** Full empty-state component props. */
export type EmptyStateSlotProps = EmptyOwnerProps & EmptyStateInjected
/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */
export type EmptyStateSlotProps = PropsRuntime<'conversation.empty'> & EmptyStateInjected

View File

@@ -3,25 +3,29 @@
* one-line summary and expanded-body text from the frozen call slice. No
* inline output ever — full results live in the details panel.
*/
import type { ToolCallBlock } from './toolview.ts'
// The block union's defining home is runtime (fold-product types); this
// contract only forwards it (type-definition authority stays with the layer
// that produces the values).
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
export type { ToolCallBlock } from './toolview.ts'
export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
/** The frozen slice the chat view hands to toolview components as `block`
* (both members are cache-stable references off ConversationSnapshot). */
/** The five figma row variants (think is fed by reasoning blocks, not tool calls). */
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'others'
/** The seven row variants (think is fed by reasoning blocks, not tool calls). */
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'others'
/** Row state semantic; colors self-supplied via StateDot (design gives none). */
export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped'
/** Figma row titles per variant (design literals, not translatable copy). */
export const VARIANT_TITLES: Record<ToolRowVariant, string> = {
think: 'Think', search: 'Search', read: 'Read', bash: 'Bash', others: 'Tool call',
think: 'Think', search: 'Search', read: 'Read', bash: 'Bash',
write: 'Write', edit: 'Edit', others: 'Tool call',
}
/** Known tool name -> variant; fs write/edit intentionally fall to others (no figma form). */
/** Known tool name -> variant. */
const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
bash: 'bash',
read: 'read',
@@ -29,6 +33,8 @@ const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
web_search: 'search',
grep: 'search',
glob: 'search',
write: 'write',
edit: 'edit',
}
/**
@@ -78,6 +84,8 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
read: ['path', 'file_path', 'url'],
search: ['query', 'pattern', 'url'],
think: [],
write: ['path', 'file_path'],
edit: ['path', 'file_path'],
others: [],
}

View File

@@ -1,77 +0,0 @@
/**
* Tool-ring contract: the props surface handed to toolview components, the
* registry's resolve/registration shapes, and the tool-call block union.
* Shared face between the chat domain (ToolViewOutlet consumes resolve) and
* the toolviews domain (registry implementation + sample rows); domain
* implementation files import this, never each other.
*/
import type { FC } from 'react'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { CallId, Translate } from './views.ts'
// The block union's defining home is runtime (fold-product types); the
// contract only forwards it (type-definition authority stays with the layer
// that produces the values).
export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
/** Props handed to registered toolview components. */
export interface ToolViewProps {
callId: CallId
toolName: string
block: ToolCallBlock
useSession: UseSession
actions: { openDetails(): void }
t: Translate
}
/**
* Toolview inject factory: produces the registrant's private injected share
* `I`, called once per (registration x session binding) and cached by the
* render outlet. Session-bound by nature — tool rows always render inside a
* session subtree.
*/
export type ToolViewInject<I extends object> = (b: SessionBinding) => I
/** Options accepted by the toolview registry's register; `I` is inferred from the inject factory. */
export interface ToolViewOptions<I extends object = object> {
/** Session filter; absent = global registration. */
scope?: (sessionId: SessionId) => boolean
/** Private inject factory merged into the row's props by the render outlet. */
inject?: ToolViewInject<I>
}
/**
* A resolved toolview registration. `I` is erased to `object` on the resolve
* read face (storage erases the per-registration parameter; the outlet merges
* injected props untyped — the register site already proved component ⊇ I).
*/
export interface ResolvedToolView<I extends object = object> {
component: FC<ToolViewProps & I>
inject?: ToolViewInject<I>
}
/** The registry's read face consumed by render outlets (implementation lives in the toolviews domain). */
export interface ToolViewResolver {
/**
* Resolve the renderer for a tool in a session. Order: scope match (later
* registration wins) > global > undefined (caller falls back to the
* generic card).
* @param tool - tool name.
* @param sessionId - session the row renders in.
* @returns resolved view, or undefined when nothing matches.
*/
resolve(tool: string, sessionId: SessionId): ResolvedToolView | undefined
/**
* Subscribe to registration changes (synchronous).
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void
/**
* Monotonic version for uSES pairing.
* @returns current version.
*/
getVersion(): number
}

View File

@@ -1,68 +1,39 @@
/**
* View-ring contract: the typed conversation view table and the props
* surfaces handed to registered views. Shared face between the skeleton
* domain (ConversationRoot renders views) and the chat domain (registers the
* chat view); domain implementation files import this, never each other.
* Shared conversation contract primitives: the view tab projection (slot
* entries in 'conversation.view' surface as tabs), the chat store state
* shared through the declared store, and the selection primitives every
* domain consumes. Shared face between the skeleton domain (tab strip +
* view outlet) and the chat domain; domain implementation files import this,
* never each other. The view ring itself IS the 'conversation.view' slot
* (contract in slots.ts) — the package-local view registry is retired, and
* so is the hand-threaded translate channel (framework-level per-slot i18n
* injection is the planned replacement).
*/
import type { FC } from 'react'
import type { ScopedSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
/**
* One ConversationViewMap entry: per-view props extension shapes (design
* ledger, view ring). `chromeProps` extends {@link ChromeProps} for the
* view's chrome attachments; `extraProps` extends {@link ConvViewProps} for
* the view component itself. Both optional — the common bases stay the floor.
*/
export interface ViewEntryDef { chromeProps?: object; extraProps?: object }
/**
* Typed conversation view table; ui-trajectory merges {trajectory, waterfall}.
* The chat entry is declared inline here (self-merge from a sibling module
* trips TS6305 under tsc -b).
*/
export interface ConversationViewMap { chat: ViewEntryDef }
/** View id constrained to registered ConversationViewMap keys (all string literals; chat is declared inline). */
export type ViewId = keyof ConversationViewMap
/** Per-view chrome props: the common base plus the entry's declared extension. */
export type ChromePropsOf<Id extends ViewId> =
ChromeProps & (ConversationViewMap[Id] extends { chromeProps: infer C extends object } ? C : object)
/** Per-view component props: the common base plus the entry's declared extension. */
export type ConvViewPropsOf<Id extends ViewId> =
ConvViewProps & (ConversationViewMap[Id] extends { extraProps: infer E extends object } ? E : object)
/** Tool call identity as carried on the wire (branded upstream in connection). */
export type CallId = string
/** Translate function bound to a namespace via i18n. */
export type Translate = (key: string, params?: Record<string, unknown>) => string
/** One registered conversation view (props positions keyed by the entry's declared shapes). */
export interface ViewEntry<Id extends ViewId = ViewId> {
id: Id
label: string
order?: number
component: FC<ConvViewPropsOf<Id>>
/** Per-view chrome attachments (chat mounts the stats line as footer). */
chrome?: { header?: FC<ChromePropsOf<Id>>; footer?: FC<ChromePropsOf<Id>> }
}
/** Props for view chrome attachments. */
export interface ChromeProps { sessionId: SessionId; useSession: UseSession }
/** Selection target for the details linkage channel (toolcall is the step special case). */
export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: CallId; toolName?: string }
/** Props handed to registered conversation views. */
export interface ConvViewProps {
sessionId: SessionId
useSession: UseSession
useSelection: SnapshotSelectorHook<SelectionTarget | null>
actions: { openDetails(t: SelectionTarget): void; loadOlder(): void }
/** Chat has no delegated sub-slots in P-I (toolviews go through the named registry). */
slots: ScopedSlots<never>
/**
* One conversation view tab, projected from a 'conversation.view' slot
* entry's registration options (label falls back to the entry id).
*/
export interface ViewTab { id: string; label: string }
/**
* Chat store state (slot terminal design §4): the per-session store shared by
* the conversation, chat-view, and details registrations. `createChatStore`
* implements this shape. `view` may carry a stale persisted id after a view
* plugin unloads — the slot ledger is the runtime validator (unknown ids fall
* back to the first registered view).
*/
export interface ChatStoreState {
/** Details-linkage channel (conversation writes, details reads). */
selection: SelectionTarget | null
/** Composer draft (persisted; survives session switches and reloads). */
draft: string
/** Active conversation view id ('conversation.view' entry id); null falls back to the first view. */
view: string | null
}

View File

@@ -1,42 +1,31 @@
/**
* Conversation domain plugin, browser half: skeleton (header/tabs/composer),
* typed view registry, scope-addressed ConversationService, named toolview
* registry, minimal details panel. Contract: api-contracts v3 section 7.
* Thin shell: type surfaces live in contract/, assembly in apply.ts; the
* three implementation domains (skeleton/chat/toolviews) never import each
* other — contract/ is their only shared face.
* the 'conversation.view' slot ring (chat entry here; other plugins
* contribute view tabs through ctx.slots), the chat view's keyed
* 'conversation.chat.toolview' row hole, scope-addressed ConversationService,
* minimal details panel. Contract: api-contracts v3 section 7. Thin shell:
* type surfaces live in contract/, assembly in apply.ts; the implementation
* domains (skeleton/chat) never import each other — contract/ is their only
* shared face.
*/
import type { ConversationService } from './service.ts'
import type { ToolViewRegistry } from './toolviews/registry.ts'
export { apply, inject } from './apply.ts'
export { ConversationService } from './service.ts'
export { ToolViewRegistry } from './toolviews/registry.ts'
export type {
CallId, ChromeProps, ChromePropsOf, ConversationViewMap, ConvViewProps, ConvViewPropsOf,
SelectionTarget, Translate, ViewEntry, ViewEntryDef, ViewId,
CallId, ChatStoreState, SelectionTarget, ViewTab,
} from './contract/views.ts'
export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type {
ResolvedToolView, ToolCallBlock, ToolViewOptions, ToolViewProps, ToolViewResolver,
} from './contract/toolview.ts'
export type {
ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps,
ChatStore, ChatViewInjected, ChatViewSlotProps, ConversationInjected, ConversationSlotProps,
ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
export { ConversationRoot } from './skeleton/ConversationRoot.tsx'
export type { ConversationRootProps } from './skeleton/ConversationRoot.tsx'
export { InputBar } from './skeleton/InputBar.tsx'
export type { InputBarError, InputBarProps } from './skeleton/InputBar.tsx'
export { EmptyState } from './skeleton/EmptyState.tsx'
export type { EmptyStateProps } from './skeleton/EmptyState.tsx'
export { DetailsPanel } from './skeleton/DetailsPanel.tsx'
export type { DetailsPanelProps } from './skeleton/DetailsPanel.tsx'
// Export discipline: packages/client/AGENTS.md.
declare module 'cordis' {
interface Context {
conversation: ConversationService
toolviews: ToolViewRegistry
}
}

View File

@@ -1,8 +1,10 @@
/**
* ConversationService implementation: scope-addressed send/cancel, per-scope
* selection/draft stores booked on the session scope fiber, view registry
* with a uSES read face, openDetails orchestration, and the empty-state
* startSession chain. Contract: api-contracts v3 section 7.
* ConversationService implementation: scope-addressed send/cancel and the
* empty-state startSession chain. Contract: api-contracts v3 section 7.
* Selection/draft state moved to the declared chat store (slot terminal
* design §4); the view registry moved to the 'conversation.view' slot (slot
* ledger owns registration, ordering, and disposal) — what remains is the
* send/stop orchestration face.
*
* Scope addressing rides the cordis Service tracker: property access through
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
@@ -20,29 +22,10 @@ import type { Context } from 'cordis'
// SessionsService tags contexts with — scopeOf then always returns undefined
// in the browser while unit tests (single-instance path resolution) stay green.
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SelectionTarget, ViewEntry, ViewId } from './index.ts'
/** Mutable view-registry cell (plain object: mutation never crosses the tracker proxy). */
interface ViewsState {
entries: Map<string, ViewEntry>
/** Sorted projection cache; null = rebuild on next read. */
cache: readonly ViewEntry[] | null
tick: number
listeners: Set<() => void>
}
import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
export class ConversationService extends Service {
private readonly selections = new Map<SessionId, SnapshotStore<SelectionTarget | null>>()
private readonly draftStores = new Map<SessionId, SnapshotStore<string>>()
private readonly viewsState: ViewsState = {
entries: new Map(), cache: null, tick: 0, listeners: new Set(),
}
/**
* @param ctx - owning root context (the plugin apply context; the service
* registers itself and follows that fiber's lifetime).
@@ -71,91 +54,6 @@ export class ConversationService extends Service {
if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`)
}
/** Per-scope selection channel (details linkage); root access throws. */
get selection(): SnapshotStore<SelectionTarget | null> {
return this.scopeStore(this.selections, 'selection',
() => createSnapshotStore<SelectionTarget | null>(null))
}
/**
* Per-scope draft store, persisted per session id; root access throws.
* Persistence is hand-rolled (raw string per key): the snapshot-store
* engine's persist middleware object-spreads state on save, corrupting
* primitive-state stores.
*/
get drafts(): SnapshotStore<string> {
return this.scopeStore(this.draftStores, 'drafts', (id) => {
const key = `dsh.conversation.draft.${id}`
const store = createSnapshotStore<string>(loadDraft(key))
store.subscribe(() => { saveDraft(key, store.getSnapshot()) })
return store
})
}
/**
* Write the scoped selection and open the details panel. Orchestration
* only — panel geometry stays with ctx.layout.
* @param target - selection target.
*/
openDetails(target: SelectionTarget): void {
this.selection.set(target)
this.requireLayout().openDetails()
}
/**
* Register a conversation view. Duplicate ids throw; the registration is an
* effect on the caller's fiber (plugin unload collects it).
* @param entry - the view entry.
* @returns disposer removing the view.
*/
registerView<Id extends ViewId>(entry: ViewEntry<Id>): () => void {
const views = this.viewsState
const dispose = this.ctx.effect(() => {
if (views.entries.has(entry.id)) {
throw new Error(`conversation view "${entry.id}" is already registered`)
}
views.entries.set(entry.id, entry)
bumpViews(views)
return () => {
views.entries.delete(entry.id)
bumpViews(views)
}
}, 'conversation.registerView()')
// The effect disposer settles asynchronously; the registry face stays a
// synchronous fire-and-forget disposer.
return () => { void dispose() }
}
/**
* Registered views ordered by `order` (ties keep registration sequence).
* Stable array reference between mutations (uSES getSnapshot source).
* @returns the view entries.
*/
views(): readonly ViewEntry[] {
const state = this.viewsState
state.cache ??= [...state.entries.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
return state.cache
}
/**
* Subscribe to view registry changes (synchronous, like the toolview registry).
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribeViews(fn: () => void): () => void {
const { listeners } = this.viewsState
listeners.add(fn)
return () => { listeners.delete(fn) }
}
/**
* Monotonic view registry version for uSES pairing.
* @returns current version.
*/
viewsVersion(): number {
return this.viewsState.tick
}
/**
* Empty-state first-send chain (root-context method; does not read scope):
* create the session, navigate to it, then send through the new scope.
@@ -170,9 +68,9 @@ export class ConversationService extends Service {
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 layout.open validates against it.
// list-store projection landed before sessions.open validates against it.
await Promise.resolve()
this.requireLayout().open(id)
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
@@ -185,34 +83,11 @@ export class ConversationService extends Service {
/** 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)
}
/** Read the caller's session scope tag; root contexts fail loud. */
private scopeId(op: string): SessionId {
const id = scopeOf(this.ctx)
if (id === undefined) {
throw new Error(`conversation.${op} requires a session scope — address one via ctx.sessions.scope(id).conversation`)
}
return id
}
/**
* Per-scope store account: lazily created, booked on the scope fiber so the
* scope teardown (SessionsService prune) collects the entry.
*/
private scopeStore<T>(
map: Map<SessionId, SnapshotStore<T>>, op: string,
make: (id: SessionId) => SnapshotStore<T>): SnapshotStore<T> {
const id = this.scopeId(op)
let store = map.get(id)
if (store === undefined) {
store = make(id)
map.set(id, store)
this.ctx.effect(() => () => { map.delete(id) }, `conversation.${op} scope account`)
}
return store
return this.requireSessions().manager.get(id)
}
private requireSessions(): SessionsService {
@@ -223,29 +98,4 @@ export class ConversationService extends Service {
if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
return sessions
}
private requireLayout(): LayoutService {
const layout = this.ctx.get('layout')
if (layout === undefined) throw new Error('conversation: layout service unavailable')
return layout
}
}
function bumpViews(state: ViewsState): void {
state.cache = null
state.tick += 1
for (const fn of [...state.listeners]) fn()
}
function loadDraft(key: string): string {
/* v8 ignore next -- storage-less environment guard (workers/tests without DOM); jsdom always provides localStorage. */
if (typeof localStorage === 'undefined') return ''
return localStorage.getItem(key) ?? ''
}
function saveDraft(key: string, text: string): void {
/* v8 ignore next -- storage-less environment guard (workers/tests without DOM); jsdom always provides localStorage. */
if (typeof localStorage === 'undefined') return
if (text === '') localStorage.removeItem(key)
else localStorage.setItem(key, text)
}

View File

@@ -1,38 +1,57 @@
// ConversationRoot: the conversation slot's skeleton (figma Header 39:27730 +
// Tab_Group + view area + composer). Zero framework imports — everything
// arrives via props from the inject factory: breadcrumb feed, view registry
// read face, per-view render, and the composer's draft/send choreography.
// The active view id lives in layout.viewFor (shell viewing state), read and
// written through injected accessors.
// Tab_Group + view area + composer). Pure component — everything arrives via
// props: the framework standard kit (useSession/sessionId/useSessions), the
// declared chat store's useStore/actions, the injected business face, and the
// renderSlot share for the declared 'conversation.view' child slot (views are
// slot entries; the active one renders via the list `only` filter).
// Breadcrumbs derive from useSessions with a pure parentId walk; the active
// view id lives in the chat store's `view` field (per-session by store scope).
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps } from '../contract/slots.ts'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import css from './ConversationRoot.module.css'
/**
* Full props = owner share (sessionId) & standard share (useSession) &
* injected share — composed by reference from the contract, never re-typed
* here (share-ownership rule).
*/
/** Full props = the automatic shares & injected share — composed by reference
* from the contract, never re-typed here (share-ownership rule). */
export type ConversationRootProps = ConversationSlotProps
/** Breadcrumb chain: walk parentId links (root ancestor first, self last;
* empty when unknown; a broken link stops the walk). Pure twin of the
* sessions service's ancestry — components derive, they don't subscribe. */
function deriveAncestry(list: SessionListState, id: SessionId): readonly SessionSummary[] {
const chain: SessionSummary[] = []
let cursor: SessionId | undefined = id
while (cursor !== undefined) {
const summary: SessionSummary | undefined = list.byId[cursor]
if (summary === undefined || chain.includes(summary)) break
chain.unshift(summary)
cursor = summary.parentId
}
return chain
}
export function ConversationRoot({
sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView,
sessionId, useSession, useSessions, useStore, actions, renderSlot,
views, send, stop, open,
}: ConversationRootProps) {
useSyncExternalStore(views.subscribe, views.version)
const list = views.list()
const activeId = useActiveView() ?? 'chat'
const active = list.find(v => v.id === activeId) ?? list[0]
const tabs = views.list()
// The store's persisted view id may be stale (view plugin unloaded); the
// slot ledger is the runtime validator — unknown ids fall to the first view.
const activeId = useStore(s => s.view) ?? 'chat'
const active = tabs.find(v => v.id === activeId) ?? tabs[0]
const ancestry = useAncestry()
const draft = composer.useDraft()
const running = useSession(s => (s as { running: boolean }).running)
const removed = useSession(s => (s as { removed: boolean }).removed)
const promptError = useSession(s => (s as { promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null }).promptError)
const turns = useSession(s => countTurns(s as { nodes: readonly { kind: string }[] }))
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
const draft = useStore(s => s.draft)
const running = useSession(s => s.running)
const removed = useSession(s => s.removed)
const promptError = useSession(s => s.promptError)
const turns = useSession(s => countTurns(s))
const error: InputBarError | null = promptError === null
? null
@@ -52,7 +71,7 @@ export function ConversationRoot({
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { actions.open(s.id) }}
onClick={() => { open(s.id) }}
>
{s.displayTitle}
</button>
@@ -65,16 +84,16 @@ export function ConversationRoot({
{/* Header button row (Fork / Session log / I/O Details): a P-I visual
placeholder registry slot is deferred — buttons land with their features. */}
</div>
{list.length > 1 && (
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">
{list.map(v => (
{tabs.map(v => (
<button
key={v.id}
type="button"
role="tab"
aria-selected={v.id === active?.id}
className={clsx(css.tab, v.id === active?.id && css.tabActive)}
onClick={() => { actions.openView(v.id) }}
onClick={() => { actions.setView(v.id) }}
>
{v.label}
</button>
@@ -84,7 +103,7 @@ export function ConversationRoot({
</header>
<div className={css.viewArea}>
{active !== undefined && renderView(active)}
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>
<InputBar
@@ -93,9 +112,9 @@ export function ConversationRoot({
disabled={removed}
error={error}
variant="composer"
onDraftChange={composer.setDraft}
onSend={composer.send}
onStop={composer.stop}
onDraftChange={actions.setDraft}
onSend={(mode) => { send(draft, mode) }}
onStop={stop}
/>
</div>
)

View File

@@ -1,14 +1,16 @@
// DetailsPanel, P-I minimal form: close button + the selected call's args and
// result rendered raw. The three-段 Switch / Prev-Next stepping / See-in-
// trajectory are deferred (ledger). Subscribes to the per-scope selection and
// derives the call material from the session snapshot — no data of its own.
// trajectory are deferred (ledger). Reads the selection from the shared chat
// store (conversation writes, this panel reads — the cross-registration
// share the store seat exists for) and derives the call material from the
// session snapshot — no data of its own.
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { shallowEqual } from '@deepseek-ai/dsh-client-web-react'
import type { DetailsSlotProps } from '../contract/slots.ts'
import css from './DetailsPanel.module.css'
/** Full props composed by reference from the contract (owner & standard & injected shares). */
/** Full props composed by reference from the contract (automatic shares & injected share). */
export type DetailsPanelProps = DetailsSlotProps
/** Selected call material: resolved result node, or the in-flight running call's args. */
@@ -41,13 +43,13 @@ function pretty(raw: string): string {
}
}
export function DetailsPanel({ useSession, useSelection, actions }: DetailsPanelProps) {
const selection = useSelection(s => s)
export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPanelProps) {
const selection = useStore(s => s.selection)
const callId = selection?.callId
// materialFor builds a fresh wrapper; shallowEqual short-circuits on its
// stable members (result node reference rides the snapshot's structural sharing).
const material = useSession(
s => (callId === undefined ? null : materialFor(s as ConversationSnapshot, callId)),
s => (callId === undefined ? null : materialFor(s, callId)),
(a, b) => shallowEqual(a, b))
return (
@@ -58,7 +60,7 @@ export function DetailsPanel({ useSession, useSelection, actions }: DetailsPanel
</div>
<button
type="button" className={css.close} aria-label="关闭详情"
onClick={() => { actions.closeDetails() }}
onClick={() => { closeDetails() }}
>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />

View File

@@ -1,12 +1,14 @@
// EmptyState (figma NEW SESSION screen): centered hero card built around the
// SAME InputBar component the resident composer uses (the empty→content
// transition is one component changing position, never a swap). Project
// picker: cwd set derived from sessions.list plus a free-form new-directory
// input; submit runs the startSession chain (create → open → send) in one
// service call.
// picker: cwd set derived in-component from the standard useSessions hook
// (subscription is the framework's, derivation is a pure function — design
// §6) plus a free-form new-directory input; submit runs the startSession
// chain (create → open → send) in one service call.
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { EmptyStateSlotProps } from '../contract/slots.ts'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
@@ -15,11 +17,22 @@ import css from './EmptyState.module.css'
/** Select sentinel for the free-form directory entry (impossible as a real path: not absolute). */
const NEW_DIR = '::new-directory'
/** Full props composed by reference from the contract (owner & injected shares; root slot has no standard share). */
/** Full props composed by reference from the contract (runtime share & injected share; no store). */
export type EmptyStateProps = EmptyStateSlotProps
export function EmptyState({ useCwds, actions }: EmptyStateProps) {
const cwds = useCwds(s => s)
/** Deduped cwd set in list order (pure derivation over the sessions list). */
function deriveCwds(state: SessionListState): readonly string[] {
const seen = new Set<string>()
for (const id of state.ids) {
const cwd = state.byId[id]?.cwd
if (cwd !== undefined && cwd !== '') seen.add(cwd)
}
return [...seen]
}
export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
const list = useSessions(s => s)
const cwds = useMemo(() => deriveCwds(list), [list])
// Local viewing state: the empty state owns no session, so its draft is
// ephemeral by design (drafts are keyed by session id; there is none yet).
const [draft, setDraft] = useState('')
@@ -35,14 +48,14 @@ export function EmptyState({ useCwds, actions }: EmptyStateProps) {
setSending(true)
setError(null)
const chosen = cwd.trim()
actions.startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) })
startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) })
.catch((reason: unknown) => {
// The empty state survives failure with the draft intact (no session
// exists to carry promptError; this is the only local error surface).
setError({ op: 'send', message: reason instanceof Error ? reason.message : String(reason) })
setSending(false)
})
// Success needs no cleanup: layout.open swaps this slot out for the session body.
// Success needs no cleanup: the session selection swaps this slot out for the session body.
}
const picker = (

View File

@@ -0,0 +1,52 @@
/**
* Chat store factory (slot terminal design §4): selection + draft + active
* view for one session, shared by the conversation and details registrations
* (apply constructs one handle and passes it to both). Session-scope
* derivation: both mount slots are scope=session, so the framework creates
* one instance per session; the persist key is scope-suffixed by the
* framework, aligning with the previous per-session draft persistence.
*
* Module exports the factory only — a module-level handle would pin identity
* in the module cache (a de-facto singleton surviving plugin reloads).
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatStoreState, SelectionTarget } from './contract/views.ts'
/**
* Annotation twin of the actions literal below (the export needs a declared
* return type); drift fails assignability at the defineStore call.
*/
type ChatActions = {
select: (draft: ChatStoreState, target: SelectionTarget | null) => void
setDraft: (draft: ChatStoreState, text: string) => void
clearDraft: (draft: ChatStoreState) => void
restoreDraft: (draft: ChatStoreState, text: string) => void
setView: (draft: ChatStoreState, view: string) => void
}
/**
* Declare the per-session chat store. `selection` is the details-linkage
* channel (conversation writes, details reads); `draft` is the composer text
* (persisted so it survives session switches and reloads); `view` is the
* active conversation view id (a 'conversation.view' entry id — store seat is
* the cross-remount survival channel, null falls back to the first view).
* @returns the store handle (spec + identity + factory in one value).
*/
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
return defineStore({
// Anchored to the contract shape: consumers read the store through
// PropsStore<ChatStore>'s SnapshotSelectorHook<ChatStoreState>, so init
// and the contract cannot drift.
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
persist: 'dsh.conversation.chat',
actions: {
select: (d, target: SelectionTarget | null) => { d.selection = target },
setDraft: (d, text: string) => { d.draft = text },
clearDraft: (d) => { d.draft = '' },
// Optimistic-send failure restore: only when the user typed nothing new
// since the clear (send choreography lives in the inject factory).
restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
setView: (d, view: string) => { d.view = view },
},
})
}

View File

@@ -1,20 +1,32 @@
// Bash toolview sample, written in third-party posture: everything below uses
// only the public registration surface (ctx.toolviews.register + ToolViewProps)
// — the differential-rendering acceptance proof for the registry chain.
// Two registrations: a global bash row, and a scope-filtered variant that
// takes over for matching sessions only (later registration wins its tier).
// only the public slot surface (ctx.slots.register into the keyed
// 'conversation.chat.toolview' hole + ToolRowProps) — the acceptance proof
// that a plain plugin can take over a tool row with zero dedicated machinery.
// Session-dimension differentiation happens INSIDE the component (the
// canonical sub-agent scenario): rows in child sessions render the scoped
// variant, derived from the standard useSessions kit — no registry predicates.
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolViewProps } from '../contract/toolview.ts'
import type { ToolViewRegistry } from './registry.ts'
import { toolRowModel, type ToolCallBlock } from '../contract/tool-call-model.ts'
import type { Context } from 'cordis'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import css from './bash-sample.module.css'
/** Global bash row: command-first monospace summary (replaces the generic row). */
export function BashRow({ toolName, block, actions }: ToolViewProps) {
const model = toolRowModel(toolName, block as ToolCallBlock)
/** Bash row: command-first monospace summary replacing the generic card.
* Sub-session rows (parentId present) swap the prompt for a scoped badge —
* the differential stays observable per session from one registration. */
export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
if (isChild) {
return (
<div className={css.row} data-sample="bash-scoped" onClick={openDetails}>
<span className={css.scopeBadge}>scoped</span>
<span className={css.command}>{model.summary}</span>
</div>
)
}
return (
<div className={css.row} data-sample="bash-global" onClick={actions.openDetails}>
<div className={css.row} data-sample="bash-global" onClick={openDetails}>
<span className={css.prompt} aria-hidden>$</span>
<span className={css.command}>{model.summary}</span>
{model.state === 'error' && <span className={css.err}>failed</span>}
@@ -22,31 +34,20 @@ export function BashRow({ toolName, block, actions }: ToolViewProps) {
)
}
/** Scoped variant: visually distinct so the differential hit is observable. */
export function ScopedBashRow({ toolName, block, actions }: ToolViewProps) {
const model = toolRowModel(toolName, block as ToolCallBlock)
return (
<div className={css.row} data-sample="bash-scoped" onClick={actions.openDetails}>
<span className={css.scopeBadge}>scoped</span>
<span className={css.command}>{model.summary}</span>
</div>
)
}
/**
* Register both sample rows.
* @param toolviews - the conversation plugin's registry service.
* @param scope - session filter for the scoped variant.
* @returns disposer removing both registrations.
* The sample as a plain registrant plugin. `inject` carries the load-order
* seam: requiring the conversation service guarantees the chat entry (and
* with it the 'conversation.chat.toolview' declaration) is registered —
* ui-conversation's apply mounts the service after the chat entry.
*/
export function registerBashSamples(
toolviews: ToolViewRegistry,
scope: (sessionId: SessionId) => boolean,
): () => void {
const offGlobal = toolviews.register('bash', BashRow)
const offScoped = toolviews.register('bash', ScopedBashRow, { scope })
return () => {
offGlobal()
offScoped()
}
export const bashToolviewSample = {
name: 'bash-toolview-sample',
inject: ['slots', 'conversation'],
/**
* Register the bash row into the chat view's keyed toolview hole.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash' }, BashRow)
},
}

View File

@@ -1,103 +0,0 @@
/**
* ToolViewRegistry: named per-tool component registry, session-scope aware
* (api-contracts v3 section 7). Consumed by chat now, trajectory/waterfall
* later — deliberately a named service, not a SlotMap key. The tool key set
* is deliberately open (model-side tools arrive at runtime): the strong
* typing lives inside the Entry — `I` is inferred from the inject factory at
* the register site and proves component props ⊇ ToolViewProps & I.
*/
import type { FC } from 'react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ResolvedToolView, ToolViewOptions, ToolViewProps } from '../contract/toolview.ts'
/** Stored registration: the per-registration inject parameter is erased
* (storage-erase/read-restore is the typed-Map boundary, one cast budgeted). */
interface Registration extends ToolViewOptions {
component: FC<ToolViewProps & object>
}
/**
* Per-tool renderer registry. Resolution order: scope match (later
* registration wins) > global (same tie-break) > undefined, where the caller
* falls back to GenericToolCard.
*/
export class ToolViewRegistry {
private byTool = new Map<string, Registration[]>()
private version = 0
private listeners = new Set<() => void>()
/**
* Register a tool row renderer. The component must accept the shared
* ToolViewProps plus its own injected share `I` — mismatches (missing keys,
* wrong types, an inject factory that does not produce what the component
* declares) are register-site compile errors.
* @param tool - tool name the renderer takes over.
* @param component - row component over ToolViewProps & I.
* @param opts - optional session-scope filter and private inject factory.
* @returns disposer removing this registration.
*/
register<I extends object = object>(
tool: string, component: FC<ToolViewProps & I>, opts?: ToolViewOptions<I>): () => void {
const list = this.byTool.get(tool) ?? []
if (list.length === 0) this.byTool.set(tool, list)
// Storage erases I (heterogeneous registrations share one list); resolve
// restores the erased shape on the read face.
const entry: Registration = { component: component as FC<ToolViewProps & object>, ...opts }
list.push(entry)
this.bump()
let disposed = false
return () => {
if (disposed) return
disposed = true
const at = list.indexOf(entry)
/* v8 ignore next -- negative arm: an entry lives in one list and only its
own once-guarded disposer removes it, so a live disposer always finds it. */
if (at >= 0) list.splice(at, 1)
if (list.length === 0) this.byTool.delete(tool)
this.bump()
}
}
/**
* Resolve the renderer for a tool in a session.
* @param tool - tool name.
* @param sessionId - session the row renders in (fed to scope filters).
* @returns resolved view, or undefined when nothing matches.
*/
resolve(tool: string, sessionId: SessionId): ResolvedToolView | undefined {
const list = this.byTool.get(tool)
if (list === undefined) return undefined
let global: Registration | undefined
let scoped: Registration | undefined
for (const entry of list) {
if (entry.scope === undefined) global = entry
else if (entry.scope(sessionId)) scoped = entry
}
const hit = scoped ?? global
if (hit === undefined) return undefined
return hit.inject === undefined ? { component: hit.component } : { component: hit.component, inject: hit.inject }
}
/**
* Subscribe to registration changes (render outlets re-resolve on notify).
* @param fn - change listener.
* @returns disposer.
*/
subscribe(fn: () => void): () => void {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
/**
* Monotonic registration version for uSES getSnapshot.
* @returns current version.
*/
getVersion(): number {
return this.version
}
private bump(): void {
this.version += 1
for (const fn of this.listeners) fn()
}
}

View File

@@ -15,11 +15,10 @@ export const name = 'client-ui-conversation-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the conversation service emits no cordis events — its
* view and toolview registries notify through package-local subscribe faces
* whose ordering (synchronous version bump before notification) is exercised
* directly by the behavior specs, and the per-scope store accounts are owned
* mutable state with no cross-plugin observer to contradict.
* No runtime invariant: the conversation service emits no cordis events, and
* both rings this package owns (the 'conversation.view' tab ring and the
* 'conversation.chat.toolview' row hole) ride the slot system, whose ledger
* invariants live with the runtime slots package.
*/
const install: InvariantInstaller = () => {}

View File

@@ -1,26 +1,34 @@
// @vitest-environment jsdom
// apply inject factories exercised end to end: the conversation slot surface
// (ancestry feed, views triple, active view, composer choreography incl.
// optimistic clear + failure restore, renderView chrome assembly, watch-driven
// open), the details surface, and the empty-state surface (cwd derivation
// cache). Complements chat-apply.spec.tsx, which stops at registration.
// apply inject factories exercised end to end against the terminal thin
// shape: the conversation surface (views triple, send choreography incl.
// optimistic clear + failure restore THROUGH the declared store actions,
// openDetails = select action + layout orchestration, sessions.open
// navigation), the injectless-but-closeDetails details surface, and the
// one-callback empty surface. Complements chat-apply.spec.tsx (registration)
// and selection-survival.spec.ts (store axis). History opening is NOT an
// inject concern anymore — the runtime sessions service opens on watch
// (sessions-service.spec.ts owns that behavior).
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { createElement } from 'react'
import { createSnapshotStore, bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { cleanup } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationService, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
import { ConversationService, apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { createChatStore } from '../src/client/stores.ts'
afterEach(cleanup)
const ROOT = 'root-1' as SessionId
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
type ChatActions = ChatInstance['actions']
const SCOPE_TAG: symbol = (() => {
const recorded: (string | symbol)[] = []
const spy = new Proxy(new Context(), {
@@ -35,28 +43,18 @@ const SCOPE_TAG: symbol = (() => {
return symbol
})()
function snapshotBase(): ConversationSnapshot {
return {
sessionId: ROOT, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
}
async function bench() {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
const listStore = createSnapshotStore<SessionListState>({
ids: [ROOT],
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
})
const snap = snapshotBase()
current: ROOT,
} as SessionListState)
const sessionFake = {
getSnapshot: () => snap,
subscribe: () => () => {},
useSelector: undefined as unknown,
open: vi.fn(() => Promise.resolve()),
loadOlder: vi.fn(() => Promise.resolve()),
prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
@@ -64,7 +62,6 @@ async function bench() {
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
}
sessionFake.useSelector = bindSnapshotSelector(sessionFake as never)
const scopes = new Map<SessionId, Context>()
const mint = (id: SessionId): Context => {
let scoped = scopes.get(id)
@@ -77,200 +74,189 @@ async function bench() {
const sessionsFake = {
list: listStore,
manager: { get: () => sessionFake },
ancestry: (id: SessionId) => {
const s = listStore.getSnapshot().byId[id]
return s === undefined ? [] : [s]
},
scope: (id: SessionId) => mint(id),
cell: () => undefined,
create: vi.fn(() => Promise.resolve(ROOT)),
open: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
const layoutFake = {
current: createSnapshotStore<{ sessionId?: SessionId; viewFor: Record<string, string> }>({ viewFor: {} }),
open: vi.fn(), openView: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(),
}
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
ctx.provide('layout', layoutFake)
ctx.provide('i18n', { bind: () => (key: string) => key })
const slots = ctx.get('slots') as SlotsService
slots.define('conversation', { kind: 'single', scope: 'session' })
slots.define('details', { kind: 'single', scope: 'session' })
slots.define('conversation.empty', { kind: 'single', scope: 'root' })
// The AppFrame role: the three conversation-package slots must be declared
// by a live entry before apply can contribute into them (the stand-in
// consumes renderSlot to satisfy the declare-means-render check).
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, (_p: { renderSlot?: unknown }) => null)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const binding: SessionBinding = {
sessionId: ROOT as never,
session: { useSelector: sessionFake.useSelector } as never,
ctx: mint(ROOT) as never,
// Reach the render-side entry view (inject + store handle) the way the
// renderer does: through the host face.
let host: SlotRendererHost | undefined
slots.install({ renderRoot: (h) => { host = h; return null } })
slots.renderSlot('root', {})
const hostFace = host!
const entryOf = (key: 'conversation' | 'conversation.view' | 'details' | 'conversation.empty') => hostFace.entriesOf(key)[0]!
/** Resolve store instance + call the inject the way the outlet would. */
const conversationSurface = (id: SessionId) => {
const entry = entryOf('conversation')
const instance = hostFace.storeOf(entry, id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationInjected)(
id, instance.actions)
return { instance, injected }
}
const entryOf = (key: 'conversation' | 'details' | 'conversation.empty') => {
const entries = slots.entries(key)
return entries[0]! as { options: { inject: (b: unknown) => Record<string, unknown> } }
/** Same resolution for the chat entry riding the view ring. */
const chatViewSurface = (id: SessionId) => {
const entry = entryOf('conversation.view')
const instance = hostFace.storeOf(entry, id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)(
id, instance.actions)
return { instance, injected }
}
return { ctx, slots, binding, sessionFake, sessionsFake, layoutFake, mint, entryOf }
return { ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, sessionFake, sessionsFake, layoutFake, mint }
}
describe('conversation slot inject surface', () => {
it('assembles the full surface and pulls history through the watch signal', async () => {
it('assembles the thin surface side-effect-free, navigates via sessions.open', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {
useAncestry: () => readonly { id: SessionId }[]
views: { list(): readonly ViewEntry[]; version(): number; subscribe(fn: () => void): () => void }
useActiveView: () => string | undefined
composer: { useDraft: () => string; setDraft(t: string): void; send(m: string): void; stop(): void }
actions: { openView(v: string): void; open(id: SessionId): void }
renderView: (entry: ViewEntry) => unknown
}
expect(b.sessionFake.open).toHaveBeenCalledTimes(1)
const { injected } = b.conversationSurface(ROOT)
// Assembly has no session side effects: opening the event window belongs
// to the runtime watch path, not the inject factory.
expect(b.sessionFake.open).not.toHaveBeenCalled()
expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
injected.actions.openView('chat')
expect(b.layoutFake.openView).toHaveBeenCalledWith(ROOT, 'chat')
injected.actions.open(ROOT)
expect(b.layoutFake.open).toHaveBeenCalledWith(ROOT)
injected.open(ROOT)
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
// loadOlder moved to the chat view entry's face (the ring rider).
const chatView = b.chatViewSurface(ROOT)
chatView.injected.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
})
it('composer send trims, optimistically clears, and restores on failure; stop swallows rejection', async () => {
it('send trims, optimistically clears through actions, restores on failure without clobbering new typing', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {
composer: { setDraft(t: string): void; send(m: 'queue'): void; stop(): void }
}
const scoped = b.mint(ROOT).get('conversation') as ConversationService
// Whitespace-only draft: no send.
scoped.drafts.set(' ')
injected.composer.send('queue')
const { instance, injected } = b.conversationSurface(ROOT)
// Whitespace-only: no send, and the (whitespace) draft is not cleared.
instance.actions.setDraft(' ')
injected.send(' ', 'queue')
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
expect(instance.store.getSnapshot().draft).toBe(' ')
// Success: cleared and stays cleared.
injected.composer.setDraft('hello')
injected.composer.send('queue')
expect(scoped.drafts.getSnapshot()).toBe('')
instance.actions.setDraft('hello')
injected.send('hello', 'queue')
expect(instance.store.getSnapshot().draft).toBe('')
await Promise.resolve()
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
// Failure: restored (draft still empty when the rejection lands).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
injected.composer.setDraft('retry me')
injected.composer.send('queue')
instance.actions.setDraft('retry me')
injected.send('retry me', 'queue')
await vi.waitFor(() => {
expect(scoped.drafts.getSnapshot()).toBe('retry me')
expect(instance.store.getSnapshot().draft).toBe('retry me')
})
// Failure with new typing: no clobber.
// Failure landing after new typing: no clobber (restoreDraft fills empty only).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
injected.composer.send('queue')
injected.composer.setDraft('typed during flight')
injected.send('retry me', 'queue')
instance.actions.setDraft('typed during flight')
await new Promise(r => setTimeout(r, 0))
expect(scoped.drafts.getSnapshot()).toBe('typed during flight')
expect(instance.store.getSnapshot().draft).toBe('typed during flight')
// Stop failure is swallowed (promptError owns the surface).
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
injected.composer.stop()
injected.stop()
await new Promise(r => setTimeout(r, 0))
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
})
it('view actions forward: openDetails writes selection through the scoped service, loadOlder hits the session', async () => {
it('inject fails loud when the session resolves no scope or the scope lacks the service', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {
// viewProps rides renderView's closure; reach the actions through a rendered entry.
renderView: (entry: ViewEntry) => React.ReactNode
}
let captured: { openDetails(t: { turnSeq: number; callId?: string }): void; loadOlder(): void } | undefined
const Probe = (p: { actions: typeof captured }) => {
captured = p.actions
return null
}
render(createElement('div', null, injected.renderView({
id: 'chat', label: 'Chat', component: Probe,
} as unknown as ViewEntry)))
captured!.openDetails({ turnSeq: 2, callId: 'c1' })
const entry = b.entryOf('conversation')
const instance = b.hostFace.storeOf(entry, ROOT) as ChatInstance
const injectFn = entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationInjected
// Unknown session: sessions.scope answers nothing.
;(b.sessionsFake.scope as unknown) = () => undefined
expect(() => injectFn(ROOT, instance.actions)).toThrow(/resolved no scope/)
// A scope minted outside the service tree: no conversation service on it.
const foreign = new Context()
;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({})
expect(() => injectFn(ROOT, instance.actions)).toThrow(/unavailable through the session scope/)
})
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
const b = await bench()
const { instance, injected } = b.chatViewSurface(ROOT)
injected.openDetails({ turnSeq: 2, callId: 'c1' })
expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' })
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
const scoped = b.mint(ROOT).get('conversation') as import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
expect(scoped.selection.getSnapshot()).toEqual({ turnSeq: 2, callId: 'c1' })
captured!.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
// The chat view shares the conversation entry's store instance: selection
// writes land where the skeleton and details read.
const conv = b.conversationSurface(ROOT)
expect(conv.instance).toBe(instance)
})
it('renderView mounts chrome header/footer around the view body', async () => {
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {
renderView: (entry: ViewEntry) => React.ReactNode
}
const entry = {
id: 'chat', label: 'Chat',
component: () => createElement('div', { 'data-testid': 'body' }),
chrome: {
header: () => createElement('div', { 'data-testid': 'hd' }),
footer: () => createElement('div', { 'data-testid': 'ft' }),
},
} as unknown as ViewEntry
const view = render(createElement('div', null, injected.renderView(entry)))
expect(view.getByTestId('hd')).toBeTruthy()
expect(view.getByTestId('body')).toBeTruthy()
expect(view.getByTestId('ft')).toBeTruthy()
// Ancestry and draft/active-view hooks execute inside a component tree.
const HookProbe = () => {
const injected2 = b.entryOf('conversation').options.inject(b.binding) as {
useAncestry: () => readonly { displayTitle: string }[]
useActiveView: () => string | undefined
composer: { useDraft: () => string }
}
const chain = injected2.useAncestry()
const active = injected2.useActiveView()
const draft = injected2.composer.useDraft()
return createElement('i', { 'data-testid': 'probe' }, `${chain.length}|${active ?? 'none'}|${draft}`)
}
const probe = render(createElement(HookProbe))
// Draft content carries over from the composer case (per-scope store is
// process-resident); the probe asserts hook wiring, not draft value.
expect(probe.getByTestId('probe').textContent).toMatch(/^1\|none\|/)
// A list-store update while mounted drives the ancestry selector's
// shallowEqual arm (same derived chain → short-circuit, no re-render churn).
await act(async () => {
b.sessionsFake.list.update((d: { byId: Record<string, { updatedAt: number }> }) => {
d.byId[ROOT]!.updatedAt = 2
})
})
expect(probe.getByTestId('probe').textContent).toMatch(/^1\|none\|/)
// The views read-face triple forwards to the service registry.
const injected3 = b.entryOf('conversation').options.inject(b.binding) as {
views: { list(): readonly { id: string }[]; subscribe(fn: () => void): () => void; version(): number }
}
expect(injected3.views.list().map(v => v.id)).toEqual(['chat'])
const beforeVersion = injected3.views.version()
const { injected } = b.conversationSurface(ROOT)
const before = injected.views.version()
const listener = vi.fn()
const unsub = injected3.views.subscribe(listener)
const conversation = b.ctx.get('conversation') as import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
const offExtra = conversation.registerView({ id: 'chat2', label: 'X', component: () => null } as never)
const unsub = injected.views.subscribe(listener)
// A second ring rider (what ui-trajectory does in production).
const off = b.slots.register(
{ name: 'conversation.view', id: 'chat2', order: 5, label: 'X' } as never, (() => null) as never)
await Promise.resolve() // ledger notifications batch per microtask
expect(listener).toHaveBeenCalled()
expect(injected3.views.version()).toBeGreaterThan(beforeVersion)
offExtra()
expect(injected.views.version()).toBeGreaterThan(before)
expect(injected.views.list().map(v => v.id)).toEqual(['chat', 'chat2'])
// Label falls back to the id when a rider declares none.
const off2 = b.slots.register(
{ name: 'conversation.view', id: 'bare', order: 6 } as never, (() => null) as never)
expect(injected.views.list().map(v => v.label)).toEqual(['Chat', 'X', 'bare'])
off()
off2()
unsub()
})
})
describe('details and empty inject surfaces', () => {
it('details surface wires selection and closeDetails', async () => {
it('details injects the one layout callback; selection rides the shared store instead', async () => {
const b = await bench()
const injected = b.entryOf('details').options.inject(b.binding) as {
useSelection: unknown
actions: { closeDetails(): void }
}
expect(injected.useSelection).toBeTypeOf('function')
injected.actions.closeDetails()
const entry = b.entryOf('details')
const injected = (entry.inject as unknown as () => DetailsInjected)()
expect(Object.keys(injected)).toEqual(['closeDetails'])
injected.closeDetails()
expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
// The shared handle: details resolves the SAME instance conversation writes.
const conv = b.hostFace.storeOf(b.entryOf('conversation'), ROOT)
const details = b.hostFace.storeOf(entry, ROOT)
expect(details).toBe(conv)
})
it('empty surface derives the deduped cwd set with a per-state cache and starts sessions', async () => {
it('empty injects the startSession chain only (no store, cwds derive in-component)', async () => {
const b = await bench()
const injected = b.entryOf('conversation.empty').options.inject({ ctx: b.ctx }) as {
useCwds: (sel: (s: readonly string[]) => unknown, eq?: unknown) => unknown
actions: { startSession(opts: { text: string; mode: 'queue' }): Promise<void> }
}
const CwdsProbe = () => {
const cwds = injected.useCwds(s => s) as readonly string[]
return createElement('i', { 'data-testid': 'cwds' }, cwds.join(','))
}
const view = render(createElement(CwdsProbe))
expect(view.getByTestId('cwds').textContent).toBe('/proj')
await injected.actions.startSession({ text: 'go', mode: 'queue' })
const entry = b.entryOf('conversation.empty')
expect(entry.store).toBeUndefined()
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
expect(Object.keys(injected)).toEqual(['startSession'])
await injected.startSession({ text: 'go', mode: 'queue' })
expect(b.sessionsFake.create).toHaveBeenCalled()
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
})
it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => {
const b = await bench()
const injected = (b.entryOf('conversation.empty').inject as unknown as () => EmptyStateInjected)()
// Tear the service's own fiber (registry keyed by the class): the slot
// entries survive, so the gesture-time read hits the loud branch.
b.ctx.registry.delete(ConversationService)
await vi.waitFor(() => { expect(b.ctx.get('conversation')).toBeUndefined() })
expect(() => injected.startSession({ text: 'go', mode: 'queue' })).toThrow(/conversation service unavailable/)
})
})

View File

@@ -1,19 +1,19 @@
// @vitest-environment jsdom
// apply wiring: services provided, chat view + footer chrome registered, the
// three slot registrations land against ui-layout-shaped specs, and the bash
// samples resolve differentially (sub-session default scope). Full-chain
// rendering belongs to the shell e2e; this spec stops at the assembly surface.
// apply wiring: the conversation service provided, the chat view registered
// as the first 'conversation.view' ring entry declaring the keyed toolview
// hole, the three slot registrations land against a root entry's children
// declarations (the AppFrame role), the shared store handle rides all session
// entries, and the bash sample mounts through the load-order seam as a keyed
// entry. Full-chain rendering belongs to the machinery spec
// (chat-toolview-slot.spec.tsx) and the shell e2e; this spec stops at the
// assembly surface.
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls ui-layout's SlotMap declaration merge into this spec's
// program so the slot keys below typecheck in the client lane.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
const ROOT = 'root-1' as SessionId
const CHILD = 'child-1' as SessionId
@@ -29,77 +29,101 @@ async function bench() {
[ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, updatedAt: 1 },
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, updatedAt: 2 },
},
})
current: undefined,
} as SessionListState)
const sessionsFake = {
list: listStore,
manager: { get: vi.fn() },
ancestry: () => [],
scope: () => undefined,
cell: () => undefined,
create: vi.fn(),
open: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
ctx.provide('layout', {
current: createSnapshotStore<{ viewFor: Record<string, string> }>({ viewFor: {} }),
open: vi.fn(), openView: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('i18n', { bind: () => (key: string) => key })
// Specs owned by ui-layout in production; declared here so registrations land.
// Declared by ui-layout's root entry in production; a stand-in root
// occupant declares them here so the contributions land (it consumes
// renderSlot to satisfy the declare-means-render check).
const slots = ctx.get('slots') as SlotsService
slots.define('conversation', { kind: 'single', scope: 'session' })
slots.define('details', { kind: 'single', scope: 'session' })
slots.define('conversation.empty', { kind: 'single', scope: 'root' })
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, (_p: { renderSlot?: unknown }) => null)
const fiber = ctx.plugin({ inject: [...inject], apply })
return { ctx, fiber, slots }
}
/** First stored entry for a key (inject/store live directly on StoredEntry). */
function renderEntryOf(slots: SlotsService, key: 'conversation' | 'conversation.view' | 'details' | 'conversation.empty') {
return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
}
describe('apply wiring', () => {
it('provides conversation and toolviews services', async () => {
it('provides the conversation service', async () => {
const b = await bench()
await b.fiber.await()
expect(b.ctx.get('conversation')).toBeDefined()
expect(b.ctx.get('toolviews')).toBeInstanceOf(ToolViewRegistry)
})
it('registers the chat view with the stats footer', async () => {
it('registers the chat view as the first ring entry, declaring the keyed toolview hole', async () => {
const b = await bench()
await b.fiber.await()
const conversation = b.ctx.get('conversation') as ConversationService
const views = conversation.views()
expect(views.map((v) => v.id)).toEqual(['chat'])
expect(views[0]?.chrome?.footer).toBeDefined()
const entries = b.slots.entries('conversation.view')
expect(entries.map((e) => e.options.id)).toEqual(['chat'])
expect(entries[0]?.options.label).toBe('Chat')
expect(entries[0]?.options.order).toBe(0)
// Declaring is claiming: the chat entry's registration put the hole on
// the ledger with the contract's kind/scope.
expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
})
it('occupies conversation/details/conversation.empty with inject factories', async () => {
it('occupies the three slots + the ring; session entries share one store handle, empty declares none', async () => {
const b = await bench()
await b.fiber.await()
for (const key of ['conversation', 'details', 'conversation.empty'] as const) {
const entries = b.slots.entries(key)
expect(entries, key).toHaveLength(1)
expect((entries[0]!.options as { inject?: unknown }).inject, key).toBeTypeOf('function')
}
const conversation = renderEntryOf(b.slots, 'conversation')
const chatView = renderEntryOf(b.slots, 'conversation.view')
const details = renderEntryOf(b.slots, 'details')
const empty = renderEntryOf(b.slots, 'conversation.empty')
expect(conversation?.inject).toBeTypeOf('function')
expect(chatView?.inject).toBeTypeOf('function')
expect(details?.inject).toBeTypeOf('function')
expect(empty?.inject).toBeTypeOf('function')
// The shared handle: one apply-built store value on ALL session entries.
expect(conversation?.store).toBeDefined()
expect(details?.store).toBe(conversation?.store)
expect(chatView?.store).toBe(conversation?.store)
// The empty slot is storeless (local state + useSessions derivation).
expect(empty?.store).toBeUndefined()
})
it('bash samples resolve differentially: scoped row for sub-sessions, global for roots', async () => {
it('mounts the bash sample as a keyed entry through the load-order seam', async () => {
const b = await bench()
await b.fiber.await()
const toolviews = b.ctx.get('toolviews') as ToolViewRegistry
const forChild = toolviews.resolve('bash', CHILD)
const forRoot = toolviews.resolve('bash', ROOT)
expect(forChild).toBeDefined()
expect(forRoot).toBeDefined()
expect(forChild!.component).not.toBe(forRoot!.component)
// The sample plugin's inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map((e) => e.options.key)).toEqual(['bash'])
})
it('plugin fiber disposal collects every registration (unload cascade)', async () => {
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
const b = await bench()
await b.fiber.await()
await b.fiber.dispose()
expect(b.slots.entries('conversation')).toHaveLength(0)
// The declared ring collapses with its declaring entry, and the chat
// entry's keyed hole (with the sample's registration) collapses with it.
expect(b.slots.entries('conversation.view')).toHaveLength(0)
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
expect(b.slots.entries('details')).toHaveLength(0)
expect(b.slots.entries('conversation.empty')).toHaveLength(0)
expect(b.ctx.get('conversation')).toBeUndefined()
expect(b.ctx.get('toolviews')).toBeUndefined()
})
})

View File

@@ -1,41 +1,20 @@
// @vitest-environment jsdom
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
// ToolViewOutlet inject cache + crash fallback + retry, StatsLine no-cache
// join, PendingCard reason strip, AssistantMarkdown single-line reasoning,
// ChatView view-body fallbacks, and apply's action lambdas.
// StatsLine no-cache join, PendingCard reason strip, AssistantMarkdown
// single-line reasoning. (Tool-row dispatch tails live with the keyed-slot
// machinery specs since the tool ring dissolved into renderSlot.)
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { act } from '@testing-library/react'
import type { SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector, createSessionProvider } from '@deepseek-ai/dsh-client-web-react'
import type { SessionBinding as ReactSessionBinding, UseSession } from '@deepseek-ai/dsh-client-web-react'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolViewProps, Translate } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
import { ToolViewOutlet } from '../src/client/chat/ToolViewOutlet.tsx'
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
const result = (callId: string): ToolResultNode => ({
kind: 'tool-result', seq: 3, callId,
call: { name: 'bash', argsRaw: '{"command":"x"}' },
content: [], isError: false, callView: null, resultView: null,
})
const viewProps = (): ToolViewProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails: vi.fn() },
t: ((k: string) => k) as Translate,
})
describe('MessageItem arms', () => {
it('steering bubbles carry the interjection badge and non-text rest blocks', () => {
const view = render(
@@ -85,72 +64,8 @@ describe('small branch tails', () => {
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine sessionId={SID} useSession={bindSnapshotSelector(source) as unknown as UseSession} />,
<StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} />,
)
expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy()
})
})
describe('ToolViewOutlet dispatch', () => {
it('caches the inject factory per (registration x binding) and merges its props', () => {
const registry = new ToolViewRegistry()
const inject = vi.fn(() => ({ extra: 'injected' }))
registry.register('bash',
(p: ToolViewProps & { extra: string }) => <div data-testid="row">{p.extra}</div>,
{ inject })
// InjectedRow reads the session binding from context: mount through the
// real SessionProvider so the (factory x binding) cache path executes.
const binding: ReactSessionBinding = {
sessionId: SID,
session: { useSelector: (() => { throw new Error('unused') }) as never },
ctx: {},
}
const Provider = createSessionProvider({
useCurrent: () => SID,
resolveBinding: () => binding,
renderBody: () => (
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />
),
})
const view = render(<Provider />)
expect(view.getByTestId('row').textContent).toBe('injected')
expect(inject).toHaveBeenCalledTimes(1)
// Remount against the SAME binding: cache hit, factory not re-run.
view.unmount()
const second = render(<Provider />)
expect(second.getByTestId('row').textContent).toBe('injected')
expect(inject).toHaveBeenCalledTimes(1)
})
it('a crashing custom row falls back to GenericToolCard and retries on re-registration', () => {
const registry = new ToolViewRegistry()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
// React dev builds re-dispatch boundary-caught errors as window 'error'
// events (invokeGuardedCallback); swallow them so vitest sees the caught path.
const swallow = (e: Event): void => { e.preventDefault() }
window.addEventListener('error', swallow)
try {
const Bomb = () => { throw new Error('row bomb') }
registry.register('bash', Bomb as never)
const view = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
// Crash caught: generic row rendered instead.
expect(view.getByText('Bash')).toBeTruthy()
// A new registration bumps the version; the boundary retries the custom row.
act(() => { registry.register('bash', (() => <div data-testid="fixed" />) as never) })
expect(view.getByTestId('fixed')).toBeTruthy()
} finally {
window.removeEventListener('error', swallow)
consoleError.mockRestore()
}
})
it('registry miss renders the generic row directly', () => {
const registry = new ToolViewRegistry()
const view = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
expect(view.getByText('Bash')).toBeTruthy()
})
})

View File

@@ -1,21 +1,19 @@
// @vitest-environment jsdom
// StatsLine (chrome.footer first consumer): totals derivation + the RFC hard
// acceptance — zero renders during streaming. Bash sample: differential
// registry hits per session, teardown reverts to the generic row.
// StatsLine (rendered inside the chat view body): totals derivation + the RFC
// hard acceptance — zero renders during streaming. Bash sample row: the
// canonical sub-agent differential decided INSIDE the component off the
// standard useSessions kit (no registry predicates — tool ring dissolved).
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode,
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ChromeProps, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { StatsLine, deriveStats } from '../src/client/chat/StatsLine.tsx'
import { BashRow, ScopedBashRow, registerBashSamples } from '../src/client/toolviews/bash-sample.tsx'
import { ToolViewOutlet } from '../src/client/chat/ToolViewOutlet.tsx'
import { childSessionScope } from '../src/client/chat/register.ts'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { StatsLine, deriveStats, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
afterEach(cleanup)
@@ -77,8 +75,8 @@ describe('deriveStats', () => {
})
describe('StatsLine', () => {
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): ChromeProps {
return { sessionId: SID, useSession: bindSnapshotSelector(source) as unknown as UseSession }
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): StatsLineProps {
return { useSession: bindSnapshotSelector(source) }
}
it('renders the joined stats row and hides with zero steps', () => {
@@ -95,7 +93,7 @@ describe('StatsLine', () => {
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
const { set, source } = makeSource({ nodes: [assistant(1, 1)] })
let renders = 0
function Counting(p: ChromeProps) {
function Counting(p: StatsLineProps) {
renders += 1
return <StatsLine {...p} />
}
@@ -109,70 +107,79 @@ describe('StatsLine', () => {
})
})
describe('bash toolview samples', () => {
describe('bash sample row', () => {
const ROOT = 'root-1' as SessionId
const CHILD = 'child-1' as SessionId
const result = (callId: string): ToolResultNode => ({
kind: 'tool-result', seq: 3, callId,
call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
content: [], isError: false, callView: null, resultView: null,
})
const viewProps = (openDetails = vi.fn()): ToolViewProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails },
t: (k) => k,
})
function outlet(registry: ToolViewRegistry, sessionId: SessionId, p = viewProps()) {
return render(
<ToolViewOutlet registry={registry} sessionId={sessionId} toolName="bash" viewProps={p} />,
)
/** Real list-store engine: the family fixture the in-component parentId branch reads. */
function listStore() {
return createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
byId: {
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 },
},
current: undefined,
} as SessionListState)
}
it('differential rendering: scoped row for the matching session, global elsewhere', () => {
const registry = new ToolViewRegistry()
registerBashSamples(registry, (id) => id === ('swarm' as SessionId))
const scoped = outlet(registry, 'swarm' as SessionId)
const rowProps = (sessionId: SessionId, over?: {
store?: ReturnType<typeof listStore>
openDetails?: () => void
}): ToolRowProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
openDetails: over?.openDetails ?? vi.fn(),
sessionId,
useSessions: bindSnapshotSelector(over?.store ?? listStore()),
} as unknown as ToolRowProps)
it('differential rendering: the scoped variant in sub-sessions, global at roots', () => {
const scoped = render(<BashRow {...rowProps(CHILD)} />)
expect(scoped.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
const plain = outlet(registry, SID)
expect(scoped.getByText('scoped')).toBeTruthy()
const plain = render(<BashRow {...rowProps(ROOT)} />)
expect(plain.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
})
it('teardown removes both registrations and falls back to the generic row', () => {
const registry = new ToolViewRegistry()
const off = registerBashSamples(registry, () => true)
const view = outlet(registry, SID)
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
act(() => off())
expect(view.container.querySelector('[data-sample]')).toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
it('a session outside the list renders the global arm (no parent known)', () => {
const view = render(<BashRow {...rowProps('gone' as SessionId)} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
})
it('childSessionScope matches sub-sessions via the injected list read face', () => {
const child = 'child' as SessionId
const root = 'root' as SessionId
const scope = childSessionScope({
getSnapshot: () => ({
ids: [root, child],
byId: {
[root]: { id: root, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 },
[child]: { id: child, title: 'c', displayTitle: 'c', parentId: root, running: false, updatedAt: 0 },
},
}),
it('a live parentId write flips the row to the scoped variant (store subscription)', () => {
const store = listStore()
const orphan = 'late-child' as SessionId
store.update((d) => {
d.ids.push(orphan)
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, updatedAt: 0 }
})
expect(scope(child)).toBe(true)
expect(scope(root)).toBe(false)
expect(scope('gone' as SessionId)).toBe(false)
const view = render(<BashRow {...rowProps(orphan, { store })} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
act(() => {
store.update((d) => { d.byId[orphan]!.parentId = ROOT })
})
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
})
it('sample rows summarize the command and hand clicks to openDetails', () => {
const open = vi.fn()
const p = viewProps(open)
const global = render(<BashRow {...p} />)
expect(global.getByText('Build')).toBeTruthy()
fireEvent.click(global.getByText('Build'))
expect(open).toHaveBeenCalledTimes(1)
const scoped = render(<ScopedBashRow {...p} />)
expect(scoped.getByText('scoped')).toBeTruthy()
it('summarizes the command and hands clicks to openDetails on both arms', () => {
const openGlobal = vi.fn()
const global = render(<BashRow {...rowProps(ROOT, { openDetails: openGlobal })} />)
// Two renders share document.body: query inside each container.
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
expect(globalRow.textContent).toContain('Build')
fireEvent.click(globalRow)
expect(openGlobal).toHaveBeenCalledTimes(1)
const openScoped = vi.fn()
const scoped = render(<BashRow {...rowProps(CHILD, { openDetails: openScoped })} />)
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
expect(scopedRow.textContent).toContain('Build')
fireEvent.click(scopedRow)
expect(openScoped).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,94 @@
// @vitest-environment jsdom
/**
* createChatStore unit account (slot terminal design §4): the declared
* actions write set, persist round-trip through the scope-suffixed key, and
* factory purity (every create() is an independent instance; the factory
* itself holds no singleton state).
*/
import { beforeEach, describe, expect, it } from 'vitest'
import { createChatStore } from '../src/client/stores.ts'
const KEY = 'dsh.conversation.chat'
beforeEach(() => {
localStorage.clear()
})
describe('createChatStore', () => {
it('init shape: empty selection/draft/view', () => {
const store = createChatStore().create()
expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
})
it('actions cover the declared write set', () => {
const store = createChatStore().create()
store.actions.select({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
expect(store.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
store.actions.select(null)
expect(store.store.getSnapshot().selection).toBeNull()
store.actions.setDraft('hello')
expect(store.store.getSnapshot().draft).toBe('hello')
store.actions.clearDraft()
expect(store.store.getSnapshot().draft).toBe('')
store.actions.setView('chat')
expect(store.store.getSnapshot().view).toBe('chat')
})
it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => {
const store = createChatStore().create()
// Rollback path: draft was cleared by send, nothing typed since.
store.actions.restoreDraft('failed text')
expect(store.store.getSnapshot().draft).toBe('failed text')
// The user typed something new before the failure landed: keep theirs.
store.actions.setDraft('newer input')
store.actions.restoreDraft('stale text')
expect(store.store.getSnapshot().draft).toBe('newer input')
})
it('persists per scope key and rehydrates a fresh instance', () => {
const handle = createChatStore()
const s1 = handle.create('sess-1')
s1.actions.setDraft('draft for one')
s1.actions.select({ turnSeq: 1 })
// Scope-suffixed key: each session persists separately.
expect(localStorage.getItem(`${KEY}.sess-1`)).not.toBeNull()
expect(localStorage.getItem(`${KEY}.sess-2`)).toBeNull()
// A rebuilt instance under the same scope key rehydrates the state.
const again = createChatStore().create('sess-1')
expect(again.store.getSnapshot().draft).toBe('draft for one')
expect(again.store.getSnapshot().selection).toEqual({ turnSeq: 1 })
// A sibling scope starts clean.
const other = createChatStore().create('sess-2')
expect(other.store.getSnapshot().draft).toBe('')
})
it('clearPersisted removes the scope entry (session-death cleanup hook)', () => {
const store = createChatStore().create('sess-9')
store.actions.setDraft('doomed')
expect(localStorage.getItem(`${KEY}.sess-9`)).not.toBeNull()
store.clearPersisted()
expect(localStorage.getItem(`${KEY}.sess-9`)).toBeNull()
})
it('every create() is an independent instance; the factory holds no singleton', () => {
const handle = createChatStore()
const a = handle.create()
const b = handle.create()
a.actions.setDraft('only in a')
expect(b.store.getSnapshot().draft).toBe('')
// Two factory calls likewise share no LIVE state (identity is per handle
// VALUE, not per module — the sharing contract lives in the framework's
// handle x scope-key resolution, not in module state). Persistence is the
// one sanctioned cross-instance channel: clear it so this assertion sees
// memory identity, not rehydration (covered by the persist case above).
localStorage.clear()
const c = createChatStore().create()
expect(c.store.getSnapshot().draft).toBe('')
})
})

View File

@@ -4,11 +4,11 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
afterEach(cleanup)
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { classifyTool, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
@@ -28,6 +28,8 @@ describe('tool-call-model', () => {
expect(classifyTool('web_fetch')).toBe('read')
expect(classifyTool('web_search')).toBe('search')
expect(classifyTool('grep')).toBe('search')
expect(classifyTool('write')).toBe('write')
expect(classifyTool('edit')).toBe('edit')
expect(classifyTool('todo_write')).toBe('others')
})
@@ -48,6 +50,8 @@ describe('tool-call-model', () => {
it('keeps summaries single-line and falls back for opaque args', () => {
expect(toolRowModel('bash', running({ argsRaw: '{"command":"a\\nb"}' })).summary).toBe('a')
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/tmp/x.ts"}' })).summary).toBe('/tmp/x.ts')
expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
// Others rows prefix the real tool name into the summary slot (figma-flows
// ruling: static "Tool call" title, name rides the mutable summary).
expect(toolRowModel('x', running({ argsRaw: '{"n":1}' })).summary).toBe('x · {"n":1}')
@@ -114,12 +118,28 @@ describe('ToolRow', () => {
})
})
describe('ThinkRow', () => {
it('expands from either Think or the reasoning summary', () => {
const view = render(
<AssistantMarkdown
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
/>,
)
const row = view.getByRole('button')
fireEvent.click(view.getByText('Inspect the session'))
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/Check persistence/)).toBeTruthy()
fireEvent.click(view.getByText('Think'))
expect(row.getAttribute('aria-expanded')).toBe('false')
})
})
describe('GenericToolCard', () => {
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolViewProps => ({
callId: 'c1', toolName, block,
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails: vi.fn() },
t: (k) => k,
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
callId: 'c1', toolName, block, openDetails: vi.fn(),
})
it('renders the classified variant row from the frozen slice', () => {
@@ -138,10 +158,36 @@ describe('GenericToolCard', () => {
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
})
it('row click reaches actions.openDetails', () => {
it('renders edit with its dedicated title, icon variant, and path summary', () => {
const view = render(
<GenericToolCard {...props('edit', running({
name: 'edit',
argsRaw: '{"file_path":"src/x.ts","old_string":"before","new_string":"after"}',
}))} />,
)
expect(view.getByText('Edit')).toBeTruthy()
expect(view.getByText('src/x.ts')).toBeTruthy()
expect(view.container.querySelector('[data-variant="edit"]')).not.toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
})
it('renders write with its dedicated title, icon variant, and path summary', () => {
const view = render(
<GenericToolCard {...props('write', running({
name: 'write',
argsRaw: '{"file_path":"src/x.ts","content":"hello"}',
}))} />,
)
expect(view.getByText('Write')).toBeTruthy()
expect(view.getByText('src/x.ts')).toBeTruthy()
expect(view.container.querySelector('[data-variant="write"]')).not.toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
})
it('row click reaches openDetails', () => {
const p = props('bash', result())
const view = render(<GenericToolCard {...p} />)
fireEvent.click(view.getByText('List files'))
expect(p.actions.openDetails).toHaveBeenCalledTimes(1)
expect(p.openDetails).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,232 @@
// @vitest-environment jsdom
// The dissolved tool ring's acceptance chain on the REAL machinery stack:
// cordis Context + SlotsService ledger + the web-react renderer + this
// package's own apply — no outlet twins. Proves the keyed
// 'conversation.chat.toolview' hole end to end: registered rows dispatch by
// entryKey (the bash sample lands through its plugin), unregistered tools
// fall back to GenericToolCard at the render site, live registration/unload
// flips rows in place, duplicate keys fail loud, the inject channel feeds
// (sessionId) => I into row components, and a registrant's
// inject: ['slots', 'conversation'] load-order seam suspends on real fiber
// semantics until the service (and with it the hole declaration) is present.
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
const SID = 's1' as SessionId
afterEach(cleanup)
// The chat store persists under its declared key; clear between cases.
beforeEach(() => {
localStorage.clear()
})
const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({
kind: 'tool-result', seq, callId,
call: { name, argsRaw: args },
content: [], isError: false, callView: null, resultView: null,
})
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
}
/** Test-owned AppFrame role: declares the layout-owned children and renders the conversation area under the framework session provider. */
type AppRootProps = PropsRenderSlots<'conversation' | 'details' | 'conversation.empty'>
function AppRoot({ renderSlot, SessionProvider }: AppRootProps) {
return <SessionProvider>{() => renderSlot('conversation', {})}</SessionProvider>
}
/**
* Real-stack bench: SlotsService plugin, renderer installed, sessions/layout
* fakes at the service seams only (external boundaries), the package apply on
* its own fiber, and the test AppFrame occupying 'root'.
*/
async function bench(nodes: ToolResultNode[]) {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
const list = createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', running: false, updatedAt: 1 } },
current: SID,
} as SessionListState)
// Identity-stable cell: the renderer caches hooks per source and inject
// results per cell, both by object identity.
const cell = { sessionId: SID, session }
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
ctx.provide('sessions', {
list,
manager: { get: () => ({ loadOlder: vi.fn() }) },
scope: () => ({ get: () => scoped }),
cell: (id: string) => (id === SID ? cell : undefined),
create: vi.fn(),
open: vi.fn(),
})
ctx.provide('layout', layout)
ctx.provide('i18n', { bind: () => (key: string) => key })
slots.install(createSlotRenderer())
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, AppRoot)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, session, list, layout }
}
/** Render the whole tree through the ctx-level root seam (the shell's own entry). */
function mountApp(slots: SlotsService) {
return render(<>{slots.renderSlot('root', {})}</>)
}
describe('keyed toolview hole through the real machinery', () => {
it('dispatches registered rows by entryKey and unregistered tools to the GenericToolCard fallback', async () => {
const b = await bench([
toolResult(3, 'c1', 'bash'),
toolResult(4, 'c2', 'mystery', '{"n":1}'),
])
const view = mountApp(b.slots)
// bash: the sample plugin's keyed registration took the row (root
// session → global arm, decided inside the component off useSessions).
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('Build')).toBeTruthy()
// mystery: no registration under that key → render-site fallback.
expect(view.getByText('Tool call')).toBeTruthy()
})
it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => {
const b = await bench([toolResult(3, 'c1', 'bash')])
const view = mountApp(b.slots)
view.getByText('Build').click()
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
})
it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => {
const b = await bench([toolResult(3, 'c2', 'mystery', '{"n":1}')])
const view = mountApp(b.slots)
expect(view.getByText('Tool call')).toBeTruthy()
let dispose = (): void => {}
await act(async () => {
dispose = b.slots.register(
{ name: 'conversation.chat.toolview', key: 'mystery' },
() => <div data-testid="mystery-row" />)
})
// Per-key version tick: the row flipped without a remount of the view.
expect(view.getByTestId('mystery-row')).toBeTruthy()
expect(view.queryByText('Tool call')).toBeNull()
await act(async () => { dispose() })
expect(view.queryByTestId('mystery-row')).toBeNull()
expect(view.getByText('Tool call')).toBeTruthy()
})
it('a duplicate key registration fails loud at load', async () => {
const b = await bench([])
// The bash sample already holds the 'bash' key (later-wins retired with
// the ring — the keyed ledger throws instead).
expect(() => b.slots.register(
{ name: 'conversation.chat.toolview', key: 'bash' },
() => null,
)).toThrow(/key "bash"/)
})
it('the inject channel feeds (sessionId) => I into the row component', async () => {
const b = await bench([toolResult(3, 'c3', 'probe', '{"x":1}')])
const poked: string[] = []
b.slots.register({
name: 'conversation.chat.toolview',
key: 'probe',
// Two-way business face: data derived from the session id out, a
// callback closing over it back in — the askuser-pattern inject shape.
inject: (sessionId: SessionId) => ({
mark: `for:${sessionId}`,
poke: () => { poked.push(sessionId) },
}),
}, ({ mark, poke }: ToolRowProps & { mark: string; poke: () => void }) => (
<button data-testid="probe-row" onClick={poke}>{mark}</button>
))
const view = mountApp(b.slots)
const row = view.getByTestId('probe-row')
expect(row.textContent).toBe(`for:${SID}`)
row.click()
expect(poked).toEqual([SID])
})
})
describe('registrant load-order seam', () => {
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
ctx.provide('sessions', {
list: createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined } as SessionListState),
manager: { get: vi.fn() },
scope: () => undefined,
cell: () => undefined,
create: vi.fn(),
open: vi.fn(),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('i18n', { bind: () => (key: string) => key })
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, AppRoot)
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
// semantics hold it — apply must not run while 'conversation' is absent.
// (Plain arrow, not vi.fn: mock functions carry a prototype and trip the
// fiber's isConstructor branch.)
let applyRuns = 0
const registrantApply = (registrantCtx: Context): void => {
applyRuns += 1
registrantCtx.slots.register(
{ name: 'conversation.chat.toolview', key: 'late' }, () => null)
}
const late = ctx.plugin({
name: 'late-registrant',
inject: ['slots', 'conversation'],
apply: registrantApply,
})
await Promise.resolve()
expect(applyRuns).toBe(0)
// Mounting the package resolves the seam: service present ⟹ the chat
// entry (and its hole declaration) is already on the ledger, so the
// suspended registrant lands without an undeclared-slot throw.
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await late.await()
expect(applyRuns).toBe(1)
expect(slots.entries('conversation.chat.toolview').map(e => e.options.key))
.toEqual(expect.arrayContaining(['bash', 'late']))
})
})

View File

@@ -3,20 +3,25 @@
// toolview dispatch and selection handoff — driven through a scripted
// ObservableSnapshot fake, no wire.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode, UserMessageNode,
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConvViewProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatView } from '../src/client/chat/ChatView.tsx'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '../src/client/stores.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
import { deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
// Keyless create() persists under the bare declared key; clear between cases
// so one harness's selection cannot rehydrate into the next.
beforeEach(() => {
localStorage.clear()
})
const SID = 's1' as SessionId
@@ -62,39 +67,41 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, callView: null,
})
function makeHarness(init?: Partial<ConversationSnapshot>) {
const { set, source } = makeSource(init)
const registry = new ToolViewRegistry()
const ChatView = createChatView({ toolviews: registry, t: (k) => k })
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const loadOlder = vi.fn()
const selection = makeSelection()
const props: ConvViewProps = {
sessionId: SID,
useSession: bindSnapshotSelector(source) as unknown as UseSession,
useSelection: bindSnapshotSelector(selection.source),
actions: { openDetails, loadOlder },
slots: { renderSlot: () => null } as never,
}
return { set, registry, ChatView, props, openDetails, loadOlder, setSelection: selection.set }
/** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
return bindSnapshotSelector(store)
}
function makeSelection() {
let sel: SelectionTarget | null = null
const subs = new Set<() => void>()
return {
set(next: SelectionTarget | null) {
sel = next
for (const fn of [...subs]) fn()
},
source: {
getSnapshot: () => sel,
subscribe: (fn: () => void) => {
subs.add(fn)
return () => subs.delete(fn)
},
},
function makeHarness(init?: Partial<ConversationSnapshot>) {
const { set, source } = makeSource(init)
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const loadOlder = vi.fn()
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the PropsStore useStore share).
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
// every tool lands on GenericToolCard); keyed dispatch to registered rows
// is the slot machinery's behavior, covered by its own specs.
const chat = createChatStore().create()
const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot']
// SessionProvider seat arrives with the session-scope child declaration;
// ChatView never invokes it (render-prop pass-through stub).
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
const props: ChatViewSlotProps = {
sessionId: SID,
useSession: bindSnapshotSelector(source),
useSessions: emptySessions(),
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
renderSlot,
SessionProvider: SessionProviderStub,
openDetails,
loadOlder,
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return { set, ChatView, props, openDetails, loadOlder, setSelection }
}
describe('chat-flow derivation', () => {
@@ -179,11 +186,13 @@ describe('ChatView', () => {
const h = makeHarness({
nodes: [user(1, 'q'), assistant(2, 'old'), toolResult(3, 'a')],
})
// Count renderSlot invocations: the memo boundary holds when CallRow does
// not re-render, so the row's renderSlot call count freezes during chunks.
let rowRenders = 0
h.registry.register('bash', () => {
h.props.renderSlot = (((_key: string, _owner: object) => {
rowRenders += 1
return <div data-testid="counting-row" />
})
}) as unknown as ChatViewSlotProps['renderSlot'])
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('counting-row')).toBeTruthy()
const afterMount = rowRenders
@@ -221,21 +230,19 @@ describe('ChatView', () => {
expect(view.getByText('cmd-r1')).toBeTruthy()
})
it('a scoped toolview registration takes over rendering for its session only', () => {
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
h.registry.register('bash', () => <div data-testid="custom-bash" />, { scope: (id) => id === SID })
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('custom-bash')).toBeTruthy()
})
it('unregistering a toolview falls back to the generic row live', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const off = h.registry.register('bash', () => <div data-testid="custom-bash" />)
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('custom-bash')).toBeTruthy()
act(() => off())
expect(view.queryByTestId('custom-bash')).toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
const calls: { key: string; entryKey?: string }[] = []
h.props.renderSlot = (((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
return opts?.fallback ?? null
}) as unknown as ChatViewSlotProps['renderSlot'])
render(<h.ChatView {...h.props} />)
// Keyed dispatch: slot name is the declared hole, entryKey the wire tool
// name, and the fallback (GenericToolCard) renders on an empty ledger.
// (Registered-row takeover and live unload are slot machinery behavior,
// owned by the slot system's own specs.)
expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }])
})
it('prepend compensates scrollTop by the height delta; a trailing user node force-scrolls', () => {

View File

@@ -1,23 +1,21 @@
// @vitest-environment jsdom
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
// PendingCard question arm, bash sample error pill, registry disposer
// idempotence re-entry, register.ts explicit bashSampleScope override, the
// node-half empty apply, and AssistantMarkdown reasoning/unknown block arms.
// PendingCard question arm, bash sample error pill, the node-half empty
// apply, and AssistantMarkdown reasoning/unknown block arms.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationService, Translate, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply as nodeApply } from '../src/index.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
import { registerChat } from '../src/client/chat/register.ts'
afterEach(cleanup)
@@ -67,11 +65,8 @@ describe('tails', () => {
call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
content: [], isError: false, callView: null, resultView: null,
}
const props: ToolViewProps = {
callId: 'c5', toolName: 'todo_write', block: settled,
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails: vi.fn() },
t: ((k: string) => k) as Translate,
const props: ToolRowOwnerProps = {
callId: 'c5', toolName: 'todo_write', block: settled, openDetails: vi.fn(),
}
const view = render(<GenericToolCard {...props} />)
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
@@ -79,49 +74,25 @@ describe('tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('BashRow shows the failed pill on error results', () => {
it('BashRow shows the failed pill on error results (root session arm)', () => {
const errorResult: ToolResultNode = {
kind: 'tool-result', seq: 1, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
content: [], isError: true, callView: null, resultView: null,
}
const props: ToolViewProps = {
callId: 'c1', toolName: 'bash', block: errorResult,
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails: vi.fn() },
t: ((k: string) => k) as Translate,
}
// Root session (no parentId): the global arm renders, error pill visible.
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],
byId: { [sid]: { id: sid, title: 'r', running: false, updatedAt: 0 } },
current: undefined,
} as SessionListState)
const props = {
callId: 'c1', toolName: 'bash', block: errorResult, openDetails: vi.fn(),
sessionId: sid, useSessions: bindSnapshotSelector(list),
} as unknown as ToolRowProps
const view = render(<BashRow {...props} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('failed')).toBeTruthy()
})
it('registry disposer re-entry is a no-op after the entry was already removed', () => {
const registry = new ToolViewRegistry()
const off = registry.register('bash', (() => null) as never)
const v1 = registry.getVersion()
off()
const v2 = registry.getVersion()
off()
expect(registry.getVersion()).toBe(v2)
expect(v2).toBeGreaterThan(v1)
})
it('registerChat registers the chat view with the stats footer and disposes cleanly', () => {
const disposer = vi.fn()
const calls: unknown[] = []
const conversation = {
registerView: (entry: unknown) => {
calls.push(entry)
return disposer
},
} as unknown as ConversationService
const toolviews = new ToolViewRegistry()
const off = registerChat({ conversation, toolviews, t: ((k: string) => k) as Translate })
const entry = calls[0] as { id: string; chrome?: { footer?: unknown } }
expect(entry.id).toBe('chat')
// footer is a memo exotic component (object, not plain function).
expect(entry.chrome?.footer).toBeDefined()
off()
expect(disposer).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,23 +1,21 @@
// @vitest-environment jsdom
// Final branch tails for the coverage gate, post slot-phase-2: apply's need()
// throw + cwd cache hit/empty-cwd skip, AssistantMarkdown non-final reasoning,
// StatsLine usage-less node, ChatView tool-group selected passthrough +
// running-empty guard, DetailsPanel titleless selection, registry disposer
// after a foreign removal emptied the list.
// Final branch tails for the coverage gate, terminal slot form:
// AssistantMarkdown non-final reasoning, StatsLine usage-less node,
// DetailsPanel titleless selection. (The old cwd WeakMap-cache account
// retired with the mechanism — derivation lives in EmptyState now, covered
// by the skeleton specs.)
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { Context } from 'cordis'
import { createSnapshotStore, bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '../src/client/stores.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
afterEach(cleanup)
@@ -31,53 +29,6 @@ function snapshotBase(): ConversationSnapshot {
} as ConversationSnapshot
}
describe('apply need() and cwd cache', () => {
it('apply fails loud when a required service is absent', () => {
// Call apply directly (no fiber machinery): need('sessions') on a bare
// context throws synchronously — the loud-failure branch without the
// fiber runner's internal rejection surface. Mount semantics (inject
// gating) are covered by the full bench in apply-inject.spec.
void inject
const ctx = new Context()
expect(() => { (apply as (c: Context) => void)(ctx) }).toThrow(/sessions service unavailable/)
})
it('cwd derivation caches per list state and skips empty cwd values', async () => {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const listStore = createSnapshotStore<SessionListState>({
ids: [SID, 'x2' as SessionId, 'x3' as SessionId],
byId: {
[SID]: { id: SID, title: 'a', displayTitle: 'a', cwd: '/proj', running: false, updatedAt: 1 },
['x2' as SessionId]: { id: 'x2' as SessionId, title: 'b', displayTitle: 'b', cwd: '', running: false, updatedAt: 1 },
['x3' as SessionId]: { id: 'x3' as SessionId, title: 'c', displayTitle: 'c', running: false, updatedAt: 1 },
},
})
ctx.provide('sessions', { list: listStore, manager: { get: vi.fn() }, ancestry: () => [], scope: () => undefined, create: vi.fn() })
ctx.provide('layout', { current: createSnapshotStore<{ viewFor: Record<string, string> }>({ viewFor: {} }), open: vi.fn(), openView: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('i18n', { bind: () => (k: string) => k })
const slots = ctx.get('slots') as SlotsService
slots.define('conversation', { kind: 'single', scope: 'session' })
slots.define('details', { kind: 'single', scope: 'session' })
slots.define('conversation.empty', { kind: 'single', scope: 'root' })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const entry = slots.entries('conversation.empty')[0]! as unknown as {
options: { inject: (b: unknown) => { useCwds: (sel: (s: readonly string[]) => readonly string[]) => readonly string[] } }
}
const injected = entry.options.inject({ ctx })
const Probe = () => {
const cwds = injected.useCwds(s => s)
const again = injected.useCwds(s => s)
// Cache hit: same state object yields the same derived array reference.
return <i data-testid="cwds">{`${cwds.join(',')}|${String(cwds === again)}`}</i>
}
const view = render(<Probe />)
expect(view.getByTestId('cwds').textContent).toBe('/proj|true')
})
})
describe('render branch tails', () => {
it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => {
const view = render(
@@ -101,7 +52,7 @@ describe('render branch tails', () => {
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine sessionId={SID} useSession={bindSnapshotSelector(source) as unknown as UseSession} />,
<StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
)
expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
})
@@ -114,27 +65,23 @@ describe('render branch tails', () => {
})
it('DetailsPanel title falls to 详情 when the selection has no toolName and no material', () => {
const SEL: SelectionTarget = { turnSeq: 1, callId: 'ghost' }
localStorage.clear()
const snap = snapshotBase()
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snapshotBase(), subscribe: () => () => {} }) as unknown as UseSession}
useSelection={bindSnapshotSelector({ getSnapshot: () => SEL, subscribe: () => () => {} })}
actions={{ closeDetails: vi.fn() }}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSessions={bindSnapshotSelector(emptyList)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
expect(view.getByText('详情')).toBeTruthy()
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
})
it('registry disposer tolerates the list already emptied by a sibling disposer', () => {
const registry = new ToolViewRegistry()
const offA = registry.register('bash', () => null)
const offB = registry.register('bash', () => null)
offA()
offB()
// Both entries gone; a re-register works from a fresh list.
registry.register('bash', () => null)
expect(registry.resolve('bash', SID)).toBeDefined()
})
})

View File

@@ -0,0 +1,26 @@
/**
* Test-local selector-hook binder: the engine carries no hook since the store
* migration (runtime is React-free); the renderer binds in production, specs
* bind here. Delegates to web-react's bindSnapshotSelector SOURCE (same
* with-selector uSES shim as production, so selector-level render economics —
* a top-level snapshot swap with an unchanged slice does NOT re-render — hold
* in Profiler-count specs). Source-relative import: the package dependency
* edge to web-react is gone (store migration §7); tests reach the sibling
* package the same way they reach their own src internals.
*/
import { bindSnapshotSelector } from '../../web-react/src/bind.ts'
/** Minimal observable source (engine stores and scripted fakes both satisfy it). */
export interface HookSource<T> {
getSnapshot(): T
subscribe(fn: () => void): () => void
}
/**
* Bind a selector hook over a snapshot source.
* @param src - the source.
* @returns a SnapshotSelectorHook-shaped hook.
*/
export function hookOf<T>(src: HookSource<T>) {
return bindSnapshotSelector<T>(src)
}

View File

@@ -1,16 +1,19 @@
// @vitest-environment jsdom
/**
* M1a regression pin: the per-scope selection must survive list refreshes.
* Drives the REAL SessionsService + ConversationService chain over the
* programmable wire fake — a late list refresh that upgrades the display
* title (bare id → cwd basename) and a reconnect-driven refreshList+resync
* must neither recreate the session scope nor clear the selection account.
* Selection survival across the store seat (terminal design §4): the chat
* store now carries what the per-scope selection account used to — this pins
* the same behavior contract in the new mechanism. Drives the REAL
* SlotsService store axis with the shared createChatStore handle (the exact
* apply.ts shape: one handle, two session-slot registrations): same session's
* two slots resolve one instance (conversation writes, details reads);
* sessions are isolated; a session's death buries its instance AND its
* persisted draft; a list refresh does not touch instance identity.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { beforeEach, describe, expect, it } from 'vitest'
import { SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { createChatStore } from '../src/client/stores.ts'
// The runtime package's programmable fake lives in its tests; import through
// the src path (same pattern the runtime specs use — test-support material).
@@ -22,15 +25,31 @@ interface Bench {
ctx: Context
api: FakeApiClient
sessions: SessionsService
conversation: ConversationService
slots: SlotsService
chat: ReturnType<typeof createChatStore>
}
function bench(): Bench {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const conversation = new ConversationService(ctx)
return { ctx, api, sessions, conversation }
// Service self-registers as ctx 'slots' (cordis Service constructor).
const slots = new SlotsService(ctx)
const chat = createChatStore()
// The apply.ts shape: one shared handle across both session-slot
// registrations. 'conversation'/'details' must first exist in the ledger —
// register a root occupant declaring them (the AppFrame role; the stand-in
// consumes renderSlot to satisfy the declare-means-render check).
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
},
}, (_p: { renderSlot?: unknown }) => null)
slots.register({ name: 'conversation', store: chat }, () => null)
slots.register({ name: 'details', store: chat }, () => null)
return { ctx, api, sessions, slots, chat }
}
async function flush(): Promise<void> {
@@ -48,8 +67,63 @@ function feed(b: Bench, rows: { id: string; cwd?: string; running?: boolean }[])
}) as never)
}
describe('selection survives list refreshes (M1a)', () => {
it('create → select → display-title-upgrading refresh keeps scope, binding, store and value', async () => {
/** Resolve the store instance the renderer would hand a slot's component for a session. */
function storeFor(b: Bench, slot: 'conversation' | 'details', sessionId: SessionId) {
const host = renderHost(b)
const entry = host.entriesOf(slot)[0]!
return host.storeOf(entry, sessionId)! as ReturnType<ReturnType<typeof createChatStore>['create']>
}
/** The host face is only built at renderSlot time; install a stub renderer once to reach it. */
function renderHost(b: Bench): import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost {
const captured = (b as unknown as { _host?: import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost })
if (captured._host === undefined) {
b.slots.install({
renderRoot: (host) => {
captured._host = host
return null
},
})
b.slots.renderSlot('root', {})
}
return captured._host!
}
beforeEach(() => {
localStorage.clear()
})
describe('selection survives on the store seat', () => {
it('one session, two slots: conversation writes, details reads the SAME instance', async () => {
const b = bench()
feed(b, [{ id: 's1' }])
await b.sessions.manager.refreshList()
await flush()
const conv = storeFor(b, 'conversation', sid('s1'))
const details = storeFor(b, 'details', sid('s1'))
conv.actions.select({ turnSeq: 3, callId: 'c1' })
expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
// Identity, not just value: the shared handle resolves one instance per scope key.
expect(details).toBe(conv)
})
it('sessions are isolated: s2 selection never bleeds into s1', async () => {
const b = bench()
feed(b, [{ id: 's1' }, { id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
const one = storeFor(b, 'conversation', sid('s1'))
const two = storeFor(b, 'conversation', sid('s2'))
expect(two).not.toBe(one)
one.actions.select({ turnSeq: 1, callId: 'a' })
two.actions.select({ turnSeq: 9, callId: 'z' })
expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' })
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
})
it('a display-title-upgrading list refresh keeps instance identity and the selection value', async () => {
const b = bench()
// First-send shape: client-side create inserts the row without cwd (title = bare id).
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s1') }))
@@ -58,11 +132,9 @@ describe('selection survives list refreshes (M1a)', () => {
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 's1' })
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
const binding = b.sessions.binding(id)
expect(binding).toBeDefined()
const scoped = b.sessions.scope(id)!
const store = (scoped.get('conversation') as ConversationService).selection
store.set({ turnSeq: 3, callId: 'c1' })
const store = storeFor(b, 'conversation', id)
store.actions.select({ turnSeq: 3, callId: 'c1' })
store.actions.setDraft('half-typed')
// The late list refresh lands (host knows the cwd → better fallback label).
feed(b, [{ id: 's1', cwd: '/w/proj-a' }])
@@ -71,53 +143,40 @@ describe('selection survives list refreshes (M1a)', () => {
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 'proj-a' })
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
// Scope, binding and the selection account must all be identity-stable.
expect(b.sessions.scope(id)).toBe(scoped)
expect(b.sessions.binding(id)).toBe(binding)
const after = (b.sessions.scope(id)!.get('conversation') as ConversationService).selection
const after = storeFor(b, 'conversation', id)
expect(after).toBe(store)
expect(after.getSnapshot()).toEqual({ turnSeq: 3, callId: 'c1' })
expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
expect(after.store.getSnapshot().draft).toBe('half-typed')
})
it('reconnect (handleConnected: refreshList + resync) keeps the selection account', async () => {
it('session death buries the instance and its persisted draft', async () => {
const b = bench()
feed(b, [{ id: 's1' }])
feed(b, [{ id: 's1' }, { id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
const scoped = b.sessions.scope(sid('s1'))!
const store = (scoped.get('conversation') as ConversationService).selection
store.set({ turnSeq: 1, callId: 'c9' })
// Mint the scope (store prune rides the scope-teardown axis: no scope,
// no teardown — the real page always resolves the binding to render).
b.sessions.binding(sid('s1'))
const doomed = storeFor(b, 'conversation', sid('s1'))
doomed.actions.setDraft('to be buried')
doomed.actions.select({ turnSeq: 1 })
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
// Reconnect generation: display-title fallback upgrade arrives with the re-pull.
feed(b, [{ id: 's1', cwd: '/w/proj-a', running: true }])
b.sessions.manager.handleConnected()
await flush()
await flush()
expect(b.sessions.scope(sid('s1'))).toBe(scoped)
const after = (b.sessions.scope(sid('s1'))!.get('conversation') as ConversationService).selection
expect(after).toBe(store)
expect(after.getSnapshot()).toEqual({ turnSeq: 1, callId: 'c9' })
})
it('a transiently failing list refresh does not prune live scopes', async () => {
const b = bench()
feed(b, [{ id: 's1' }])
// Watch elsewhere so s1's scope teardown is not deferred, then remove it.
b.sessions.binding(sid('s2'))
feed(b, [{ id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
const scoped = b.sessions.scope(sid('s1'))!
const store = (scoped.get('conversation') as ConversationService).selection
store.set({ turnSeq: 2, callId: 'c2' })
// Wire hiccup: the reconnect-time list RPC throws (transport error).
b.api.onList = () => Promise.reject(new Error('boom'))
b.sessions.manager.handleConnected()
// Persisted residue is gone with the session...
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
// ...and a re-created same-id session starts from a FRESH instance.
feed(b, [{ id: 's1' }, { id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
await flush()
expect(b.sessions.scope(sid('s1'))).toBe(scoped)
expect((b.sessions.scope(sid('s1'))!.get('conversation') as ConversationService).selection.getSnapshot())
.toEqual({ turnSeq: 2, callId: 'c2' })
const reborn = storeFor(b, 'conversation', sid('s1'))
expect(reborn).not.toBe(doomed)
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
})
})

View File

@@ -1,9 +1,11 @@
// @vitest-environment jsdom
/**
* ConversationService orchestration half: scope-addressed send/cancel (result
* folding, root throw), openDetails choreography, the startSession chain, and
* the service-unavailable loud failures. Store semantics live in
* service-stores.spec.ts.
* ConversationService orchestration half after the store-seat slimming:
* scope-addressed send/cancel (result folding, root throw), the startSession
* chain (create → sessions.open → scoped send), and the service-unavailable
* loud failures. Selection/draft state left this service for the declared
* chat store (chat-store.spec.ts / selection-survival.spec.ts); the view
* registry left for the 'conversation.view' slot (views-type-chain.spec.tsx).
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
@@ -13,7 +15,7 @@ import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/cli
const sid = (s: string): SessionId => s as SessionId
/** Recover the module-private scope tag through the public seam (same probe as service-stores.spec). */
/** Recover the module-private scope tag through the public seam (same probe as apply-inject.spec). */
const SCOPE_TAG: symbol = (() => {
const recorded: (string | symbol)[] = []
const spy = new Proxy(new Context(), {
@@ -33,7 +35,7 @@ interface SessionDouble {
cancel: ReturnType<typeof vi.fn>
}
async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
async function bench(opts?: { sessions?: boolean }) {
const ctx = new Context()
const sessionDoubles = new Map<SessionId, SessionDouble>()
const scopes = new Map<SessionId, Context>()
@@ -47,6 +49,7 @@ async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
return scoped
}
const createMock = vi.fn(() => Promise.resolve(sid('new-1')))
const openMock = vi.fn()
const sessionsFake = {
manager: {
get: (id: SessionId) => {
@@ -62,16 +65,16 @@ async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
},
},
create: createMock,
open: openMock,
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
} as unknown as SessionsService
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
const layoutFake = { open: vi.fn(), openDetails: vi.fn() }
if (opts?.layout !== false) ctx.provide('layout', layoutFake)
const fiber = ctx.plugin((pluginCtx) => { void new ConversationService(pluginCtx) })
// Class-plugin mount — the same form apply.ts uses in production.
const fiber = ctx.plugin(ConversationService)
await fiber.await()
const svc = ctx.get('conversation') as ConversationService
const scopedSvc = (id: SessionId) => mint(id).get('conversation') as ConversationService
return { ctx, svc, scopedSvc, mint, sessionDoubles, sessionsFake, createMock, layoutFake }
return { ctx, svc, scopedSvc, mint, sessionDoubles, sessionsFake, createMock, openMock }
}
describe('send / cancel', () => {
@@ -109,22 +112,12 @@ describe('send / cancel', () => {
})
})
describe('openDetails', () => {
it('writes the scoped selection then opens the layout panel', async () => {
const b = await bench()
const s = b.scopedSvc(sid('s1'))
s.openDetails({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
expect(s.selection.getSnapshot()).toEqual({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
})
})
describe('startSession chain', () => {
it('creates, navigates, then sends through the new scope', async () => {
it('creates, navigates through sessions.open, then sends through the new scope', async () => {
const b = await bench()
await b.svc.startSession({ cwd: '/proj', text: 'first', mode: 'queue' })
expect(b.createMock).toHaveBeenCalledWith({ cwd: '/proj' })
expect(b.layoutFake.open).toHaveBeenCalledWith(sid('new-1'))
expect(b.openMock).toHaveBeenCalledWith(sid('new-1'))
expect(b.sessionDoubles.get(sid('new-1'))!.prompt).toHaveBeenCalledWith(
[{ type: 'text', text: 'first' }], 'queue')
})
@@ -148,12 +141,6 @@ describe('service-unavailable loud failures', () => {
await expect(b.svc.startSession({ text: 't', mode: 'queue' })).rejects.toThrow(/sessions service unavailable/)
})
it('throws when layout is missing', async () => {
const b = await bench({ layout: false })
const s = b.scopedSvc(sid('s1'))
expect(() => { s.openDetails({ turnSeq: 1 }) }).toThrow(/layout service unavailable/)
})
it('startSession fails loud when the new scope cannot resolve conversation', async () => {
const b = await bench()
// A scope minted outside the service tree: scoped.get('conversation') finds nothing.
@@ -164,28 +151,3 @@ describe('service-unavailable loud failures', () => {
.rejects.toThrow(/conversation service unavailable through the new scope/)
})
})
describe('views ordering and draft persistence branches', () => {
it('orders by explicit order with undefined treated as zero (both comparator arms)', async () => {
const b = await bench()
const entry = (id: string, order?: number) => ({
id, label: id, component: () => null,
...(order !== undefined ? { order } : {}),
})
b.svc.registerView(entry('z-late', 5) as never)
b.svc.registerView(entry('default-zero') as never)
b.svc.registerView(entry('first', -1) as never)
expect(b.svc.views().map(v => v.id)).toEqual(['first', 'default-zero', 'z-late'])
})
it('draft store round-trips through localStorage and removes the key when emptied', async () => {
const b = await bench()
localStorage.setItem('dsh.conversation.draft.s9', 'restored')
const s = b.scopedSvc(sid('s9'))
expect(s.drafts.getSnapshot()).toBe('restored')
s.drafts.set('typed')
expect(localStorage.getItem('dsh.conversation.draft.s9')).toBe('typed')
s.drafts.set('')
expect(localStorage.getItem('dsh.conversation.draft.s9')).toBeNull()
})
})

View File

@@ -1,176 +0,0 @@
// @vitest-environment jsdom
/**
* ConversationService store half: scope-addressed selection/drafts accounts
* (lazy mint, per-scope isolation, root access throws, scope teardown
* collects), view registry (order, duplicate throw, effect-scoped disposal,
* uSES read face). Send/cancel/startSession orchestration live in
* service-orchestration.spec.ts.
*/
import { Context } from 'cordis'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConvViewProps, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
const sid = (s: string): SessionId => s as SessionId
/**
* The scope tag symbol is module-private to the runtime package; recover it
* through the public seam by recording which symbol scopeOf reads off a
* spying proxy (keeps this bench honest against the real tagging shape
* without dragging the full SessionsService + wire fake in here).
*/
const SCOPE_TAG: symbol = (() => {
const recorded: (string | symbol)[] = []
const spy = new Proxy(new Context(), {
get(target, prop, receiver): unknown {
recorded.push(prop)
return Reflect.get(target, prop, receiver)
},
})
void scopeOf(spy)
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
return symbol
})()
/** Scope bench: real cordis scope fibers tagged like SessionsService.resolve mints them. */
interface Bench {
ctx: Context
svc: ConversationService
mint: (id: SessionId) => Context
dispose: (id: SessionId) => Promise<void>
}
function bench(): Bench {
const ctx = new Context()
const fibers = new Map<SessionId, { fiber: ReturnType<Context['plugin']>; ctx: Context }>()
const mint = (id: SessionId): Context => {
let rec = fibers.get(id)
if (rec === undefined) {
const fiber = ctx.plugin(() => {})
const scoped = fiber.ctx.extend({ [SCOPE_TAG]: id })
rec = { fiber, ctx: scoped }
fibers.set(id, rec)
}
return rec.ctx
}
const dispose = async (id: SessionId): Promise<void> => {
const rec = fibers.get(id)
if (rec !== undefined) {
await rec.fiber.dispose()
fibers.delete(id)
}
}
const sessions = { scope: (id: SessionId) => fibers.get(id)?.ctx } as unknown as SessionsService
ctx.provide('sessions', sessions)
const svc = new ConversationService(ctx)
return { ctx, svc, mint, dispose }
}
/** Scoped service view: ctx.get binds the root singleton to the scoped ctx (scope addressing seam). */
function convo(scoped: Context): ConversationService {
const service = scoped.get('conversation')
if (service === undefined) throw new Error('bench: conversation unavailable')
return service
}
const viewComp = (() => null) as unknown as FC<ConvViewProps>
const entry = (id: string, order?: number): ViewEntry =>
({ id, label: id, component: viewComp, ...(order !== undefined ? { order } : {}) }) as unknown as ViewEntry
beforeEach(() => { localStorage.clear() })
describe('scope addressing of stores', () => {
it('root-context selection/drafts access throws with the addressing hint', () => {
const b = bench()
expect(() => b.svc.selection).toThrow(/requires a session scope/)
expect(() => b.svc.drafts).toThrow(/requires a session scope/)
})
it('mints one store per scope and keeps identity per session', () => {
const b = bench()
const c1 = b.mint(sid('s1'))
const c2 = b.mint(sid('s2'))
const sel1 = convo(c1).selection
const sel2 = convo(c2).selection
expect(sel1).not.toBe(sel2)
expect(convo(c1).selection).toBe(sel1)
sel1.set({ turnSeq: 3 })
expect(sel1.getSnapshot()).toEqual({ turnSeq: 3 })
expect(sel2.getSnapshot()).toBeNull()
})
it('persists drafts keyed by session id and evolves independently', async () => {
const b = bench()
const c1 = b.mint(sid('s1'))
convo(c1).drafts.set('hello')
expect(localStorage.getItem('dsh.conversation.draft.s1')).toBe('hello')
const c2 = b.mint(sid('s2'))
expect(convo(c2).drafts.getSnapshot()).toBe('')
// Re-minting after teardown rehydrates from storage; clearing removes the key.
await b.dispose(sid('s1'))
expect(convo(b.mint(sid('s1'))).drafts.getSnapshot()).toBe('hello')
convo(b.mint(sid('s1'))).drafts.set('')
expect(localStorage.getItem('dsh.conversation.draft.s1')).toBeNull()
})
it('scope fiber disposal collects the store account (fresh store on re-mint)', async () => {
const b = bench()
const c1 = b.mint(sid('s1'))
const sel = convo(c1).selection
sel.set({ turnSeq: 1 })
await b.dispose(sid('s1'))
const again = b.mint(sid('s1'))
const sel2 = convo(again).selection
expect(sel2).not.toBe(sel)
expect(sel2.getSnapshot()).toBeNull()
})
})
describe('view registry', () => {
it('orders by order (ties keep registration sequence) with a stable cache reference', () => {
const b = bench()
b.svc.registerView(entry('chat', 0))
b.svc.registerView(entry('waterfall', 2))
b.svc.registerView(entry('trajectory', 1))
const views = b.svc.views()
expect(views.map(v => v.id)).toEqual(['chat', 'trajectory', 'waterfall'])
expect(b.svc.views()).toBe(views)
})
it('duplicate id throws; disposer removes and bumps the version', () => {
const b = bench()
const fn = vi.fn()
b.svc.subscribeViews(fn)
const off = b.svc.registerView(entry('chat'))
expect(() => b.svc.registerView(entry('chat'))).toThrow(/already registered/)
const v1 = b.svc.viewsVersion()
off()
expect(b.svc.viewsVersion()).toBeGreaterThan(v1)
expect(b.svc.views()).toEqual([])
expect(fn).toHaveBeenCalled()
})
it('unsubscribe stops notifications', () => {
const b = bench()
const fn = vi.fn()
const unsub = b.svc.subscribeViews(fn)
unsub()
b.svc.registerView(entry('chat'))
expect(fn).not.toHaveBeenCalled()
})
it('a registering plugin fiber unloading collects its views (effect cascade)', async () => {
const b = bench()
const fiber = b.ctx.plugin((pluginCtx: Context) => {
convo(pluginCtx).registerView(entry('chat'))
})
await fiber.await()
expect(b.svc.views().map(v => v.id)).toEqual(['chat'])
await fiber.dispose()
expect(b.svc.views()).toEqual([])
})
})

View File

@@ -1,16 +1,22 @@
// @vitest-environment jsdom
// Skeleton branch tails for the coverage gate (complements skeleton.spec.tsx
// acceptance flows): breadcrumb ancestry rendering + error strip in
// ConversationRoot, DetailsPanel non-JSON args / non-text result blocks /
// error-only results, EmptyState failure surface and custom-directory swap.
// acceptance flows), four-share props form: breadcrumb ancestry derivation +
// error strip in ConversationRoot, DetailsPanel non-JSON args / non-text
// result blocks / error-only results over the shared store, EmptyState
// failure surface and custom-directory swap with in-component cwd derivation.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationRoot, DetailsPanel, EmptyState } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { hookOf } from './hook.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { createChatStore } from '../src/client/stores.ts'
import { ConversationRoot, type ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
afterEach(cleanup)
@@ -32,37 +38,54 @@ function sessionSource(over?: Partial<ConversationSnapshot>) {
}
}
const summary = (id: string, title: string): SessionSummary =>
({ id: id as SessionId, title: `durable ${title}`, displayTitle: title, running: false, updatedAt: 1 })
/** Sessions-list stub over a snapshot store (the standard useSessions hook shape). */
function listHook(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) {
const store = createSnapshotStore<SessionListState>({
ids: rows.map(r => r.id as SessionId),
byId: Object.fromEntries(rows.map(r => [r.id, {
id: r.id as SessionId, title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentId: r.parentId as SessionId } : {}),
}])),
current: undefined,
} as SessionListState)
return hookOf(store)
}
describe('ConversationRoot branches', () => {
const chatEntry: ViewEntry = {
id: 'chat', label: 'Chat', component: () => null,
} as unknown as ViewEntry
const chatTab: ViewTab = { id: 'chat', label: 'Chat' }
/** renderSlot stub in the outlet's baked shape (ring key + only filter marker). */
const stubRenderSlot = (() => <div data-testid="view-body" />) as unknown as ConversationRootProps['renderSlot']
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
function rootProps(over?: {
ancestry?: readonly SessionSummary[]
rows?: { id: string; title: string; parentId?: string }[]
snapshot?: Partial<ConversationSnapshot>
}) {
const open = vi.fn()
const chat = createChatStore().create()
const view = render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource(over?.snapshot)) as unknown as UseSession}
useAncestry={() => over?.ancestry ?? []}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
useActiveView={() => undefined}
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
actions={{ openView: vi.fn(), open }}
renderView={() => <div data-testid="view-body" />}
useSession={hookOf(sessionSource(over?.snapshot)) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook(over?.rows ?? [])}
useStore={hookOf(chat)}
actions={chat.actions}
renderSlot={stubRenderSlot}
SessionProvider={SessionProviderStub}
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
send={vi.fn()}
stop={vi.fn()}
open={open}
/>,
)
return { view, open }
return { view, open, chat }
}
it('renders the ancestry breadcrumb with separators and navigates on ancestor click', () => {
it('derives the ancestry breadcrumb from the sessions list and navigates on ancestor click', () => {
const { view, open } = rootProps({
ancestry: [summary('root-1', 'Workspace'), summary('s1', 'Current')],
rows: [{ id: 'root-1', title: 'Workspace' }, { id: 's1', title: 'Current', parentId: 'root-1' }],
})
expect(view.getByText('Workspace')).toBeTruthy()
expect(view.getByText('/')).toBeTruthy()
@@ -73,6 +96,14 @@ describe('ConversationRoot branches', () => {
expect(open).toHaveBeenCalledTimes(1)
})
it('a broken parent link stops the ancestry walk at the known chain', () => {
const { view } = rootProps({
rows: [{ id: 's1', title: 'Orphan', parentId: 'vanished' }],
})
// The walk keeps s1 itself and stops where the parent is unknown.
expect(view.getByText('Orphan')).toBeTruthy()
})
it('falls back to the raw session id without ancestry and counts user turns', () => {
const { view } = rootProps({
snapshot: { nodes: [{ kind: 'user', seq: 1 } as never, { kind: 'assistant', seq: 2 } as never] },
@@ -88,31 +119,41 @@ describe('ConversationRoot branches', () => {
expect(view.getByText(/停止失败haltinternal/)).toBeTruthy()
})
it('an unknown active view id falls back to the first registered view', () => {
it('an unknown stored view id falls back to the first registered view', () => {
const { chat } = rootProps({})
cleanup()
chat.actions.setView('gone')
const view = render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource()) as unknown as UseSession}
useAncestry={() => []}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
useActiveView={() => 'gone' as never}
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
actions={{ openView: vi.fn(), open: vi.fn() }}
renderView={(entry) => <div data-testid={`body-${entry.id}`} />}
useSession={hookOf(sessionSource()) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={hookOf(chat)}
actions={chat.actions}
renderSlot={stubRenderSlot}
SessionProvider={SessionProviderStub}
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
send={vi.fn()}
stop={vi.fn()}
open={vi.fn()}
/>,
)
expect(view.getByTestId('body-chat')).toBeTruthy()
expect(view.getByTestId('view-body')).toBeTruthy()
})
})
describe('DetailsPanel branches', () => {
function panel(selection: SelectionTarget | null, snapshot?: Partial<ConversationSnapshot>) {
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
return render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource(snapshot)) as unknown as UseSession}
useSelection={bindSnapshotSelector({ getSnapshot: () => selection, subscribe: () => () => {} })}
actions={{ closeDetails: vi.fn() }}
useSession={hookOf(sessionSource(snapshot)) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={hookOf(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
}
@@ -139,13 +180,16 @@ describe('DetailsPanel branches', () => {
return () => subs.delete(fn)
},
}
const SEL: SelectionTarget = { turnSeq: 1, callId: 'c9' }
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 1, callId: 'c9' })
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector(source) as unknown as UseSession}
useSelection={bindSnapshotSelector({ getSnapshot: () => SEL, subscribe: () => () => {} })}
actions={{ closeDetails: vi.fn() }}
useSession={hookOf(source) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={hookOf(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
expect(view.getByText(/"a": 1/)).toBeTruthy()
@@ -189,18 +233,10 @@ describe('DetailsPanel branches', () => {
})
describe('EmptyState branches', () => {
// getSnapshot must return a stable reference (uSES contract) — a fresh
// array per call loops the selector forever.
const CWDS: readonly string[] = ['/proj']
const NO_CWDS: readonly string[] = []
it('keeps the draft and surfaces a local error strip when startSession rejects', async () => {
const startSession = vi.fn(() => Promise.reject(new Error('create down')))
const view = render(
<EmptyState
useCwds={bindSnapshotSelector({ getSnapshot: () => CWDS, subscribe: () => () => {} })}
actions={{ startSession }}
/>,
<EmptyState useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])} startSession={startSession} />,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'first task' } })
@@ -212,10 +248,7 @@ describe('EmptyState branches', () => {
it('non-Error rejection reasons stringify into the error strip', async () => {
const startSession = vi.fn(() => Promise.reject('plain-string'))
const view = render(
<EmptyState
useCwds={bindSnapshotSelector({ getSnapshot: () => NO_CWDS, subscribe: () => () => {} })}
actions={{ startSession }}
/>,
<EmptyState useSessions={listHook([])} startSession={startSession} />,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'go' } })
@@ -223,15 +256,20 @@ describe('EmptyState branches', () => {
await waitFor(() => expect(view.getByText(/发送失败plain-string/)).toBeTruthy())
})
it('cwd select picks an option, swaps to free-form on 新目录, and submits the typed path', async () => {
it('cwd derivation skips blank cwds; select picks, swaps to free-form, submits the typed path', async () => {
const startSession = vi.fn(() => Promise.resolve())
const view = render(
<EmptyState
useCwds={bindSnapshotSelector({ getSnapshot: () => CWDS, subscribe: () => () => {} })}
actions={{ startSession }}
useSessions={listHook([
{ id: 'a', title: 'a', cwd: '/proj' },
{ id: 'b', title: 'b' }, // no cwd: filtered from the option set
])}
startSession={startSession}
/>,
)
const select = view.container.querySelector('select')!
expect([...(select as HTMLSelectElement).options].map(o => o.value))
.toEqual(['', '/proj', '::new-directory'])
fireEvent.change(select, { target: { value: '/proj' } })
expect((select as HTMLSelectElement).value).toBe('/proj')
fireEvent.change(select, { target: { value: '::new-directory' } })

View File

@@ -1,25 +1,33 @@
// @vitest-environment jsdom
/**
* Skeleton acceptance: empty-state transition (same InputBar component in
* hero position, startSession submit), ConversationRoot view switching over
* the registry face, DetailsPanel open/close linkage against a layout-shaped
* fake. Components stay framework-free — everything arrives via props here,
* exactly as the inject factories will assemble them.
* Skeleton acceptance over the four-share props form: empty-state transition
* (same InputBar component in hero position, startSession submit, in-component
* cwd derivation), ConversationRoot view switching through the store's view
* field, DetailsPanel selection through the shared store. Components stay
* pure — the framework shares are stubbed (useSession/useSessions), the store
* share is a REAL createChatStore().create() instance (same construction path
* as production), injected callbacks are spies.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import { bindSnapshotSelector, createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import {
ConversationRoot, DetailsPanel, EmptyState,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SelectionTarget, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
// Export discipline: packages/client/AGENTS.md.
import { createChatStore } from '../src/client/stores.ts'
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
const sid = (s: string): SessionId => s as SessionId
afterEach(cleanup)
beforeEach(() => {
localStorage.clear()
})
/** Minimal conversation snapshot slice the skeleton reads. */
interface FakeSnapshot {
@@ -34,17 +42,41 @@ function fakeSession(init: Partial<FakeSnapshot> = {}) {
const store = createSnapshotStore<FakeSnapshot>({
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init,
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
/** Sessions-list stub: the standard useSessions hook over a snapshot store. */
function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) {
const store = createSnapshotStore<SessionListState>({
ids: rows.map(r => sid(r.id)),
byId: Object.fromEntries(rows.map(r => [r.id, {
id: sid(r.id), title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentId: sid(r.parentId) } : {}),
}])),
current: undefined,
} as SessionListState)
return { store, useSessions: bindSnapshotSelector(store) }
}
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</>
describe('EmptyState', () => {
it('submits startSession with the typed text and picked cwd; failure surfaces locally', async () => {
const cwds = createSnapshotStore<readonly string[]>(['/w/app', '/w/lib'])
it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
const { useSessions } = fakeSessions([
{ id: 'a', title: 'a', cwd: '/w/app' },
{ id: 'b', title: 'b', cwd: '/w/lib' },
{ id: 'c', title: 'c', cwd: '/w/app' }, // duplicate cwd dedupes
])
let reject!: (e: Error) => void
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
render(<EmptyState useCwds={cwds.useSelector} actions={{ startSession }} />)
render(<EmptyState useSessions={useSessions} startSession={startSession} />)
fireEvent.change(screen.getByRole('combobox', { name: '项目目录' }), { target: { value: '/w/app' } })
const select = screen.getByRole('combobox', { name: '项目目录' })
expect([...(select as HTMLSelectElement).options].map(o => o.value))
.toEqual(['', '/w/app', '/w/lib', '::new-directory'])
fireEvent.change(select, { target: { value: '/w/app' } })
const box = screen.getByPlaceholderText('Message to run task, plan and build')
fireEvent.change(box, { target: { value: '造一个轮子' } })
fireEvent.keyDown(box, { key: 'Enter' })
@@ -57,8 +89,8 @@ describe('EmptyState', () => {
})
it('new-directory option swaps the select for a free-form input', () => {
const cwds = createSnapshotStore<readonly string[]>([])
render(<EmptyState useCwds={cwds.useSelector} actions={{ startSession: () => Promise.resolve() }} />)
const { useSessions } = fakeSessions([])
render(<EmptyState useSessions={useSessions} startSession={() => Promise.resolve()} />)
fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } })
const custom = screen.getByPlaceholderText(/目录路径/)
fireEvent.change(custom, { target: { value: '/tmp/fresh' } })
@@ -67,90 +99,108 @@ describe('EmptyState', () => {
})
describe('ConversationRoot', () => {
function bench(views: ViewEntry[], active?: string) {
function bench(tabs: ViewTab[], activeView?: string) {
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }] })
const activeStore = createSnapshotStore<string | undefined>(active)
const openView = vi.fn((v: string) => { activeStore.set(v) })
const open = vi.fn()
const drafts = createSnapshotStore<string>('')
const { useSessions } = fakeSessions([
{ id: 'root', title: 'proj' },
{ id: 's1', title: 'child', parentId: 'root' },
])
const chat = createChatStore().create()
if (activeView !== undefined) chat.actions.setView(activeView)
const send = vi.fn()
const stop = vi.fn()
const ancestry: SessionSummary[] = [
{ id: sid('root'), title: 'proj', displayTitle: 'proj', running: false, updatedAt: 1 },
{ id: sid('s1'), title: 'child', displayTitle: 'child', running: false, updatedAt: 1, parentId: sid('root') },
]
const rendered: string[] = []
const open = vi.fn()
// The renderSlot share as the outlet would bake it: renders a marker for
// the ring key carrying the active-id filter (a Mock cannot satisfy the
// generic method type directly — cast once at the prop seam).
const renderSlot = vi.fn((key: string, _owner: object, opts?: { only?: string }) => (
<div data-testid={`view-${opts?.only ?? '(all)'}`} data-slot={key} />
))
const ui = render(
<ConversationRoot
sessionId={sid('s1')}
useSession={useSession}
useAncestry={() => ancestry}
useSessions={useSessions}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot as unknown as ConversationRootProps['renderSlot']}
SessionProvider={SessionProviderStub}
views={{
list: () => views,
list: () => tabs,
subscribe: () => () => {},
version: () => 1,
}}
useActiveView={() => activeStore.useSelector(s => s) as ViewId | undefined}
composer={{
useDraft: () => drafts.useSelector(s => s),
setDraft: (t) => { drafts.set(t) },
send, stop,
}}
actions={{ openView: openView as (v: never) => void, open }}
renderView={(entry) => { rendered.push(entry.id); return <div data-testid={`view-${entry.id}`} /> }}
send={send}
stop={stop}
open={open}
/>)
return { ui, openView, open, rendered, send, drafts }
return { ui, chat, send, stop, open, renderSlot }
}
const comp = (() => null) as unknown as FC<never>
const view = (id: string, label: string): ViewEntry =>
({ id, label, component: comp }) as unknown as ViewEntry
const tab = (id: string, label: string): ViewTab => ({ id, label })
it('renders breadcrumb chain, meta turns, and the active view (default chat)', () => {
const { rendered, open } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
it('renders breadcrumb chain (useSessions-derived), meta turns, and the default chat view', () => {
const { open } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
expect(screen.getByText('proj')).toBeTruthy()
expect(screen.getByText('child')).toBeTruthy()
expect(screen.getByText(/2 turns/)).toBeTruthy()
expect(rendered).toEqual(['chat'])
expect(screen.getByTestId('view-chat')).toBeTruthy()
// Ancestor crumb navigates; current crumb is disabled.
fireEvent.click(screen.getByRole('button', { name: 'proj' }))
expect(open).toHaveBeenCalledWith('root')
expect((screen.getByRole('button', { name: 'child' }) as HTMLButtonElement).disabled).toBe(true)
})
it('switches views through actions.openView and re-renders the new body', () => {
const { openView } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
it('switches views through the store view field and falls back on unknown ids', () => {
const { chat } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(openView).toHaveBeenCalledWith('trajectory')
expect(chat.store.getSnapshot().view).toBe('trajectory')
expect(screen.getByTestId('view-trajectory')).toBeTruthy()
cleanup()
// A stale persisted id (its view plugin unloaded) falls to the first view.
bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')], 'ghost-view')
expect(screen.getByTestId('view-chat')).toBeTruthy()
})
it('hides the tab strip with a single view and wires the composer send', () => {
const { send } = bench([view('chat', 'Chat')])
it('renders the active view through the declared ring slot with the only filter', () => {
const { renderSlot } = bench([tab('chat', 'Chat')])
// No owner share: views take everything from the standard kit (contract).
expect(renderSlot).toHaveBeenCalledWith('conversation.view', {}, { only: 'chat' })
expect(screen.getByTestId('view-chat').getAttribute('data-slot')).toBe('conversation.view')
})
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
const { chat, send } = bench([tab('chat', 'Chat')])
expect(screen.queryByRole('tablist')).toBeNull()
const box = screen.getByPlaceholderText(/输入消息/)
fireEvent.change(box, { target: { value: 'hi' } })
// Typing goes through actions.setDraft into the shared store.
expect(chat.store.getSnapshot().draft).toBe('hi')
fireEvent.keyDown(box, { key: 'Enter' })
expect(send).toHaveBeenCalledWith('queue')
expect(send).toHaveBeenCalledWith('hi', 'queue')
})
})
describe('DetailsPanel', () => {
function benchDetails(snapshot: Partial<FakeSnapshot>, selection: SelectionTarget | null) {
const { useSession } = fakeSession(snapshot)
const selectionStore = createSnapshotStore<SelectionTarget | null>(selection)
const { useSessions } = fakeSessions([])
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const closeDetails = vi.fn()
render(
<DetailsPanel
sessionId={sid('s1')}
useSession={useSession}
useSelection={selectionStore.useSelector}
actions={{ closeDetails }}
useSessions={useSessions}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={closeDetails}
/>)
return { closeDetails, selectionStore }
return { closeDetails, chat }
}
it('renders the selected call args and result; close fires the layout-linked action', () => {
it('renders the selected call args and result off the shared store; close fires the injected callback', () => {
const { closeDetails } = benchDetails({
nodes: [{
kind: 'tool-result', callId: 'c1',

View File

@@ -1,62 +0,0 @@
/**
* Tool-ring Entry typing (design §7): I inferred from the inject factory at
* the register site, component must accept ToolViewProps & I, and the resolve
* read face carries the erased-but-present inject. Compile-time checks via
* @ts-expect-error pairs; the runtime assertions just keep vitest happy.
*/
import { describe, expect, it } from 'vitest'
import type { FC } from 'react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
const sid = (s: string): SessionId => s as SessionId
// Positive control: component's own injected share matches the factory's product.
interface RowInjected { useMyStore: () => number }
const InjectedRowComp: FC<ToolViewProps & RowInjected> = () => null
// Plain rows take the shared props only.
const PlainRowComp: FC<ToolViewProps> = () => null
describe('tool-ring entry typing', () => {
it('register infers I from the inject factory and accepts a matching component', () => {
const reg = new ToolViewRegistry()
const off = reg.register('bash', InjectedRowComp, {
inject: () => ({ useMyStore: () => 1 }),
})
expect(reg.resolve('bash', sid('s'))?.inject).toBeDefined()
off()
})
it('injectless registration needs no options and resolves without inject', () => {
const reg = new ToolViewRegistry()
reg.register('read', PlainRowComp)
expect('inject' in (reg.resolve('read', sid('s')) ?? {})).toBe(false)
})
it('compile-time: factory product must cover the component injected share', () => {
const reg = new ToolViewRegistry()
reg.register('bash', InjectedRowComp, {
// @ts-expect-error the factory misses useMyStore, which the component requires
inject: () => ({ somethingElse: 1 }),
})
expect(true).toBe(true)
})
// Known boundary (not asserted): a component demanding an injected share CAN
// register bare — with I defaulting to `object`, FC<ToolViewProps & RowInjected>
// is structurally assignable to FC<ToolViewProps & object> (parameter
// bivariance over a wider props type). The register-site guarantee holds in
// the direction that matters: WITH an inject factory, its product must cover
// the component's share (previous case). The bare-register gap is the same
// one SlotMap's single-kind register has and is accepted by design §7.
it('compile-time: scope filter receives the branded SessionId', () => {
const reg = new ToolViewRegistry()
reg.register('bash', PlainRowComp, {
// @ts-expect-error number is not assignable to SessionId
scope: (id: number) => id > 0,
})
expect(true).toBe(true)
})
})

View File

@@ -1,101 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
const sid = (s: string) => s as SessionId
const comp = (name: string) => {
const fc = () => null
fc.displayName = name
return fc as unknown as import('react').FC<ToolViewProps>
}
describe('ToolViewRegistry', () => {
it('resolves a global registration for any session', () => {
const reg = new ToolViewRegistry()
const bash = comp('Bash')
reg.register('bash', bash)
expect(reg.resolve('bash', sid('a'))?.component).toBe(bash)
expect(reg.resolve('bash', sid('b'))?.component).toBe(bash)
expect(reg.resolve('read', sid('a'))).toBeUndefined()
})
it('prefers a matching scope filter over the global registration', () => {
const reg = new ToolViewRegistry()
const global = comp('Global')
const swarm = comp('Swarm')
reg.register('bash', global)
reg.register('bash', swarm, { scope: id => id === sid('swarm-1') })
expect(reg.resolve('bash', sid('swarm-1'))?.component).toBe(swarm)
expect(reg.resolve('bash', sid('plain'))?.component).toBe(global)
})
it('later registration wins within the same tier, scoped and global', () => {
const reg = new ToolViewRegistry()
const s1 = comp('S1')
const s2 = comp('S2')
const g1 = comp('G1')
const g2 = comp('G2')
reg.register('bash', g1)
reg.register('bash', s1, { scope: () => true })
reg.register('bash', s2, { scope: () => true })
reg.register('bash', g2)
expect(reg.resolve('bash', sid('x'))?.component).toBe(s2)
const scopeless = new ToolViewRegistry()
scopeless.register('bash', g1)
scopeless.register('bash', g2)
expect(scopeless.resolve('bash', sid('x'))?.component).toBe(g2)
})
it('a non-matching scope filter falls through to global, then undefined', () => {
const reg = new ToolViewRegistry()
const scoped = comp('Scoped')
reg.register('bash', scoped, { scope: () => false })
expect(reg.resolve('bash', sid('x'))).toBeUndefined()
const global = comp('Global')
reg.register('bash', global)
expect(reg.resolve('bash', sid('x'))?.component).toBe(global)
})
it('disposer removes exactly its registration and is idempotent', () => {
const reg = new ToolViewRegistry()
const g = comp('G')
const s = comp('S')
const off = reg.register('bash', s, { scope: () => true })
reg.register('bash', g)
off()
off()
expect(reg.resolve('bash', sid('x'))?.component).toBe(g)
})
it('unregistering the last entry resolves undefined (GenericToolCard fallback)', () => {
const reg = new ToolViewRegistry()
const off = reg.register('bash', comp('B'))
off()
expect(reg.resolve('bash', sid('x'))).toBeUndefined()
})
it('carries the inject factory through resolve', () => {
const reg = new ToolViewRegistry()
const inject = () => ({})
reg.register('bash', comp('B'), { inject })
expect(reg.resolve('bash', sid('x'))?.inject).toBe(inject)
reg.register('read', comp('R'))
expect('inject' in reg.resolve('read', sid('x'))!).toBe(false)
})
it('notifies subscribers and bumps the version on register and dispose', () => {
const reg = new ToolViewRegistry()
const fn = vi.fn()
const unsub = reg.subscribe(fn)
const v0 = reg.getVersion()
const off = reg.register('bash', comp('B'))
expect(fn).toHaveBeenCalledTimes(1)
expect(reg.getVersion()).toBeGreaterThan(v0)
off()
expect(fn).toHaveBeenCalledTimes(2)
unsub()
reg.register('read', comp('R'))
expect(fn).toHaveBeenCalledTimes(2)
})
})

View File

@@ -1,96 +0,0 @@
// Tool-ring type-chain samples (design §9 item 5, toolviews half): the
// register→inject→resolve chain where `I` is inferred from the inject
// factory and proved against the component at the register site, plus
// expect-error duals. Tool names stay an open set (no per-tool props table —
// design §7); the strong typing under test is Entry-internal. The known
// bare-register variance edge (FC<Props & I> assignable to FC<Props & object>
// without an inject factory) is accepted by design §7 and deliberately not
// pinned here. Follows the slots-ring exemplar's shape.
import { describe, expect, it } from 'vitest'
import type { FC, ReactNode } from 'react'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolViewOptions, ToolViewProps } from '../src/client/contract/toolview.ts'
import { ToolViewRegistry } from '../src/client/toolviews/registry.ts'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
const sid = (s: string): SessionId => s as SessionId
/** Registrant's own injected share (locally declared — ownership rule). */
interface RowInjected { useRuns: () => number; actions2: { rerun: () => void } }
const InjectedRow: FC<ToolViewProps & RowInjected> = () => null
const PlainRow: FC<ToolViewProps> = () => null
describe('tool-ring type-chain negatives (compile-time; body never runs)', () => {
it('holds the negative samples as expect-error sites', () => {
const negatives = (registry: ToolViewRegistry) => {
// 1. Inject factory under-produces the component's declared share:
// I infers from the factory, and the component position then fails.
registry.register(
'bash',
// @ts-expect-error component wants actions2, which the factory never produces
InjectedRow,
{ inject: () => ({ useRuns: () => 1 }) },
)
// 2. Inject factory produces a drifted value type for a declared key
// (I infers from the component position here, so TS flags the factory).
registry.register(
'bash',
InjectedRow,
// @ts-expect-error useRuns returns string here, component wants number
{ inject: () => ({ useRuns: () => 'one', actions2: { rerun: () => {} } }) },
)
// 3. Options object drifts: scope filter with a wrong parameter shape.
const badScope: ToolViewOptions<RowInjected> = {
// @ts-expect-error scope takes a SessionId, not a numeric index
scope: (index: number) => index > 0,
}
void badScope
// 4. Component demanding props outside ToolViewProps & I (a key neither
// standard nor injected) cannot register even with a full factory.
const Overreaching: FC<ToolViewProps & RowInjected & { fromNowhere: boolean }> = () => null
registry.register(
'bash',
// @ts-expect-error fromNowhere is neither a standard prop nor produced by the factory
Overreaching,
{ inject: (): RowInjected => ({ useRuns: () => 1, actions2: { rerun: () => {} } }) },
)
return null as ReactNode
}
expect(negatives).toBeTypeOf('function')
})
})
describe('tool-ring full chain (positive dual)', () => {
it('registers with an inferred inject share, resolves by scope order, and reads the erased face back', () => {
const registry = new ToolViewRegistry()
// Registration: I inferred from the factory, component proved ⊇ ToolViewProps & I.
const disposeGlobal = registry.register('bash', InjectedRow, {
inject: (b: SessionBinding): RowInjected => ({
useRuns: () => b.sessionId.length,
actions2: { rerun: () => {} },
}),
})
const disposeScoped = registry.register('bash', PlainRow, {
scope: id => id === sid('swarm-1'),
})
// Resolve: scope match beats global; elsewhere the global row wins.
expect(registry.resolve('bash', sid('swarm-1'))?.component).toBe(PlainRow)
const global = registry.resolve('bash', sid('other'))
expect(global?.component).toBe(InjectedRow)
// Read face: I is erased to object, the factory reference survives; the
// outlet-side restoration is the budgeted cast (same boundary as slots).
const injected = (global?.inject as (b: SessionBinding) => RowInjected)(
{ sessionId: 'ab', session: { useSelector: undefined }, ctx: undefined },
)
expect(injected.useRuns()).toBe(2)
// Unknown tool → undefined (caller falls back to the generic card).
expect(registry.resolve('ghost-tool', sid('other'))).toBeUndefined()
disposeScoped()
expect(registry.resolve('bash', sid('swarm-1'))?.component).toBe(InjectedRow)
disposeGlobal()
expect(registry.resolve('bash', sid('other'))).toBeUndefined()
})
})

View File

@@ -1,100 +1,122 @@
// View-ring type-chain samples (design §9 item 5, views half): the
// register→inject→render chain composed through ConversationViewMap's
// per-view extension shapes, plus expect-error duals for each stage.
// Follows the slots-ring exemplar (ui-slots/tests/type-chain.spec.tsx):
// negatives live in a never-executed function body; the positive dual runs
// the real ConversationService view registry.
// View-ring + toolview-hole type-chain samples, slot form: both are declared
// slots, so the register→inject→render chain and its compile-time locks are
// the slot system's (ui-slots/tests/type-chain.spec.tsx owns the generic
// duals). This spec pins the package-specific surface: the SlotMap rows
// (kind/scope/owner), list- and keyed-kind registration shapes, the ChatView
// and tool-row composed-props contracts, and the runtime dual — a real
// SlotsService ledger driving registration/order/disposal the way
// ConversationRoot's tab projection consumes it.
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { FC, ReactNode } from 'react'
import type {
ChromePropsOf, ConvViewProps, ConvViewPropsOf, ViewEntry,
} from '../src/client/contract/views.ts'
import { ConversationService } from '../src/client/service.ts'
import type { ReactNode } from 'react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatViewSlotProps, ConvViewProps, ToolRowProps } from '../src/client/contract/slots.ts'
// Test-only view keys with distinct extension shapes (merged like
// ui-trajectory does; extension fields are optional per ViewEntryDef).
declare module '../src/client/contract/views.ts' {
interface ConversationViewMap {
'vt-extended': { chromeProps: { statLabel: string }; extraProps: { density: 'compact' | 'wide' } }
'vt-plain': object
}
}
const ExtendedView: FC<ConvViewPropsOf<'vt-extended'>> = ({ density }) => (density === 'compact' ? null : null)
const ExtendedChrome: FC<ChromePropsOf<'vt-extended'>> = ({ statLabel }) => (statLabel === '' ? null : null)
const PlainView: FC<ConvViewPropsOf<'vt-plain'>> = () => null
describe('view-ring type-chain negatives (compile-time; body never runs)', () => {
describe('view-ring type negatives (compile-time; body never runs)', () => {
it('holds the negative samples as expect-error sites', () => {
const negatives = (service: ConversationService) => {
// 1. Registration: a component missing the entry's declared extraProps
// cannot register under that id (props flow from the map entry).
const NarrowComp: FC<ConvViewProps & { density: number }> = () => null
service.registerView({
id: 'vt-extended',
label: 'x',
// @ts-expect-error density has the wrong value type vs the map entry's extraProps
component: NarrowComp,
})
// 2. Registration: chrome typed for another view's chromeProps drifts.
service.registerView({
id: 'vt-plain',
label: 'x',
component: PlainView,
// @ts-expect-error vt-plain declares no statLabel chromeProps
chrome: { footer: ExtendedChrome },
})
// 3. Registration: id outside the map is rejected at the entry.
service.registerView({
// @ts-expect-error unregistered view id
id: 'vt-ghost',
label: 'x',
component: PlainView,
})
// 4. Render side: per-view props narrow — the extended view's density
// is not accessible under another id's props type.
const renderPlain = (props: ConvViewPropsOf<'vt-plain'>): ReactNode => {
// @ts-expect-error density belongs to vt-extended's extension, not vt-plain
return props.density === 'compact' ? null : null
const negatives = (slots: SlotsService) => {
// 1. List-kind registration requires the id shape field.
// @ts-expect-error missing `id` on a list-slot registration
slots.register({ name: 'conversation.view', order: 1 }, (_p: ConvViewProps) => null)
// 2. A keyed-kind shape field is rejected on the list slot.
slots.register(
// @ts-expect-error `key` belongs to keyed slots, not the list ring
{ name: 'conversation.view', id: 'x', key: 'k' },
(_p: ConvViewProps) => null)
// 3. Component props must stay within the composed contract: an
// undeclared member cannot be required.
// @ts-expect-error component demands a prop no share supplies
slots.register(
{ name: 'conversation.view', id: 'y' },
(_p: ConvViewProps & { phantom: number }) => null)
// 4. Views receive no renderSlot — the ring's entries declare no children.
const renderless = (props: ConvViewProps): ReactNode => {
// @ts-expect-error views receive no renderSlot — no sub-slot delegation
void props.renderSlot
return null
}
void renderPlain
// 5. Entry-shape drift: ViewEntry<Id> ties chrome and component to the
// SAME id — mixing ids inside one entry fails.
const mixed: ViewEntry<'vt-extended'> = {
id: 'vt-extended',
label: 'x',
component: ExtendedView,
// @ts-expect-error chrome for vt-plain cannot ride a vt-extended entry
chrome: { header: (props: ChromePropsOf<'vt-plain'> & { onlyPlain: true }) => null },
void renderless
// 5. The chat entry's face is its own: openDetails does not exist on the
// base view props (store-less riders never see it).
const baseOnly = (props: ConvViewProps): ReactNode => {
// @ts-expect-error openDetails lives on ChatViewSlotProps, not the base
void props.openDetails
return null
}
void mixed
void baseOnly
// 6. ChatViewSlotProps carries the full composition (standard kit +
// store + inject face) — a handler with a wrong signature is red.
const chatProps = (props: ChatViewSlotProps): ReactNode => {
// @ts-expect-error openDetails takes a SelectionTarget, not a string
props.openDetails('nope')
return null
}
void chatProps
// 7. Keyed hole registration requires the key shape field.
// @ts-expect-error missing `key` on a keyed-slot registration
slots.register({ name: 'conversation.chat.toolview' }, (_p: ToolRowProps) => null)
// 8. A list-kind shape field is rejected on the keyed hole.
slots.register(
// @ts-expect-error `id`/`order` belong to list slots, not the keyed hole
{ name: 'conversation.chat.toolview', key: 'k', order: 1 },
(_p: ToolRowProps) => null)
// 9. Tool-row components stay within their composed contract: the
// owner share + standard kit supply no chat-view members.
const overreaching = (props: ToolRowProps): ReactNode => {
// @ts-expect-error loadOlder lives on ChatViewSlotProps, not the row contract
void props.loadOlder
return null
}
void overreaching
// 10. Owner-share drift is red at the row component seam: block is the
// call union, not arbitrary payload.
const drifted = (props: ToolRowProps): ReactNode => {
// @ts-expect-error the block union has no `argsParsed` member
void props.block.argsParsed
return null
}
void drifted
return null as ReactNode
}
expect(negatives).toBeTypeOf('function')
})
})
describe('view-ring full chain (positive dual)', () => {
it('registers, lists, and renders through the per-view extension shapes', () => {
describe('view-ring runtime dual (real ledger)', () => {
function bench() {
const ctx = new Context()
const service = new ConversationService(ctx)
// Registration: extension-typed component + same-id chrome compose cleanly.
const dispose = service.registerView({
id: 'vt-extended',
label: '扩展视图',
order: 7,
component: ExtendedView,
chrome: { footer: ExtendedChrome },
})
const entry = service.views().find(v => v.id === 'vt-extended')
expect(entry?.label).toBe('扩展视图')
// Render surface: the listed entry's component accepts the composed props
// (base ConvViewProps + the map extension), spelled here as the same type
// the runtime hands over.
expect(typeof entry?.component).toBe('function')
expect(typeof entry?.chrome?.footer).toBe('function')
dispose()
expect(service.views().some(v => v.id === 'vt-extended')).toBe(false)
const slots = new SlotsService(ctx)
// The conversation entry's role: declare the ring (declaring is claiming).
slots.register({
name: 'root',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
}, (_p: { renderSlot?: unknown }) => null)
return { slots }
}
it('registers, orders, projects tabs, and disposes through the slot ledger', () => {
const { slots } = bench()
const offLate = slots.register(
{ name: 'conversation.view', id: 'z-late', order: 20, label: '晚' }, () => null)
const offEarly = slots.register(
{ name: 'conversation.view', id: 'early', order: 0, label: '早' }, () => null)
// Order-sorted ledger, label fallback for a labelless rider.
const offBare = slots.register(
{ name: 'conversation.view', id: 'bare', order: 10 }, () => null)
const tabs = slots.entries('conversation.view')
.map(e => ({ id: e.options.id, label: e.options.label ?? e.options.id }))
expect(tabs).toEqual([
{ id: 'early', label: '早' },
{ id: 'bare', label: 'bare' },
{ id: 'z-late', label: '晚' },
])
// Duplicate ids fail loud at load (the ring's uniqueness contract).
expect(() => slots.register({ name: 'conversation.view', id: 'early' }, () => null))
.toThrow(/already has an entry with id "early"/)
offEarly()
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['bare', 'z-late'])
offBare()
offLate()
expect(slots.entries('conversation.view')).toHaveLength(0)
})
})

View File

@@ -1,15 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"jsx": "react-jsx",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
"outDir": "lib/types"
},
"include": [
"src"

View File

@@ -1,8 +1,10 @@
# @deepseek-ai/dsh-client-ui-layout
Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. Contract: api-contracts v3 §5.
Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. A closed sidebar retains a 56px control rail while details closes to zero width; collapse/expand animates the grid tracks on the deepsuite sider curve. Contract: api-contracts v3 §5.
Slot declarations use the composed-props entry form (`owner` share, no full `props`): the exported OwnerShare contracts are `SidebarOwnerProps` / `ConvOwnerProps` / `DetailsOwnerProps` / `EmptyOwnerProps` — registrants reference them via `OwnerOf<'sidebar' | ...>` and compose their own injected share locally. The `conversation` entry authorizes `conversation.empty` delegation through `children`.
Slot declarations use the composed-props entry form (`owner` share, no full `props`): the exported OwnerShare contracts are `SidebarOwnerProps` / `ConvOwnerProps` / `DetailsOwnerProps` / `EmptyOwnerProps` — registrants reference them via `OwnerOf<'sidebar' | ...>` and compose their own injected share locally. No entry declares `children` (declaring it requires the registered component to carry the slots face — reserved for future business slots): delegation authority is the component-side whitelist, i.e. AppFrame's `ScopedSlots<FrameSlotKey>` face over sidebar/conversation/details/conversation.empty. Since the root-slot rework the frame itself registers into 'root' and renders those child slots at its own render sites; the shell only renders 'root'.
The export surface is the cross-package contract only: the AppFrame trio (+ `AppFrameProps`) consumed by the web shell's assembly, `LayoutService` with its store shapes (`NavState`/`PanelState`/`ViewId`), and the OwnerShare contracts. The concession-chain solver (`computeColumns`) and its geometry constants are package-internal; tests import them from `/src`.
## Model Experience

View File

@@ -36,7 +36,6 @@
"dependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"react": "^18.2.0"
},
"peerDependencies": {

View File

@@ -5,6 +5,21 @@
height: 100%;
overflow: hidden;
background: var(--dsw-alias-bg-base);
/* Collapse/expand animates the tracks on the deepsuite sider curve
(--ds-ease-in-out / --ds-transition-duration-slow, ui-theme base.css). */
transition: grid-template-columns var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* Dragging writes widths at pointer cadence; easing them would detach the
column from the handle. */
.frame[data-dragging] {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.frame {
transition: none;
}
}
.sidebarCol {
@@ -27,13 +42,8 @@
border-left: 1px solid var(--dsw-alias-border-l2);
}
/* Collapsed columns keep children mounted; the border must not paint a 1px seam.
Flags live on the frame — DetailsColumn renders inside the provider body and
does not know its own width. */
.frame[data-sidebar-collapsed] .sidebarCol {
border-right: none;
}
/* The details subtree stays mounted at zero width, so its border must not paint
a 1px seam. The collapsed sidebar instead retains a bordered compact rail. */
.frame[data-details-collapsed] .detailsCol {
border-left: none;
}
@@ -51,6 +61,19 @@
cursor: col-resize;
z-index: 2;
touch-action: none;
/* Rides the same curve as the tracks so the pill stays on the moving
border during collapse/expand; paused while dragging (frame rule). */
transition: left var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.frame[data-dragging] .handle {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.handle {
transition: none;
}
}
.handle::after {

View File

@@ -1,54 +1,47 @@
/**
* Three-column shell frame. Owns the grid tracks (sidebar | center | details),
* the two drag handles (pointer capture + rAF throttle), and the concession
* chain (columns.ts). Column content arrives via props: `sidebar` is the
* sidebar slot render, `children` is the session area (the shell mounts
* SessionProvider there; its body renders {@link CenterColumn} and
* {@link DetailsColumn}, which land as grid items because neither the provider
* nor fragments emit DOM). Zero cordis imports — stores and actions are
* injected as props.
* Three-column shell frame, registered into the built-in 'root' slot (the web
* shell renders only 'root'). Owns the grid tracks (sidebar | center |
* details), the drag handles (pointer capture + rAF throttle), the concession
* chain (columns.ts), and the child-slot render decisions: the sidebar slot
* renders HERE with live parameters from the concession solve, and the
* session pair renders under the SessionProvider standard seat (render-prop
* form, injected by the renderer because the children declaration contains
* session-scope slots; session slots get sessionId as a framework-standard
* prop, so the owner shares stay empty). Pure component: everything arrives
* through the four prop shares — zero cordis or framework imports, zero
* self-made hooks.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import { computeColumns } from './columns.ts'
import type { PanelState } from './service.ts'
import type { createLayoutStore } from './stores.ts'
import css from './AppFrame.module.css'
/** AppFrame props: injected viewing-state hooks, stable width actions, column content. */
export interface AppFrameProps {
/** Selector hook over the sidebar panel store. */
useSidebar: SnapshotSelectorHook<PanelState>
/** Selector hook over the details panel store. */
useDetails: SnapshotSelectorHook<PanelState>
/** Persist a sidebar width preference (service clamps). */
setSidebarWidth: (px: number) => void
/** Persist a details width preference (service clamps). */
setDetailsWidth: (px: number) => void
/** Sidebar column content (shell: renderSlot('sidebar')). */
sidebar: ReactNode
/** Session area (shell: SessionProvider whose body renders CenterColumn + DetailsColumn). */
children?: ReactNode
}
/** Full composed props: runtime share + child-slot render share + store share (no business face). */
export type AppFrameProps =
& PropsRuntime<'root'>
& PropsRenderSlots<'sidebar' | 'conversation' | 'details' | 'conversation.empty'>
& PropsStore<ReturnType<typeof createLayoutStore>>
/** Center column grid item; rendered inside the session provider's body. */
export function CenterColumn(props: { children?: ReactNode }) {
/** Center column grid item (session-body building block). */
function CenterColumn(props: { children?: ReactNode }) {
return <div className={css.centerCol}>{props.children}</div>
}
/** Details column grid item; width 0 keeps the subtree mounted (never unmount on close). */
export function DetailsColumn(props: { children?: ReactNode }) {
function DetailsColumn(props: { children?: ReactNode }) {
return <div className={css.detailsCol}>{props.children}</div>
}
/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. */
function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: number) => void }) {
function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) {
const [dragging, setDragging] = useState(false)
const origin = useRef(0)
const latest = useRef(0)
const frame = useRef<number | null>(null)
const callbacks = useRef({ onStart: props.onStart, onDrag: props.onDrag })
callbacks.current = { onStart: props.onStart, onDrag: props.onDrag }
const callbacks = useRef({ onStart: props.onStart, onDrag: props.onDrag, onEnd: props.onEnd })
callbacks.current = { onStart: props.onStart, onDrag: props.onDrag, onEnd: props.onEnd }
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault()
@@ -72,6 +65,7 @@ function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: num
if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null }
callbacks.current.onDrag(latest.current - origin.current)
setDragging(false)
callbacks.current.onEnd()
}, [])
return (
@@ -86,10 +80,9 @@ function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: num
)
}
/** The three-column frame (see module doc). */
export function AppFrame(props: AppFrameProps) {
const sidebar = props.useSidebar((s) => s)
const details = props.useDetails((s) => s)
/** The three-column frame (see module doc). SessionProvider arrives as a standard seat (declaring a session-scope child summons it — no framework import). */
export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: AppFrameProps) {
const panels = useStore((s) => s)
const frameRef = useRef<HTMLDivElement | null>(null)
const [viewport, setViewport] = useState(() => window.innerWidth)
@@ -113,7 +106,7 @@ export function AppFrame(props: AppFrameProps) {
}
}, [])
const cols = computeColumns(viewport, sidebar, details)
const cols = computeColumns(viewport, panels.sidebar, panels.details)
const colsRef = useRef(cols)
colsRef.current = cols
@@ -122,28 +115,54 @@ export function AppFrame(props: AppFrameProps) {
// it stays frozen for the whole gesture so dx deltas do not compound.
const sidebarBase = useRef(0)
const detailsBase = useRef(0)
const { setSidebarWidth, setDetailsWidth } = props
const onSidebarStart = useCallback(() => { sidebarBase.current = colsRef.current.sidebar }, [])
const onDetailsStart = useCallback(() => { detailsBase.current = colsRef.current.details }, [])
// Track-level transitions pause for the whole gesture: eased tracks would
// detach the column edge from the pointer (AppFrame.module.css).
const [dragging, setDragging] = useState(false)
const onDragEnd = useCallback(() => { setDragging(false) }, [])
const onSidebarStart = useCallback(() => { sidebarBase.current = colsRef.current.sidebar; setDragging(true) }, [])
const onDetailsStart = useCallback(() => { detailsBase.current = colsRef.current.details; setDragging(true) }, [])
const onSidebarDrag = useCallback((dx: number) => {
setSidebarWidth(sidebarBase.current + dx)
}, [setSidebarWidth])
actions.setSidebar(sidebarBase.current + dx)
}, [actions])
const onDetailsDrag = useCallback((dx: number) => {
setDetailsWidth(detailsBase.current - dx)
}, [setDetailsWidth])
actions.setDetails(detailsBase.current - dx)
}, [actions])
return (
<div
ref={frameRef}
className={css.frame}
style={{ gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.details}px` }}
data-sidebar-collapsed={cols.sidebar === 0 || undefined}
data-sidebar-collapsed={panels.sidebar === 0 || undefined}
data-details-collapsed={cols.details === 0 || undefined}
data-dragging={dragging || undefined}
>
<div className={css.sidebarCol}>{props.sidebar}</div>
{props.children}
{cols.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} />}
{cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} />}
<div className={css.sidebarCol}>
{/* Render-site slot call with live concession output: a closed
sidebar keeps the mounted slot at the compact-rail width, and the
component sees its rendered state as owner params decided here
(collapsed follows the preference, not the resolved width). */}
{renderSlot('sidebar', { collapsed: panels.sidebar === 0, width: cols.sidebar })}
</div>
<SessionProvider
empty={() => (
<>
<CenterColumn>{renderSlot('conversation.empty', {})}</CenterColumn>
<DetailsColumn />
</>
)}
>
{() => (
<>
{/* sessionId is a framework-standard prop on session slots — the owner passes nothing. */}
<CenterColumn>{renderSlot('conversation', {})}</CenterColumn>
<DetailsColumn>{renderSlot('details', {})}</DetailsColumn>
</>
)}
</SessionProvider>
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
{panels.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
{cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}
</div>
)
}

View File

@@ -2,13 +2,13 @@
* Pure concession-chain column solver for the three-column AppFrame.
* Chain order is fixed by contract: keep center >= CENTER_MIN by shrinking
* details first, then sidebar, then auto-closing details (derived zero width —
* persisted open/width preferences are never rewritten, so widening the window
* persisted width preferences are never rewritten, so widening the window
* restores them). Center absorbs any remaining deficit as the last resort.
* Inputs are the layout store's plain width preferences (0 = closed); a
* closed sidebar resolves to the fixed SIDEBAR_COLLAPSED control rail while
* closed details resolve to zero width.
*/
/** Panel viewing state consumed by the solver (mirrors LayoutService PanelState). */
export interface PanelInput { open: boolean; width: number }
/** Resolved widths for one frame; center may drop below CENTER_MIN only at the final fallback. */
export interface Columns { sidebar: number; center: number; details: number }
@@ -21,6 +21,8 @@ export const SIDEBAR_MIN = 240
export const SIDEBAR_MAX = 420
/** Sidebar width before any user drag. */
export const SIDEBAR_DEFAULT = 300
/** Closed-sidebar rail: a 24px icon column between 16px horizontal paddings. */
export const SIDEBAR_COLLAPSED = 56
/** Details drag clamp floor. */
export const DETAILS_MIN = 300
/** Details drag clamp ceiling. */
@@ -44,16 +46,16 @@ export function clampWidth(px: number, min: number, max: number): number {
* the output is a function of (viewport, preferences) only, so recovery on
* re-widening is automatic. After the auto-close step the details pressure is
* gone, so the sidebar returns to its preferred width when it fits.
* Preferences re-clamp here because they cross a durable boundary
* (localStorage rehydration may carry stale ranges).
* @param viewport - available frame width in px.
* @param sidebar - sidebar preference (open flag + persisted width).
* @param details - details preference (open flag + persisted width).
* @returns resolved widths; details 0 means visually closed (never unmounted).
* @param sidebar - sidebar width preference in px (0 = closed).
* @param details - details width preference in px (0 = closed).
* @returns resolved widths; details 0 means visually closed (never unmounted), while a closed sidebar keeps its compact rail.
*/
export function computeColumns(viewport: number, sidebar: PanelInput, details: PanelInput): Columns {
const want = (p: PanelInput, min: number, max: number): number =>
p.open ? clampWidth(p.width, min, max) : 0
const s0 = want(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
const d0 = want(details, DETAILS_MIN, DETAILS_MAX)
export function computeColumns(viewport: number, sidebar: number, details: number): Columns {
const s0 = sidebar === 0 ? SIDEBAR_COLLAPSED : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
const d0 = details === 0 ? 0 : clampWidth(details, DETAILS_MIN, DETAILS_MAX)
// Step 1: everything fits at preferred widths.
if (s0 + d0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0 - d0, details: d0 }
@@ -62,15 +64,15 @@ export function computeColumns(viewport: number, sidebar: PanelInput, details: P
const d1 = d0 === 0 ? 0 : Math.max(DETAILS_MIN, viewport - s0 - CENTER_MIN)
if (s0 + d1 + CENTER_MIN <= viewport) return { sidebar: s0, center: CENTER_MIN, details: d1 }
// Step 3: shrink sidebar toward its minimum.
const s1 = s0 === 0 ? 0 : Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN)
// Step 3: shrink sidebar toward its minimum (the collapsed rail never shrinks).
const s1 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN)
if (s1 + d1 + CENTER_MIN <= viewport) return { sidebar: s1, center: CENTER_MIN, details: d1 }
// Step 4: auto-close details (derived — preferences untouched). With the
// details pressure gone the sidebar concession is re-solved from preference.
if (d1 > 0) {
if (s0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0, details: 0 }
const s2 = s0 === 0 ? 0 : Math.max(SIDEBAR_MIN, viewport - CENTER_MIN)
const s2 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - CENTER_MIN)
return { sidebar: s2, center: Math.max(0, viewport - s2), details: 0 }
}

View File

@@ -1,22 +1,23 @@
/**
* Layout plugin, browser half: three-column AppFrame plus ctx.layout, the
* shell-level viewing-state authority (navigation + panel geometry).
* Contract: api-contracts v3 section 5. apply provides the service and
* defines the three top-level slots; frame components are exported for the
* web shell's assembly (the shell resolves this surface from the loader
* module table and closes the slots over its own scopedSlots).
* Layout plugin, browser half: one register() call contributes AppFrame into
* the runtime's built-in 'root' slot and, in the same breath, declares the
* four child slots (declaration = exclusive render authority), seats the
* layout store (panel geometry), and wires the panel-action service face.
* ctx.layout is the cross-plugin panel-action seam; navigation state lives
* with the runtime sessions service.
*/
import type { Context } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { PanelActions } from './service.ts'
import { AppFrame } from './AppFrame.tsx'
import { createLayoutStore } from './stores.ts'
import { LayoutService } from './service.ts'
export { AppFrame, CenterColumn, DetailsColumn, type AppFrameProps } from './AppFrame.tsx'
export {
clampWidth, computeColumns,
CENTER_MIN, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
type Columns, type PanelInput,
} from './columns.ts'
export { LayoutService, type NavState, type PanelState, type ViewId } from './service.ts'
// Contract surface only (export-convergence rule: cross-package consumers
// keep a symbol exported; test-only/package-internal symbols live off /src).
// LayoutService: the ctx.layout service class (consumers type against it).
// OwnerShare contracts below are the render-side halves registrants compose
// against; the frame components and the store factory are package-internal.
export { LayoutService } from './service.ts'
declare module 'cordis' {
interface Context {
@@ -26,11 +27,11 @@ declare module 'cordis' {
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
// The 'root' entry itself is the runtime's built-in slot (declared
// there); these four are the frame's children, declared by the same
// register() call that contributes AppFrame. Session slots carry no
// owner share: the framework injects sessionId as a standard prop.
'sidebar': { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps }
// children deliberately absent on every entry: the B-a validation layer
// gates COMPONENT delegation, and no P-I slot component delegates —
// conversation.empty is rendered by the shell's assembly closure, not
// handed down by ConversationRoot (its slots face is ScopedSlots<never>).
'conversation': { kind: 'single'; scope: 'session'; owner: ConvOwnerProps }
'details': { kind: 'single'; scope: 'session'; owner: DetailsOwnerProps }
'conversation.empty': { kind: 'single'; scope: 'root'; owner: EmptyOwnerProps }
@@ -39,43 +40,67 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
// OwnerShare contracts — the render-side share the slot owner supplies at
// renderSlot. Registrants IMPORT these and compose their full component props
// as OwnerOf<K> & StandardOf<K> & OwnInjected (reference, never re-typed).
// through the four-share intersection (PropsRuntime & PropsRenderSlots &
// PropsStore & I). Session owner shares stay literally empty: a phantom
// `sessionId?: never` would intersect with the framework's mandatory
// SessionStandardProps.sessionId and collapse the composed props to never —
// the anti-smuggling guard is mutually exclusive with standard injection, so
// the standard member's own type is the only guard on standard keys. Phantom
// members remain fine on keys the standards never claim (EmptyOwnerProps).
/** Sidebar owner share: the owner supplies nothing — everything arrives via inject. */
export interface SidebarOwnerProps { slots?: never }
/** Sidebar owner share: live column state from the frame's concession solve. */
export interface SidebarOwnerProps {
/** True when the sidebar is closed (the column renders the compact control rail). */
collapsed: boolean
/** Rendered column width in px (SIDEBAR_COLLAPSED when collapsed). */
width: number
}
/** Conversation owner share. */
export interface ConvOwnerProps { sessionId: SessionId }
/** Conversation owner share: empty — sessionId arrives as a framework-standard prop. */
export interface ConvOwnerProps {}
/** Details owner share. */
export interface DetailsOwnerProps { sessionId: SessionId }
/** Details owner share: empty — sessionId arrives as a framework-standard prop. */
export interface DetailsOwnerProps {}
/** Empty-state owner share (ui-conversation registers EmptyState here). */
export interface EmptyOwnerProps { slots?: never }
export interface EmptyOwnerProps { children?: never }
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
export const inject = ['slots']
/**
* Client plugin body: provide ctx.layout and define the three top-level slots.
* Client plugin body: provide ctx.layout, then one register() call — AppFrame
* into 'root' with the four child-slot declarations, the layout store seat,
* and the inject hook that hands the store's bound actions to the service.
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
const layout = new LayoutService(ctx)
export function apply(ctx: ClientContext): void {
const layout = new LayoutService()
ctx.effect(() => {
const disposeService = ctx.reflect.provide('layout', layout)
const disposeSidebar = ctx.slots.define('sidebar', { kind: 'single', scope: 'root' })
const disposeConversation = ctx.slots.define('conversation', { kind: 'single', scope: 'session' })
const disposeDetails = ctx.slots.define('details', { kind: 'single', scope: 'session' })
const disposeEmpty = ctx.slots.define('conversation.empty', { kind: 'single', scope: 'root' })
const disposeRegistration = ctx.slots.register({
name: 'root',
children: {
'sidebar': { kind: 'single', scope: 'root' },
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
// Exclusive store: the factory itself — the framework instantiates per
// entry and delivers useStore/actions to AppFrame as standard props.
store: createLayoutStore,
// No business face for the frame (I = {}): the hook's job is the
// assembly side effect wiring the entry's bound actions into the
// cross-plugin service seam.
inject: (actions: PanelActions) => {
layout.attachPanels(actions)
return {}
},
}, AppFrame)
return () => {
disposeEmpty()
disposeDetails()
disposeConversation()
disposeSidebar()
disposeRegistration()
// provide()'s disposer settles asynchronously; teardown is synchronous fire-and-forget.
void disposeService()
layout.dispose()
}
}, 'ui-layout: service + slot definitions')
}, 'ui-layout: service + root registration')
}

View File

@@ -1,132 +1,54 @@
/**
* LayoutService implementation: the shell-level viewing-state authority.
* Four persisted stores (nav + two panels); actions clamp and validate. The
* concession chain lives in columns.ts and never writes back into these
* stores — persisted preferences survive window shrinking.
* LayoutService: the cross-plugin panel-action face behind ctx.layout.
* Panel geometry itself lives in the root entry's layout store (stores.ts);
* the current-session selection lives with the runtime sessions service, and
* the per-session active view dissolved into ui-conversation's session store
* (its only consumer). What remains here is the seam other plugins'
* apply worlds reach for panel transitions (sidebar toggle from ui-sidebar,
* details open/close from ui-conversation) — writes stay inside the store's
* declared action set, delivered as the registration's bound actions.
*/
import type { Context } from 'cordis'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import {
clampWidth, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN,
SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
} from './columns.ts'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { createLayoutStore } from './stores.ts'
/** Active conversation view id (keys merged into ConversationViewMap by ui-conversation). */
export type ViewId = string
/** The layout store's bound action set (framework-baked, draft params peeled). */
export type PanelActions = BoundActions<ReturnType<typeof createLayoutStore>>
/** Navigation state: selected session and per-session active view. */
export interface NavState { sessionId?: SessionId; viewFor: Record<SessionId, ViewId> }
/** Panel viewing state: open flag plus persisted width. */
export interface PanelState { open: boolean; width: number }
/** Shell-level viewing-state authority (zustand + persist). */
/** Cross-plugin panel-action face (ctx.layout). */
export class LayoutService {
/** Navigation state store. */
readonly current: SnapshotStore<NavState>
/** Sidebar panel store (default 300, clamp [240, 420]). */
readonly sidebar: SnapshotStore<PanelState>
/** Details panel store (default 360, clamp [300, 520]; P-I global, not per-session). */
readonly details: SnapshotStore<PanelState>
#sessions: SessionsService
#unprune: () => void
#panels: PanelActions | undefined
/**
* @param ctx - root context (resolves the sessions service for open validation and list pruning).
* Adopt the root entry's bound store actions. Called from the root
* registration's inject hook (a sanctioned assembly side effect), so the
* face is live from the entry's first render; on entry re-register the
* fresh actions overwrite the stale set.
* @param actions - bound actions of the entry's layout store instance.
*/
constructor(ctx: Context) {
// ctx.get instead of ctx.sessions: the typed Context merge is suspended
// while the client/host `sessions` declaration collision awaits
// arbitration (see the runtime package's Context merge note).
const sessions = ctx.get('sessions')
if (sessions === undefined) throw new Error('layout: sessions service unavailable')
this.#sessions = sessions
this.current = createSnapshotStore<NavState>(
{ viewFor: {} },
{ persist: { name: 'dsh.layout.nav' } })
this.sidebar = createSnapshotStore<PanelState>(
{ open: true, width: SIDEBAR_DEFAULT },
{ persist: { name: 'dsh.layout.sidebar' } })
this.details = createSnapshotStore<PanelState>(
{ open: false, width: DETAILS_DEFAULT },
{ persist: { name: 'dsh.layout.details' } })
// Prune is one-directional: list removals clear keyed viewing state, and a
// selection pointing at a removed session falls back to the empty state.
this.#unprune = sessions.list.subscribe(() => { this.#prune() })
attachPanels(actions: PanelActions): void {
this.#panels = actions
}
/** Drop the sessions.list subscription (plugin teardown). */
dispose(): void {
this.#unprune()
}
#prune(): void {
const byId = this.#sessions.list.getSnapshot().byId
const nav = this.current.getSnapshot()
// Object.keys erases the branded key type; these entries were written with SessionId keys.
const viewKeys = Object.keys(nav.viewFor) as SessionId[]
const staleView = viewKeys.some(id => byId[id] === undefined)
const staleCurrent = nav.sessionId !== undefined && byId[nav.sessionId] === undefined
if (!staleView && !staleCurrent) return
this.current.update((draft) => {
// Rebuild instead of dynamic delete: viewFor is a plain keyed record and
// the survivors are the entries whose session still exists.
draft.viewFor = Object.fromEntries(
Object.entries(draft.viewFor).filter(([id]) => byId[id as SessionId] !== undefined))
if (draft.sessionId !== undefined && byId[draft.sessionId] === undefined) delete draft.sessionId
})
}
/**
* Select a session. Unknown ids fail loud instead of navigating nowhere.
* @param id - session id (must exist in sessions.list).
*/
open(id: SessionId): void {
if (this.#sessions.list.getSnapshot().byId[id] === undefined) {
throw new Error(`layout.open: unknown session ${id}`)
}
this.current.update((draft) => { draft.sessionId = id })
}
/**
* Activate a view for a session.
* @param sessionId - session id.
* @param view - view id.
*/
openView(sessionId: SessionId, view: ViewId): void {
this.current.update((draft) => { draft.viewFor[sessionId] = view })
}
/** Toggle the sidebar panel. */
/** Toggle the sidebar panel (closed ⟷ contract default width). */
toggleSidebar(): void {
this.sidebar.update((draft) => { draft.open = !draft.open })
this.#require().toggleSidebar()
}
/**
* Set the sidebar width (clamped to [240, 420]).
* @param px - width in pixels.
*/
setSidebarWidth(px: number): void {
this.sidebar.update((draft) => { draft.width = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) })
}
/** Open the details panel. */
/** Open the details panel (no-op when already open). */
openDetails(): void {
this.details.update((draft) => { draft.open = true })
this.#require().openDetails()
}
/** Close the details panel. */
closeDetails(): void {
this.details.update((draft) => { draft.open = false })
this.#require().closeDetails()
}
/**
* Set the details width (clamped to [300, 520]).
* @param px - width in pixels.
*/
setDetailsWidth(px: number): void {
this.details.update((draft) => { draft.width = clampWidth(px, DETAILS_MIN, DETAILS_MAX) })
#require(): PanelActions {
// Callers are UI gestures, which cannot fire before the root entry
// rendered (the inject hook runs in its first render) — reaching this
// unwired is a boot-order bug, not a race to tolerate.
if (this.#panels === undefined) throw new Error('layout: panel actions not wired (root entry not mounted)')
return this.#panels
}
}

View File

@@ -0,0 +1,51 @@
/**
* The root entry's layout store: panel geometry as plain widths in px
* (0 = closed), persisted across reloads. Module level exports the factory
* only — a module-level handle would pin the store's identity in the module
* cache (a de-facto singleton surviving plugin reloads). register() receives
* the factory (exclusive use: the framework instantiates per entry), AppFrame
* derives its PropsStore share from the return type, and the service face
* receives the bound actions through the registration's inject hook.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import {
clampWidth, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN,
SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
} from './columns.ts'
/** Panel width preferences in px (0 = closed) — the layout store's state. */
type PanelWidths = { sidebar: number; details: number }
/**
* Annotation twin of the actions literal below (the export needs a declared
* return type); drift fails assignability at the defineStore call.
*/
type LayoutActions = {
setSidebar: (draft: PanelWidths, px: number) => void
setDetails: (draft: PanelWidths, px: number) => void
toggleSidebar: (draft: PanelWidths) => void
openDetails: (draft: PanelWidths) => void
closeDetails: (draft: PanelWidths) => void
}
/**
* Create the layout panel store handle. The persisted preference IS the
* width, so closing a panel forgets its drag width — reopening restores the
* contract default. Actions are the complete write set: drag writes clamp
* into the panel's contract range and never cross the open/closed line;
* open/close transitions write 0 / the default explicitly.
* @returns the store handle (spec + type + identity + factory in one).
*/
export function createLayoutStore(): EngineStoreHandle<PanelWidths, LayoutActions> {
return defineStore({
init: () => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }),
persist: 'dsh.layout.panels',
actions: {
setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) },
setDetails: (d, px: number) => { d.details = clampWidth(px, DETAILS_MIN, DETAILS_MAX) },
toggleSidebar: (d) => { d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0 },
openDetails: (d) => { if (d.details === 0) d.details = DETAILS_DEFAULT },
closeDetails: (d) => { d.details = 0 },
},
})
}

View File

@@ -1,16 +1,34 @@
// @vitest-environment jsdom
/**
* AppFrame interaction spec: drag sequences (pointer capture + rAF flush),
* concession response to viewport change, details stays mounted at zero
* width. jsdom has no layout engine, so the frame width comes from a mocked
* getBoundingClientRect and resizes are driven through the ResizeObserver
* stub; assertions read the inline grid template.
* AppFrame interaction spec under the four-share props form: real layout
* store instance (createLayoutStore().create() — the test-sanctioned engine
* path), a recording renderSlot stub, and a render-prop SessionProvider stub
* (the real one is framework-wired to the renderer host; its own behavior is
* web-react's spec territory). Drag sequences (pointer capture + rAF flush),
* concession response to viewport change, and details staying mounted at
* zero width are the preserved behavior assertions. jsdom has no layout
* engine, so the frame width comes from a mocked getBoundingClientRect and
* resizes are driven through the ResizeObserver stub.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { AppFrame, CenterColumn, DetailsColumn, type PanelState } from '@deepseek-ai/dsh-client-ui-layout/client'
import { clampWidth } from '@deepseek-ai/dsh-client-ui-layout/client'
import { useSyncExternalStore } from 'react'
import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
import { SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
// Session-mode switch for the SessionProvider stub prop.
const sessionMode = { current: true }
// Render-prop contract stub fed through the standard seat prop (the renderer
// injects the real one in production): session mode runs children(id), empty
// mode runs the empty branch — the frame must work against exactly this
// shape. Typed as the seat's own component type so the branded sessionId
// parameter stays contract-checked.
const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) =>
sessionMode.current ? <>{children('s-test' as Parameters<typeof children>[0])}</> : <>{empty?.() ?? null}</>
/** Observer stub: captures the callback so tests can fire resizes manually. */
let fireResize: (() => void) | null = null
@@ -24,24 +42,35 @@ class ResizeObserverStub {
let frameWidth = 1920
/** Minimal selector hook over an engine instance (the engine carries no hook since the store migration; the renderer binds in production, the spec binds here). */
function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
return <S,>(sel: (s: T) => S): S => sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot))
}
function mountFrame() {
window.innerWidth = frameWidth // first-render viewport source before the observer fires
const sidebar = createSnapshotStore<PanelState>({ open: true, width: 300 })
const details = createSnapshotStore<PanelState>({ open: true, width: 360 })
const instance = createLayoutStore().create()
instance.actions.openDetails() // seed: sidebar at default 300, details open at default 360
const slotCalls: { key: string; props: unknown }[] = []
const renderSlot = ((key: string, owner: object) => {
slotCalls.push({ key, props: owner })
if (key === 'sidebar') return <div data-testid="sidebar-content" />
if (key === 'conversation') return <div data-testid="center-content" />
if (key === 'details') return <div data-testid="details-content" />
return <div data-testid="empty-content" />
}) as AppFrameProps['renderSlot']
const useSessions = ((sel: (s: unknown) => unknown) => sel({ ids: [], byId: {} })) as never
const utils = render(
<AppFrame
useSidebar={sidebar.useSelector}
useDetails={details.useSelector}
setSidebarWidth={(px) => { sidebar.update((d) => { d.width = clampWidth(px, 240, 420) }) }}
setDetailsWidth={(px) => { details.update((d) => { d.width = clampWidth(px, 300, 520) }) }}
sidebar={<div data-testid="sidebar-content" />}
>
<CenterColumn><div data-testid="center-content" /></CenterColumn>
<DetailsColumn><div data-testid="details-content" /></DetailsColumn>
</AppFrame>,
useStore={hookOf(instance) as never}
actions={instance.actions}
renderSlot={renderSlot}
useSessions={useSessions}
SessionProvider={SessionProviderStub}
/>,
)
const frame = utils.container.firstElementChild as HTMLElement
return { sidebar, details, frame, ...utils }
return { instance, frame, slotCalls, ...utils }
}
function tracks(frame: HTMLElement): number[] {
@@ -61,6 +90,8 @@ function drag(handle: Element, fromX: number, toX: number): void {
beforeEach(() => {
frameWidth = 1920
sessionMode.current = true
localStorage.clear() // the layout store persists; instances must not bleed across tests
vi.useFakeTimers()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number)
@@ -83,11 +114,37 @@ afterEach(() => {
})
describe('AppFrame', () => {
it('renders three tracks from panel state', () => {
it('renders three tracks from store state', () => {
const { frame } = mountFrame()
expect(tracks(frame)).toEqual([300, 360])
})
it('renders the session pair with empty owner shares (sessionId is framework-standard)', () => {
const { slotCalls, getByTestId } = mountFrame()
expect(getByTestId('center-content')).toBeTruthy()
expect(getByTestId('details-content')).toBeTruthy()
const keys = slotCalls.map((c) => c.key)
expect(keys).toContain('conversation')
expect(keys).toContain('details')
expect(keys).not.toContain('conversation.empty')
expect(slotCalls.find((c) => c.key === 'conversation')!.props).toEqual({})
expect(slotCalls.find((c) => c.key === 'details')!.props).toEqual({})
})
it('renders the empty branch through conversation.empty when no session is current', () => {
sessionMode.current = false
const { slotCalls, getByTestId, queryByTestId } = mountFrame()
expect(getByTestId('empty-content')).toBeTruthy()
expect(queryByTestId('center-content')).toBeNull()
expect(slotCalls.map((c) => c.key)).toContain('conversation.empty')
expect(slotCalls.map((c) => c.key)).not.toContain('conversation')
})
it('sidebar slot receives live concession output as owner props', () => {
const { slotCalls } = mountFrame()
expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 300 })
})
it('sidebar drag widens through rAF-batched pointer moves', () => {
const { frame } = mountFrame()
const handles = frame.querySelectorAll('[class*="handle"]')
@@ -104,21 +161,31 @@ describe('AppFrame', () => {
it('drag base is the rendered (concession-clamped) width, not the preference', () => {
frameWidth = 1250 // step-2 squeeze: details renders 310 while preference is 360
const { frame, details } = mountFrame()
const { frame, instance } = mountFrame()
expect(tracks(frame)).toEqual([300, 310])
const handles = frame.querySelectorAll('[class*="handle"]')
drag(handles[1]!, 940, 950) // shrink by 10 from the rendered width
expect(details.getSnapshot().width).toBe(300)
expect(instance.getSnapshot().details).toBe(300)
})
it('details column stays mounted at zero width', () => {
const { frame, details, getByTestId } = mountFrame()
act(() => { details.update((d) => { d.open = false }) })
const { frame, instance, getByTestId } = mountFrame()
act(() => { instance.actions.closeDetails() })
expect(tracks(frame)).toEqual([300, 0])
expect(getByTestId('details-content')).toBeTruthy()
expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
})
it('closed sidebar keeps its compact rail with mounted slot content and collapsed owner props', () => {
const { frame, instance, slotCalls, getByTestId } = mountFrame()
act(() => { instance.actions.toggleSidebar() })
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360])
expect(getByTestId('sidebar-content')).toBeTruthy()
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
const lastSidebarCall = slotCalls.filter((c) => c.key === 'sidebar').at(-1)!
expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED })
})
it('viewport shrink triggers the concession chain via ResizeObserver', () => {
const { frame } = mountFrame()
frameWidth = 1250
@@ -130,31 +197,31 @@ describe('AppFrame', () => {
})
it('drag handles disappear for collapsed columns', () => {
const { frame, details, sidebar } = mountFrame()
const { frame, instance } = mountFrame()
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(2)
act(() => { details.update((d) => { d.open = false }) })
act(() => { instance.actions.closeDetails() })
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
act(() => { sidebar.update((d) => { d.open = false }) })
act(() => { instance.actions.toggleSidebar() })
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(0)
})
})
describe('AppFrame — guard branches', () => {
it('pointer moves without capture are ignored (no width write)', () => {
const { frame, sidebar } = mountFrame()
const { frame, instance } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
const before = sidebar.getSnapshot().width
const before = instance.getSnapshot().sidebar
// Move + up without a preceding pointerdown: hasPointerCapture is false.
act(() => {
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 9, clientX: 500, bubbles: true }))
vi.advanceTimersByTime(20)
handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 9, clientX: 500, bubbles: true }))
})
expect(sidebar.getSnapshot().width).toBe(before)
expect(instance.getSnapshot().sidebar).toBe(before)
})
it('two moves inside one frame coalesce through the pending rAF', () => {
const { frame, sidebar } = mountFrame()
const { frame, instance } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
act(() => {
@@ -165,11 +232,11 @@ describe('AppFrame — guard branches', () => {
vi.advanceTimersByTime(20)
})
act(() => { handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 340, bubbles: true })) })
expect(sidebar.getSnapshot().width).toBe(340)
expect(instance.getSnapshot().sidebar).toBe(340)
})
it('pointerup with a pending rAF cancels it and commits the final position', () => {
const { frame, sidebar } = mountFrame()
const { frame, instance } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
act(() => {
@@ -177,7 +244,7 @@ describe('AppFrame — guard branches', () => {
// No timer advance: the rAF is still pending when pointerup arrives.
handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 360, bubbles: true }))
})
expect(sidebar.getSnapshot().width).toBe(360)
expect(instance.getSnapshot().sidebar).toBe(360)
})
it('zero-width resize reports are ignored (display:none window)', () => {
@@ -199,7 +266,7 @@ describe('AppFrame — unmount with an in-flight resize frame', () => {
expect(() => { vi.advanceTimersByTime(20) }).not.toThrow()
})
it('double resize inside one frame rides the pending rAF (?"?= guard)', () => {
it('double resize inside one frame rides the pending rAF (??= guard)', () => {
const { frame } = mountFrame()
frameWidth = 1250
act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) })

View File

@@ -1,15 +1,14 @@
// @vitest-environment jsdom
// Client apply wiring: ctx.layout provided, the four layout-owned slots
// defined, teardown cascades (service unprovided + slot specs removed + list
// subscription dropped). Node half and the invariant companion ride along —
// they are one-line surfaces the aggregate coverage gate still requires
// exercised.
// Client apply wiring under the terminal register form: ctx.layout provided,
// ONE register() call declares the four child slots + seats the store factory
// + wires the panel actions through the inject hook; teardown cascades
// (service unprovided + declarations gone + registration cleared). Node half
// and the invariant companion ride along — one-line surfaces the aggregate
// coverage gate still requires exercised.
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout'
import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant'
@@ -18,8 +17,6 @@ async function bench() {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
ctx.provide('sessions', { list })
return { ctx, slots: ctx.get('slots') as SlotsService }
}
@@ -28,28 +25,31 @@ describe('ui-layout client apply', () => {
expect(inject).toContain('slots')
})
it('provides ctx.layout and defines the four layout-owned slots', async () => {
it('provides ctx.layout and registers AppFrame into root with the four child declarations', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: ['slots'], apply })
await fiber.await()
expect(ctx.get('layout')).toBeInstanceOf(LayoutService)
// The one register() call occupied 'root'…
expect(slots.entries('root')).toHaveLength(1)
// …and declared the four children in the ledger.
expect(slots.spec('sidebar')).toEqual({ kind: 'single', scope: 'root' })
expect(slots.spec('conversation')).toEqual({ kind: 'single', scope: 'session' })
expect(slots.spec('details')).toEqual({ kind: 'single', scope: 'session' })
expect(slots.spec('conversation.empty')).toEqual({ kind: 'single', scope: 'root' })
})
it('teardown unwinds service, slot specs, and the prune subscription', async () => {
it('teardown unwinds the service, the root registration, and the child declarations', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: ['slots'], apply })
await fiber.await()
const layout = ctx.get('layout') as LayoutService
const disposeSpy = vi.spyOn(layout, 'dispose')
await fiber.dispose()
expect(ctx.get('layout')).toBeUndefined()
expect(slots.entries('root')).toHaveLength(0)
expect(slots.spec('sidebar')).toBeUndefined()
expect(slots.spec('conversation.empty')).toBeUndefined()
expect(disposeSpy).toHaveBeenCalledTimes(1)
// The built-in root declaration survives entry teardown (runtime-owned).
expect(slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
})
})

View File

@@ -1,11 +1,12 @@
import { describe, expect, it } from 'vitest'
import {
CENTER_MIN, clampWidth, computeColumns,
DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MIN,
} from '@deepseek-ai/dsh-client-ui-layout/client'
DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT, SIDEBAR_MIN,
} from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
const open = (width: number) => ({ open: true, width })
const closed = (width: number) => ({ open: false, width })
// Numeric preference form (0 = closed); helpers keep the scenario names readable.
const open = (width: number) => width
const closed = (_width: number) => 0
describe('clampWidth', () => {
it('clamps into the range and rounds', () => {
@@ -21,8 +22,9 @@ describe('computeColumns', () => {
expect(cols).toEqual({ sidebar: 300, center: 1920 - 300 - 360, details: 360 })
})
it('closed panels contribute zero width', () => {
expect(computeColumns(1920, closed(300), closed(360))).toEqual({ sidebar: 0, center: 1920, details: 0 })
it('closed sidebar keeps its compact rail while closed details contribute zero width', () => {
expect(computeColumns(1920, closed(300), closed(360)))
.toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 1920 - SIDEBAR_COLLAPSED, details: 0 })
})
it('preferences beyond the clamp range are clamped before solving', () => {
@@ -69,10 +71,14 @@ describe('computeColumns', () => {
})
it('sidebar-closed narrow window: details concedes then auto-closes', () => {
const fits = computeColumns(DETAILS_MIN + CENTER_MIN, closed(300), open(DETAILS_DEFAULT))
expect(fits).toEqual({ sidebar: 0, center: CENTER_MIN, details: DETAILS_MIN })
const starved = computeColumns(DETAILS_MIN + CENTER_MIN - 1, closed(300), open(DETAILS_DEFAULT))
expect(starved).toEqual({ sidebar: 0, center: DETAILS_MIN + CENTER_MIN - 1, details: 0 })
const fits = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN, closed(300), open(DETAILS_DEFAULT))
expect(fits).toEqual({ sidebar: SIDEBAR_COLLAPSED, center: CENTER_MIN, details: DETAILS_MIN })
const starved = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN - 1, closed(300), open(DETAILS_DEFAULT))
expect(starved).toEqual({
sidebar: SIDEBAR_COLLAPSED,
center: DETAILS_MIN + CENTER_MIN - 1,
details: 0,
})
})
it('tiny viewport: both panels yield everything to center', () => {
@@ -92,9 +98,9 @@ describe('computeColumns', () => {
})
describe('computeColumns — degenerate viewports', () => {
it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes all', () => {
// Reaches step 4's re-solve with s0 = 0 (the closed-sidebar arm).
it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes the rest', () => {
// Reaches step 4's re-solve with the compact rail as the sidebar floor.
expect(computeColumns(500, closed(300), open(DETAILS_DEFAULT)))
.toEqual({ sidebar: 0, center: 500, details: 0 })
.toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 500 - SIDEBAR_COLLAPSED, details: 0 })
})
})

View File

@@ -0,0 +1,73 @@
// @vitest-environment jsdom
/**
* createLayoutStore unit account: init shape, the action write set (clamp
* inside actions), and the persist key round-trip over jsdom localStorage.
* Uses the test-sanctioned path: factory self-call + .create() gives the
* real engine instance (same create path as production).
*/
import { beforeEach, describe, expect, it } from 'vitest'
import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
import {
DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN,
SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
} from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
const PERSIST_KEY = 'dsh.layout.panels'
beforeEach(() => { localStorage.clear() })
describe('createLayoutStore', () => {
it('initializes with sidebar open at default and details closed', () => {
const { store } = createLayoutStore().create()
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0 })
})
it('each create() is an independent instance (factory is not a singleton)', () => {
const a = createLayoutStore().create()
const b = createLayoutStore().create()
a.actions.setSidebar(400)
expect(b.store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT)
})
it('setSidebar/setDetails clamp into the contract ranges', () => {
const { store, actions } = createLayoutStore().create()
actions.setSidebar(1)
expect(store.getSnapshot().sidebar).toBe(SIDEBAR_MIN)
actions.setSidebar(9999)
expect(store.getSnapshot().sidebar).toBe(SIDEBAR_MAX)
actions.setDetails(1)
expect(store.getSnapshot().details).toBe(DETAILS_MIN)
actions.setDetails(9999)
expect(store.getSnapshot().details).toBe(DETAILS_MAX)
})
it('toggleSidebar flips closed <-> contract default (drag width forgotten)', () => {
const { store, actions } = createLayoutStore().create()
actions.setSidebar(400)
actions.toggleSidebar()
expect(store.getSnapshot().sidebar).toBe(0)
actions.toggleSidebar()
expect(store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT)
})
it('openDetails is a no-op when already open; closeDetails zeroes', () => {
const { store, actions } = createLayoutStore().create()
actions.openDetails()
expect(store.getSnapshot().details).toBe(DETAILS_DEFAULT)
actions.setDetails(500)
actions.openDetails()
expect(store.getSnapshot().details).toBe(500)
actions.closeDetails()
expect(store.getSnapshot().details).toBe(0)
})
it('persists under dsh.layout.panels and rehydrates on the next create', () => {
const first = createLayoutStore().create()
first.actions.setSidebar(320)
first.actions.openDetails()
expect(JSON.parse(localStorage.getItem(PERSIST_KEY) ?? '{}')).toEqual({ sidebar: 320, details: DETAILS_DEFAULT })
const second = createLayoutStore().create()
expect(second.store.getSnapshot()).toEqual({ sidebar: 320, details: DETAILS_DEFAULT })
})
})

View File

@@ -1,138 +1,57 @@
// @vitest-environment jsdom
/**
* LayoutService over the real snapshot-store engine (persist rides jsdom
* localStorage). ctx is faked down to the one surface the service reads:
* ctx.sessions.list as a real store, so prune subscriptions are exercised
* for real.
* LayoutService behavior: the cross-plugin panel-action face. Geometry
* lives in the entry store (layout-store.spec.ts) — here we assert the
* delegation seam: attachPanels wiring, the three actions forwarding, the
* unwired fail-loud, and re-attach overwriting a stale action set.
*/
import { beforeEach, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { LayoutService, DETAILS_DEFAULT, SIDEBAR_DEFAULT } from '@deepseek-ai/dsh-client-ui-layout/client'
import { describe, expect, it, vi } from 'vitest'
import { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/src/client/service.ts'
import type { PanelActions } from '@deepseek-ai/dsh-client-ui-layout/src/client/service.ts'
function makeCtx() {
const list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
// The service resolves sessions via ctx.get (typed merge suspended, see service).
const ctx = { get: (name: string) => (name === 'sessions' ? { list } : undefined) } as unknown as Context
return { ctx, list }
function fakePanels(): PanelActions {
return {
setSidebar: vi.fn(),
setDetails: vi.fn(),
toggleSidebar: vi.fn(),
openDetails: vi.fn(),
closeDetails: vi.fn(),
}
}
/** Test-side brand: specs mint ids the wire would normally brand. */
const sid = (s: string): SessionId => s as SessionId
const summary = (id: SessionId) => ({ id, title: id as string, displayTitle: id as string, running: false, updatedAt: 1 })
beforeEach(() => { localStorage.clear() })
describe('LayoutService', () => {
it('defaults: sidebar open 300, details closed 360, empty nav', () => {
const svc = new LayoutService(makeCtx().ctx)
expect(svc.sidebar.getSnapshot()).toEqual({ open: true, width: SIDEBAR_DEFAULT })
expect(svc.details.getSnapshot()).toEqual({ open: false, width: DETAILS_DEFAULT })
expect(svc.current.getSnapshot()).toEqual({ viewFor: {} })
svc.dispose()
it('forwards the three panel actions to the attached set', () => {
const service = new LayoutService()
const panels = fakePanels()
service.attachPanels(panels)
service.toggleSidebar()
service.openDetails()
service.closeDetails()
expect(panels.toggleSidebar).toHaveBeenCalledTimes(1)
expect(panels.openDetails).toHaveBeenCalledTimes(1)
expect(panels.closeDetails).toHaveBeenCalledTimes(1)
expect(panels.setSidebar).not.toHaveBeenCalled()
expect(panels.setDetails).not.toHaveBeenCalled()
})
it('open validates against sessions.list and selects', () => {
const { ctx, list } = makeCtx()
const svc = new LayoutService(ctx)
expect(() => { svc.open(sid('nope')) }).toThrow(/unknown session/)
list.update((d) => { d.ids.push(sid('s1')); d.byId[sid('s1')] = summary(sid('s1')) })
svc.open(sid('s1'))
expect(svc.current.getSnapshot().sessionId).toBe('s1')
svc.dispose()
it('fails loud before the root entry wired its actions', () => {
const service = new LayoutService()
expect(() => { service.toggleSidebar() }).toThrow(/panel actions not wired/)
expect(() => { service.openDetails() }).toThrow(/panel actions not wired/)
expect(() => { service.closeDetails() }).toThrow(/panel actions not wired/)
})
it('width setters clamp into contract ranges', () => {
const svc = new LayoutService(makeCtx().ctx)
svc.setSidebarWidth(10)
expect(svc.sidebar.getSnapshot().width).toBe(240)
svc.setSidebarWidth(10_000)
expect(svc.sidebar.getSnapshot().width).toBe(420)
svc.setDetailsWidth(10)
expect(svc.details.getSnapshot().width).toBe(300)
svc.setDetailsWidth(10_000)
expect(svc.details.getSnapshot().width).toBe(520)
svc.dispose()
})
it('re-attach overwrites the stale action set (entry re-register)', () => {
const service = new LayoutService()
const stale = fakePanels()
const fresh = fakePanels()
service.attachPanels(stale)
service.attachPanels(fresh)
it('toggle and open/close flip flags without touching widths', () => {
const svc = new LayoutService(makeCtx().ctx)
svc.toggleSidebar()
expect(svc.sidebar.getSnapshot()).toEqual({ open: false, width: SIDEBAR_DEFAULT })
svc.openDetails()
expect(svc.details.getSnapshot().open).toBe(true)
svc.closeDetails()
expect(svc.details.getSnapshot().open).toBe(false)
svc.dispose()
})
service.toggleSidebar()
it('prune clears viewFor entries and the current selection of removed sessions', () => {
const { ctx, list } = makeCtx()
const svc = new LayoutService(ctx)
list.update((d) => {
d.ids.push(sid('s1'), sid('s2'))
d.byId[sid('s1')] = summary(sid('s1'))
d.byId[sid('s2')] = summary(sid('s2'))
})
svc.open(sid('s1'))
svc.openView(sid('s1'), 'chat')
svc.openView(sid('s2'), 'chat')
list.update((d) => { d.ids = [sid('s2')]; d.byId = { [sid('s2')]: d.byId[sid('s2')]! } })
expect(svc.current.getSnapshot().sessionId).toBeUndefined()
expect(svc.current.getSnapshot().viewFor).toEqual({ s2: 'chat' })
svc.dispose()
})
it('prune leaves untouched state alone (no gratuitous store writes)', () => {
const { ctx, list } = makeCtx()
const svc = new LayoutService(ctx)
list.update((d) => { d.ids.push(sid('s1')); d.byId[sid('s1')] = summary(sid('s1')) })
svc.open(sid('s1'))
const before = svc.current.getSnapshot()
list.update((d) => { d.byId[sid('s1')] = { ...d.byId[sid('s1')]!, title: 'renamed' } })
expect(svc.current.getSnapshot()).toBe(before)
svc.dispose()
})
it('persists panel state and nav across instances (fresh service, same storage)', () => {
const first = new LayoutService(makeCtx().ctx)
first.setSidebarWidth(320)
first.openDetails()
first.dispose()
const second = new LayoutService(makeCtx().ctx)
expect(second.sidebar.getSnapshot().width).toBe(320)
expect(second.details.getSnapshot().open).toBe(true)
second.dispose()
})
it('dispose stops pruning', () => {
const { ctx, list } = makeCtx()
const svc = new LayoutService(ctx)
list.update((d) => { d.ids.push(sid('s1')); d.byId[sid('s1')] = summary(sid('s1')) })
svc.open(sid('s1'))
svc.dispose()
list.update((d) => { d.ids = []; d.byId = {} })
expect(svc.current.getSnapshot().sessionId).toBe('s1')
})
})
describe('LayoutService — construction and prune edge branches', () => {
it('throws loud when the sessions service is absent', () => {
const bare = { get: () => undefined } as unknown as Context
expect(() => new LayoutService(bare)).toThrow(/sessions service unavailable/)
})
it('prunes stale viewFor while the current selection stays valid', () => {
// Covers the prune branch where staleView holds but staleCurrent does not.
const { ctx, list } = makeCtx()
const svc = new LayoutService(ctx)
list.update((d) => { d.ids.push(sid('s1'), sid('s2')); d.byId[sid('s1')] = summary(sid('s1')); d.byId[sid('s2')] = summary(sid('s2')) })
svc.open(sid('s1'))
svc.openView(sid('s2'), 'chat')
list.update((d) => { d.ids = [sid('s1')]; d.byId = { [sid('s1')]: d.byId[sid('s1')]! } })
expect(svc.current.getSnapshot().sessionId).toBe('s1')
expect(svc.current.getSnapshot().viewFor).toEqual({})
svc.dispose()
expect(stale.toggleSidebar).not.toHaveBeenCalled()
expect(fresh.toggleSidebar).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,15 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"jsx": "react-jsx",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
"outDir": "lib/types"
},
"include": [
"src"

View File

@@ -1,15 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"jsx": "react-jsx",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
"outDir": "lib/types"
},
"include": [
"src"

View File

@@ -1,8 +1,12 @@
# @deepseek-ai/dsh-client-ui-sidebar
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Contract: api-contracts v3 §6.
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Collapse morphs the four control rows into the layout-owned 56px rail (expand / new session / new workspace / search — search expands and focuses the search box) plus the settings foot: geometry animates on the deepsuite curve while wide-only content cross-fades and unmounts at settle. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — tree hook, current-session hook, actions) and `SidebarRootComponentProps = OwnerOf<'sidebar'> & SidebarRootInjected` (the owner share referenced from ui-layout's slot declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory binds layout/sessions off `RootBinding<ClientContext>`.
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx.
There is no plugin store: rows derive in the component (`useMemo` over the `useSessions` snapshot + local expansion/search state) through the pure `deriveRows` in `tree.ts`.
The `/client` export surface is the plugin body (`apply`/`inject`) plus the contract types only — SidebarRoot, the row components, and the tree derivation are internal (the slot registration closes over them; tests import src paths directly).
## Model Experience

View File

@@ -38,7 +38,6 @@
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},

View File

@@ -1,39 +1,64 @@
/* Sidebar column (figma 133:7629): vertical stack, gap 8, padding 16/6,
sidebar fill + 1px right border painted by the layout column. Header block
(logo + New Session) and list area (section header + search + cells) carry
their own inner gaps per the style spec (1.2 / 1.3). */
/* Sidebar column (figma 133:7629): vertical stack, padding 16/6, sidebar
fill + 1px right border painted by the layout column. Collapse morphs in
place: the four control rows persist into the 56px rail (one icon each,
x-converged by the shrinking column), geometry rides the deepsuite curve
while wide-only content cross-fades 200ms; explicit margins own the
vertical rhythm in both states so every gap can transition. */
.root {
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
padding: 6px 16px;
box-sizing: border-box;
background: var(--dsw-specific-sidebar-fill);
color: var(--dsw-alias-label-primary);
font-size: 14px;
transition: padding var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* Header block (figma 133:7630): logo row + New Session, gap 16, padBottom 12. */
.headerBlock {
flex: none;
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 12px;
.root.collapsed {
padding-top: 14px;
}
/* Logo row: 60px, brand mark left, collapse button right.
figma pad is (l,t,r,b)=(4,8,4,8) — horizontal 4, vertical 8. */
/* Wide-only content: fades ahead of the geometry (200ms vs 300ms) and
unmounts once the collapse settles; remounts fade back in. */
.wide {
animation: wide-in 200ms var(--ds-ease-in-out);
transition: opacity 200ms var(--ds-ease-in-out);
}
.collapsed .wide {
opacity: 0;
}
@keyframes wide-in {
from { opacity: 0; }
}
/* Logo row (figma pad (4,8,4,8)): brand left, panel toggle right-anchored —
the toggle is the rail's expand control and slides in with the right edge. */
.logoRow {
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
height: 60px;
padding: 8px 4px;
margin-bottom: 16px;
box-sizing: border-box;
overflow: hidden;
transition:
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.collapsed .logoRow {
height: 24px;
padding: 0;
margin-bottom: 8px;
}
/* Brand group (figma I133:7632): fish + wordmark ride the text ink
@@ -79,13 +104,22 @@
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
transition:
width var(--ds-transition-duration-slow) var(--ds-ease-in-out),
height var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.iconButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* New Session: 38px capsule (figma 133:7634). */
.collapsed .iconButton {
width: 24px;
height: 24px;
}
/* New Session: 38px capsule (figma 133:7634) morphing into the rail's plain
icon control — border and fill fade with the label. */
.newSession {
flex: none;
display: flex;
@@ -94,6 +128,7 @@
gap: 6px;
height: 38px;
padding: 8px 16px;
margin-bottom: 20px; /* former headerBlock padBottom 12 + root gap 8 */
box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 24px;
@@ -103,65 +138,84 @@
font-weight: 510;
line-height: 22px;
cursor: pointer;
overflow: hidden;
transition:
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out),
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out),
border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background-color 200ms var(--ds-ease-in-out);
}
.newSession:hover {
background: var(--dsw-alias-button-floating-hover);
}
/* List area (figma 133:7640): section header + search + cells, gap 4.
Relative for the bottom fade overlay. */
.listArea {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 4px;
.collapsed .newSession {
height: 24px;
padding: 0;
margin-bottom: 8px;
gap: 0;
border-color: transparent;
background: transparent;
}
/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
transparent -> sidebar fill so it tracks the theme. */
.fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 72px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
pointer-events: none;
.collapsed .newSession:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Batch separator (figma 133:7661): 20px spacer after an expanded project's
session run, before the next project row. */
.batchGap {
flex: none;
height: 20px;
.newSessionLabel {
max-width: 200px;
overflow: hidden;
white-space: nowrap;
transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons. */
.collapsed .newSessionLabel {
max-width: 0;
}
/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons;
the right-anchored new-workspace button is the row's rail survivor. */
.sectionHeader {
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
height: 36px;
padding-left: 12px;
margin-bottom: 4px;
box-sizing: border-box;
border-radius: 12px;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
transition:
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.collapsed .sectionHeader {
height: 24px;
padding-left: 0;
margin-bottom: 8px;
}
.sectionLabel {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
line-height: 20px;
}
/* Search input: 38px capsule (figma 133:7649). Upstream binds a dedicated
design-system variable (light #F1F3F5 / dark #1B1B1C) matching no shipped
alias — a component token pinned to the static scale mirrors it (ruled
compliant: indirect via custom property, upstream-variable equivalent). */
/* Search input: 38px capsule (figma 133:7649) morphing into the rail's
search control. Upstream binds a dedicated design-system variable (light
#F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token
pinned to the static scale mirrors it (ruled compliant: indirect via
custom property, upstream-variable equivalent). */
.search {
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-75);
flex: none;
@@ -169,19 +223,64 @@
align-items: center;
gap: 8px;
height: 38px;
margin-bottom: 8px; /* + 4px area gap = 12px to the first cell (spec padB12) */
margin-bottom: 12px; /* former listArea gap 4 + own 8 (spec padB12 to the first cell) */
padding: 0 14px;
box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 24px;
background: var(--dsh-search-input-fill);
color: var(--dsw-alias-label-caption);
overflow: hidden;
transition:
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out),
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out),
border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background-color 200ms var(--ds-ease-in-out);
}
:global(body[data-ds-dark-theme]) .search {
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-900);
}
.collapsed .search {
height: 24px;
padding: 0;
margin-bottom: 8px;
gap: 0;
border-color: transparent;
background: transparent;
}
/* The capsule's leading icon, upgraded to the rail's search control. While
expanded it is decorative: pointer-events off so clicks reach the label
(native input focus); collapsed it becomes the hit target. */
.searchButton {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
padding: 0;
background: transparent;
pointer-events: none;
color: inherit;
}
.collapsed .searchButton {
pointer-events: auto;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
}
.collapsed .searchButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.searchInput {
flex: 1;
min-width: 0;
@@ -212,6 +311,44 @@
color: var(--dsw-alias-label-secondary);
}
/* Tree seat: always mounted so the foot never moves; the tree content inside
is wide-only and clips while the column squeezes. */
.listArea {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Relative for the bottom fade overlay. */
.treeBody {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
}
/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
transparent -> sidebar fill so it tracks the theme. */
.fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 72px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
pointer-events: none;
}
/* Batch separator (figma 133:7661): 20px spacer after an expanded project's
session run, before the next project row. */
.batchGap {
flex: none;
height: 20px;
}
/* Tree list: the only scrolling region. */
.list {
flex: 1;
@@ -229,20 +366,57 @@
font-size: 13px;
}
/* Foot: settings entry (figma 133:7668). */
/* Foot: settings entry (figma 133:7668). Left padding lands the 14px glyph
on the rail's icon axis when collapsed. */
.foot {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 29px;
margin: 10px 0;
margin: 18px 0 10px; /* former root gap 8 + own 10 above; root padBottom 6 below */
padding: 0 2px 0 6px;
border-radius: 12px;
cursor: pointer;
overflow: hidden;
color: var(--dsw-alias-label-primary);
transition:
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.foot:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.collapsed .foot {
gap: 0;
padding: 0 0 0 5px;
}
.footLabel {
max-width: 120px;
overflow: hidden;
white-space: nowrap;
transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.collapsed .footLabel {
max-width: 0;
}
@media (prefers-reduced-motion: reduce) {
.root,
.wide,
.logoRow,
.iconButton,
.newSession,
.newSessionLabel,
.sectionHeader,
.search,
.foot,
.footLabel {
transition: none;
animation: none;
}
}

View File

@@ -1,11 +1,19 @@
/**
* SidebarRoot (figma 133:7629): logo row + collapse, New Session, search,
* WorkSpace section header with the group-by menu, session tree list,
* Settings foot. Pure presentational — data and actions arrive through the
* inject surface; the tree store is subscribed via useTree, never derived in
* render.
* SidebarRoot (figma 133:7629): logo row + collapse, New Session, WorkSpace
* section header with the group-by menu, search, session tree list, Settings
* foot. Pure presentational — the session list arrives through the standard
* useSessions hook, viewing state (expansion, search) is local component
* state, and rows are derived in render via useMemo (slot design section 6:
* derived data is a pure function, no materializing store).
*
* Collapse is a morph, not a swap: the four control rows persist into the
* 56px rail (collapse/new session/new workspace/search, one icon each, same
* top-down order as their expanded rows) and animate their geometry on the
* deepsuite curve, while wide-only content (brand, labels, input, tree)
* cross-fades out and unmounts once the collapse settles — dropping the
* sessions subscription. Rail search expands and focuses the search box.
*/
import { Fragment, useState } from 'react'
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
FishLogo,
@@ -14,9 +22,13 @@ import {
Menu,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarRootComponentProps } from './contract/slots.ts'
import { deriveRows } from './tree.ts'
import { ProjectRowItem, SessionRowItem } from './Rows.tsx'
import css from './SidebarRoot.module.css'
/** Wide-content unmount delay; matches --ds-transition-duration-slow (0.3s). */
const COLLAPSE_SETTLE_MS = 300
const GROUP_BY_ITEMS = [
{ id: 'workspace', label: 'WorkSpace' },
// Update/Status grouping has no design yet (figma §3) — visible, disabled.
@@ -24,17 +36,53 @@ const GROUP_BY_ITEMS = [
{ id: 'status', label: 'Status', disabled: true },
]
/**
* Render the sidebar column.
* @param props - composed slot props (owner share + injected surface, contract/slots.ts).
* @returns the sidebar element tree.
*/
export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootComponentProps) {
const rows = useTree((s) => s.rows)
const query = useTree((s) => s.query)
const groupBy = useTree((s) => s.groupBy)
const current = useCurrent()
const [menuOpen, setMenuOpen] = useState(false)
/** Immutable membership toggle for the local expansion arrays. */
function toggled(list: readonly string[], key: string): string[] {
return list.includes(key) ? list.filter((k) => k !== key) : [...list, key]
}
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
function GroupByMenu() {
const [open, setOpen] = useState(false)
return (
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={GROUP_BY_ITEMS}
selectedId="workspace"
onSelect={() => { setOpen(false) }}
align="end"
anchor={(
<button
type="button"
className={clsx(css.iconButton, css.wide)}
aria-label="Group by"
onClick={() => { setOpen((v) => !v) }}
>
<IconPersonalizationOutline16 />
</button>
)}
/>
)
}
type SessionTreeProps = Pick<SidebarRootComponentProps, 'useSessions' | 'onOpen' | 'onCreate'> & {
/** Live search filter owned by the root (the query outlives the tree). */
query: string
}
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) {
const list = useSessions((s) => s)
// Wave-2 seam: row highlight expects `current` on the sessions list
// snapshot (sessions.current lives with the runtime sessions service).
const current = useSessions((s) => s.current)
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
const rows = useMemo(
() => deriveRows(list, { expandedProjects, expandedSessions, query }),
[list, expandedProjects, expandedSessions, query],
)
const now = Date.now()
// Presentational lookup (not tree derivation): the group holding the
@@ -47,83 +95,7 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
}
return (
<div className={css.root}>
<div className={css.headerBlock}>
<div className={css.logoRow}>
<span className={css.brand}>
{/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */}
<FishLogo size={23} />
<span className={css.wordmark}>deepseek</span>
<span className={css.badge}>HARNESS</span>
</span>
<button
type="button"
className={css.iconButton}
aria-label="Collapse sidebar"
onClick={() => { actions.toggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
</div>
<button type="button" className={css.newSession} onClick={() => { actions.create() }}>
<IconNewChatOutline16 size={14} />
New Session
</button>
</div>
<div className={css.listArea}>
<div className={css.sectionHeader}>
<span className={css.sectionLabel}>WorkSpace</span>
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={GROUP_BY_ITEMS}
selectedId={groupBy}
onSelect={() => { setMenuOpen(false) }}
align="end"
anchor={(
<button
type="button"
className={css.iconButton}
aria-label="Group by"
onClick={() => { setMenuOpen((v) => !v) }}
>
<IconPersonalizationOutline16 />
</button>
)}
/>
<button
type="button"
className={css.iconButton}
aria-label="New workspace"
onClick={() => { actions.create() }}
>
<IconProjectAddOutline16 />
</button>
</div>
<label className={css.search}>
<IconSearchOutline16 size={14} />
<input
className={css.searchInput}
type="text"
placeholder="Search name, keywords..."
value={query}
onChange={(e) => { tree.setQuery(e.target.value) }}
/>
{query !== '' && (
<button
type="button"
className={css.clearButton}
aria-label="Clear search"
onClick={() => { tree.setQuery('') }}
>
<IconCloseFill14 />
</button>
)}
</label>
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label="Sessions">
{rows.length === 0 && (
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
@@ -136,8 +108,8 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
<ProjectRowItem
row={row}
active={row.key === activeGroup}
onToggle={() => { tree.toggleProject(row.key) }}
onCreate={() => { actions.create(row.cwd) }}
onToggle={() => { setExpandedProjects((l) => toggled(l, row.key)) }}
onCreate={() => { onCreate(row.cwd) }}
/>
</Fragment>
)
@@ -147,17 +119,134 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
row={row}
selected={row.id === current}
now={now}
onOpen={() => { actions.open(row.id) }}
onToggle={() => { tree.toggleSession(row.id) }}
onOpen={() => { onOpen(row.id) }}
onToggle={() => { setExpandedSessions((l) => toggled(l, row.id)) }}
/>
))}
</div>
<span className={css.fade} />
</div>
)
}
/**
* Render the sidebar column.
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
* @returns the sidebar element tree.
*/
export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
// The query outlives the tree and the input (both wide-only) so collapsing
// does not silently drop an in-progress filter.
const [query, setQuery] = useState('')
const searchInput = useRef<HTMLInputElement | null>(null)
// Wide content stays mounted while the collapse animates (fading via
// .collapsed .wide), unmounts at settle, and remounts right away on expand.
const [settled, setSettled] = useState(collapsed)
useEffect(() => {
if (!collapsed) { setSettled(false); return }
const timer = window.setTimeout(() => { setSettled(true) }, COLLAPSE_SETTLE_MS)
return () => { window.clearTimeout(timer) }
}, [collapsed])
const wide = !collapsed || !settled
// Rail search = expand + land in the search box: the flag arms before the
// expand toggle; once expanded the input is mounted and takes focus.
const [searchOnExpand, setSearchOnExpand] = useState(false)
useEffect(() => {
if (!collapsed && searchOnExpand) {
searchInput.current?.focus()
setSearchOnExpand(false)
}
}, [collapsed, searchOnExpand])
return (
<div className={clsx(css.root, collapsed && css.collapsed)}>
<div className={css.logoRow}>
{wide && (
<span className={clsx(css.brand, css.wide)}>
{/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */}
<FishLogo size={23} />
<span className={css.wordmark}>deepseek</span>
<span className={css.badge}>HARNESS</span>
</span>
)}
<button
type="button"
className={css.iconButton}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onClick={() => { onToggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
</div>
<div className={clsx(css.foot)} role="button" tabIndex={0} aria-label="Settings">
<button
type="button"
className={css.newSession}
aria-label="New session"
onClick={() => { onCreate() }}
>
<IconNewChatOutline16 size={14} />
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
</button>
<div className={css.sectionHeader}>
{wide && <span className={clsx(css.sectionLabel, css.wide)}>WorkSpace</span>}
{wide && <GroupByMenu />}
<button
type="button"
className={css.iconButton}
aria-label="New workspace"
onClick={() => { onCreate() }}
>
<IconProjectAddOutline16 />
</button>
</div>
{/* Expanded: the row is a click-to-focus field (the leading icon is
decorative). Collapsed: the icon is the rail's search control. */}
<div className={css.search} onClick={() => { if (!collapsed) searchInput.current?.focus() }}>
<button
type="button"
className={css.searchButton}
aria-label="Search sessions"
tabIndex={collapsed ? 0 : -1}
onClick={() => { if (collapsed) { setSearchOnExpand(true); onToggleSidebar() } }}
>
<IconSearchOutline16 size={14} />
</button>
{wide && (
<input
ref={searchInput}
className={clsx(css.searchInput, css.wide)}
type="text"
placeholder="Search name, keywords..."
value={query}
onChange={(e) => { setQuery(e.target.value) }}
/>
)}
{wide && query !== '' && (
<button
type="button"
className={clsx(css.clearButton, css.wide)}
aria-label="Clear search"
onClick={() => { setQuery('') }}
>
<IconCloseFill14 />
</button>
)}
</div>
{/* Always-mounted seat: its flex slot pins the foot to the bottom in
both states while the tree itself is wide-only. */}
<div className={css.listArea}>
{wide && <SessionTree useSessions={useSessions} onOpen={onOpen} onCreate={onCreate} query={query} />}
</div>
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
<IconSettingsOutline14 />
Settings
{wide && <span className={clsx(css.footLabel, css.wide)}>Settings</span>}
</div>
</div>
)

View File

@@ -1,49 +1,42 @@
/**
* Sidebar slot contract: the registrant-side props composition for the
* layout-owned `sidebar` slot. The own injected share is declared here (a
* share's type lives with whoever wires it); the owner share is referenced
* off ui-layout's slot declaration through OwnerOf, never re-stated. Single
* domain — this is the package's whole contract surface.
* share's type lives with whoever wires it); the runtime share — owner
* props {collapsed,width} plus the standard useSessions hook — is
* PropsRuntime<'sidebar'>, resolved off ui-layout's SlotMap declaration and
* never re-stated. Single domain — this is the package's whole contract
* surface.
*/
import type { OwnerOf } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every
// program that sees this contract, so OwnerOf<'sidebar'> resolves.
// program that sees this contract, so PropsRuntime<'sidebar'> resolves.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarTreeState } from '../store.ts'
/** Cross-plugin actions bound in apply (layout / sessions services). */
export interface SidebarActions {
open(id: SessionId): void
create(cwd?: string): void
toggleSidebar(): void
}
/** Plugin-owned tree viewing-state actions (tree store mutators). */
export interface SidebarTreeActions {
toggleProject(key: string): void
toggleSession(id: SessionId): void
setQuery(query: string): void
}
/**
* Registrant-private injected share (arrives via the register inject
* factory). A type alias, not an interface: the alias carries an implicit
* index signature, so the factory's return crosses the registry's
* `Record<string, unknown>` boundary uncast.
* factory): plain cross-service callbacks only — tree data rides the
* standard useSessions hook and viewing state is component-local. A type
* alias, not an interface: the alias carries an implicit index signature,
* so the factory's return crosses the registry's `Record<string, unknown>`
* boundary uncast.
*/
export type SidebarRootInjected = {
useTree: SnapshotSelectorHook<SidebarTreeState>
/** Current session selector (row highlight); undefined selects nothing. */
useCurrent: () => SessionId | undefined
actions: SidebarActions
tree: SidebarTreeActions
/** Open (switch to) a session. */
onOpen: (id: SessionId) => void
/**
* Create a session and open it; cwd targets a project group (the
* sidebar's three creation entries all land in the new session).
*/
onCreate: (cwd?: string) => void
/** Collapse the sidebar column (layout service action; owner share stays {collapsed,width}). */
onToggleSidebar: () => void
}
/**
* Full component props: owner share referenced from ui-layout's declaration
* plus the own injected share. Root scope has no standard injection
* (useSession is session-scope only), so no standard term appears.
* Full component props: the framework runtime share (owner {collapsed,width}
* + standard useSessions) plus the own injected share. No children are
* declared and no store is registered, so no PropsRenderSlots/PropsStore
* term appears.
*/
export type SidebarRootComponentProps = OwnerOf<'sidebar'> & SidebarRootInjected
export type SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected

View File

@@ -1,71 +1,41 @@
/**
* Sidebar plugin, browser half: SidebarRoot registered into the layout-owned
* sidebar slot; tree derivation materialized in a plugin-owned snapshot
* store (pure consumer — no ctx service). Contract: api-contracts v3
* section 6; props composition in contract/slots.ts.
* sidebar slot. Pure consumer — the session list arrives through the
* standard useSessions prop, tree rows derive in the component, and the
* inject surface is plain cross-service callbacks closed over the plugin's
* own ctx (slot design sections 5 and 6); props composition in
* contract/slots.ts. Export discipline: packages/client/AGENTS.md.
*/
import type { RootBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootInjected } from './contract/slots.ts'
import { createSidebarTreeStore } from './store.ts'
import { SidebarRoot } from './SidebarRoot.tsx'
export {
deriveRows, formatRelativeTime, projectLabel,
UNGROUPED_KEY, UNGROUPED_LABEL,
type ProjectRow, type SessionRow, type SidebarRow, type TreeView,
} from './tree.ts'
export {
createSidebarTreeStore,
type GroupBy, type SidebarTreeState, type SidebarTreeStore,
} from './store.ts'
export { ProjectRowItem, SessionRowItem } from './Rows.tsx'
export { SidebarRoot } from './SidebarRoot.tsx'
export type {
SidebarActions, SidebarRootComponentProps, SidebarRootInjected, SidebarTreeActions,
} from './contract/slots.ts'
export type { SidebarRootComponentProps, SidebarRootInjected } from './contract/slots.ts'
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
export const inject = ['slots', 'layout', 'sessions']
/**
* Client plugin body: build the tree store and register SidebarRoot into the
* sidebar slot with the inject surface bound off the root binding's ctx.
* Client plugin body: register SidebarRoot into the sidebar slot. The inject
* factory returns service callbacks only (no hooks, no store lines) — all
* data reads ride the framework's standard useSessions delivery.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const sessions = ctx.sessions
ctx.effect(() => {
const tree = createSidebarTreeStore(sessions)
// Called once per registration (root slots cache per entry); services are
// bound off the binding ctx per the contract's inject-surface wording.
const injectProps = (b: RootBinding<ClientContext>): SidebarRootInjected => {
const { sessions: boundSessions, layout } = b.ctx
return {
useTree: tree.store.useSelector,
useCurrent: () => layout.current.useSelector(s => s.sessionId),
actions: {
open: (id) => { layout.open(id) },
create: (cwd) => {
// Create-then-open: the sidebar's three creation entries all land
// in the new session (empty-state first-send stays with ui-conversation).
void boundSessions.create(cwd === undefined ? {} : { cwd })
.then((id: SessionId) => { layout.open(id) })
},
toggleSidebar: () => { layout.toggleSidebar() },
},
tree: {
toggleProject: (key) => { tree.toggleProject(key) },
toggleSession: (id) => { tree.toggleSession(id) },
setQuery: (query) => { tree.setQuery(query) },
},
}
}
const disposeRegistration = ctx.slots.register('sidebar', SidebarRoot, { inject: injectProps })
return () => {
disposeRegistration()
tree.dispose()
}
}, 'ui-sidebar: tree store + slot registration')
const injectProps = (): SidebarRootInjected => ({
// Selection lives with the runtime sessions service (current rides the
// list snapshot); layout keeps only panel geometry.
onOpen: (id) => { ctx.sessions.open(id) },
onCreate: (cwd) => {
// Create-then-open: the sidebar's three creation entries all land
// in the new session (empty-state first-send stays with ui-conversation).
void ctx.sessions.create(cwd === undefined ? {} : { cwd })
.then((id: SessionId) => { ctx.sessions.open(id) })
},
onToggleSidebar: () => { ctx.layout.toggleSidebar() },
})
ctx.effect(
() => ctx.slots.register({ name: 'sidebar', inject: injectProps }, SidebarRoot),
'ui-sidebar: slot registration',
)
}

View File

@@ -1,94 +0,0 @@
/**
* Sidebar tree store: plugin-owned snapshot store materializing the derived
* row list. Subscribes to sessions.list and re-derives on list changes and
* on viewing-state actions (expansion, search, group-by) — components
* subscribe to `rows` and never derive in render. Contract: api-contracts
* v3 section 6.
*/
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { deriveRows, type SidebarRow } from './tree.ts'
/** Grouping strategy. Only by-workspace is designed (figma); the menu shows the rest disabled. */
export type GroupBy = 'workspace'
/** Sidebar tree state: materialized rows plus the viewing state that shaped them. */
export interface SidebarTreeState {
rows: SidebarRow[]
/** Expanded project group keys (cwd or the ungrouped key). */
expandedProjects: string[]
/** Expanded session ids (subtree unfold). */
expandedSessions: string[]
query: string
groupBy: GroupBy
}
/** Store handle: snapshot store plus mutation actions and the list unsubscribe. */
export interface SidebarTreeStore {
readonly store: SnapshotStore<SidebarTreeState>
toggleProject(key: string): void
toggleSession(id: SessionId): void
setQuery(query: string): void
setGroupBy(groupBy: GroupBy): void
dispose(): void
}
/**
* Create the sidebar tree store bound to a sessions service.
* @param sessions - root sessions service (only the list store is consumed).
* @returns store handle; call dispose on plugin teardown.
*/
export function createSidebarTreeStore(sessions: Pick<SessionsService, 'list'>): SidebarTreeStore {
const store = createSnapshotStore<SidebarTreeState>({
rows: [],
expandedProjects: [],
expandedSessions: [],
query: '',
groupBy: 'workspace',
})
const rederive = (draft: SidebarTreeState): void => {
draft.rows = deriveRows(sessions.list.getSnapshot(), {
expandedProjects: new Set(draft.expandedProjects),
expandedSessions: new Set(draft.expandedSessions),
query: draft.query,
})
}
store.update(rederive)
const unsubscribe = sessions.list.subscribe(() => { store.update(rederive) })
const toggle = (list: string[], key: string): void => {
const at = list.indexOf(key)
if (at >= 0) list.splice(at, 1)
else list.push(key)
}
return {
store,
toggleProject(key) {
store.update((draft) => {
toggle(draft.expandedProjects, key)
rederive(draft)
})
},
toggleSession(id) {
store.update((draft) => {
toggle(draft.expandedSessions, id)
rederive(draft)
})
},
setQuery(query) {
store.update((draft) => {
draft.query = query
rederive(draft)
})
},
setGroupBy(groupBy) {
store.update((draft) => {
draft.groupBy = groupBy
rederive(draft)
})
},
dispose: unsubscribe,
}
}

View File

@@ -2,8 +2,9 @@
* Pure sidebar tree derivation: session list snapshot -> flat render rows.
* Groups sessions by project directory (cwd), builds the per-group session
* tree from parentId links, sorts by recency, and applies search filtering
* with forced ancestor visibility. Components subscribe to the materialized
* rows and never derive in render. Contract: api-contracts v3 section 6.
* with forced ancestor visibility. Derived data is a pure function (slot
* design section 6): the component feeds the useSessions snapshot plus its
* local viewing state through useMemo — no materializing store.
*/
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
@@ -43,10 +44,10 @@ export interface SessionRow {
/** One flat sidebar list row. */
export type SidebarRow = ProjectRow | SessionRow
/** Viewing state consumed by the derivation. */
/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */
export interface TreeView {
expandedProjects: ReadonlySet<string>
expandedSessions: ReadonlySet<string>
expandedProjects: readonly string[]
expandedSessions: readonly string[]
query: string
}
@@ -217,17 +218,19 @@ function flattenSearch(g: Group, visible: ReadonlySet<SessionId>, rows: SidebarR
* without a display-title or label hit are dropped, and a label-only hit keeps the
* bare project row.
* @param list - sessions list snapshot.
* @param view - expansion sets and search query.
* @param view - local expansion arrays and search query.
* @returns rows in render order.
*/
export function deriveRows(list: SessionListState, view: TreeView): SidebarRow[] {
const q = view.query.trim().toLowerCase()
const expandedProjects = new Set(view.expandedProjects)
const expandedSessions = new Set(view.expandedSessions)
const rows: SidebarRow[] = []
for (const g of groupByCwd(list)) {
if (q === '') {
const expanded = view.expandedProjects.has(g.key)
const expanded = expandedProjects.has(g.key)
rows.push({ type: 'project', key: g.key, cwd: g.cwd, label: g.label, sessionCount: g.summaries.size, expanded })
if (expanded) flattenVisible(g, view.expandedSessions, rows)
if (expanded) flattenVisible(g, expandedSessions, rows)
} else {
const visible = searchVisible(g, q)
if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue

View File

@@ -15,10 +15,10 @@ export const name = 'client-ui-sidebar-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: a pure-consumer plugin deriving its tree store from
* sessions.list — it emits no cordis events and owns no cross-plugin mutable
* state; derivation and interaction behavior are asserted directly by this
* package's tree/store/component specs.
* No runtime invariant: a pure-consumer plugin deriving its rows in-component
* from the standard useSessions delivery — it emits no cordis events and owns
* no cross-plugin mutable state; derivation and interaction behavior are
* asserted directly by this package's tree/component specs.
*/
const install: InvariantInstaller = () => {}

View File

@@ -1,55 +1,53 @@
// @vitest-environment jsdom
/**
* apply wiring on a real cordis Context + SlotsService: tree store built and
* subscribed, SidebarRoot registered into the layout-owned sidebar slot with
* the inject surface bound off the root binding ctx, effect teardown
* unregisters and drops the list subscription. Behavior-level assertions
* only — the inject factory's cast shape is due to change with the slot
* type-chain redesign.
* apply wiring on a real cordis Context + SlotsService (terminal register
* form): SidebarRoot registered into the layout-declared sidebar slot, the
* thin inject surface (three plain service callbacks closed over the plugin
* ctx — no hooks, no store lines), load-order fail-loud, and fiber-teardown
* unregistration. Component behavior is covered props-direct in
* sidebar-root.spec.tsx; no renderer machinery here.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act } from 'react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { scopedSlots, RootBindingProvider } from '@deepseek-ai/dsh-client-web-react'
import { describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
// Type-only: ui-layout's SlotMap merge so the sidebar slot key typechecks.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
const sid = (s: string) => s as SessionId
afterEach(cleanup)
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const list = createSnapshotStore<SessionListState>({
ids: [sid('a')],
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } },
current: undefined,
})
const sessions = { list, create: vi.fn(async () => sid('minted')) }
const layout = {
current: createSnapshotStore<{ sessionId?: SessionId }>({}),
open: vi.fn(),
toggleSidebar: vi.fn(),
}
const sessions = { list, create: vi.fn(async () => sid('minted')), open: vi.fn() }
const layout = { toggleSidebar: vi.fn() }
ctx.provide('sessions', sessions)
ctx.provide('layout', layout)
const slots = ctx.get('slots') as SlotsService
slots.define('sidebar', { kind: 'single', scope: 'root' })
// Stand-in for ui-layout's root entry: the sidebar slot only exists while
// a live entry declares it in children (declaration account: design §2.2).
slots.register(
{ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never,
() => null,
)
return { ctx, slots, sessions, layout }
}
function mountSlot(ctx: Context, slots: SlotsService) {
const surface = scopedSlots(slots.core, 'sidebar')
return render(
<RootBindingProvider value={{ ctx }}>
{surface.renderSlot('sidebar', {})}
</RootBindingProvider>,
)
/** The sidebar entry's injected share, read off the stored entry. */
function injectedOf(slots: SlotsService): SidebarRootInjected {
const entries = slots.entries('sidebar')
expect(entries).toHaveLength(1)
// The typed StoredEntry.inject is declaration-derived ((...args: never[])
// shape); the sidebar factory is parameterless, so the call is safe here.
const inject = entries[0]!.inject as (() => SidebarRootInjected) | undefined
return inject!()
}
describe('apply', () => {
@@ -58,101 +56,57 @@ describe('apply', () => {
})
it('fails loud when mounted without the inject declaration', async () => {
// ctx.sessions rides the cordis property proxy: reading it from a plugin
// ctx.slots rides the cordis property proxy: reading it from a plugin
// that never declared the dependency throws instead of yielding undefined.
// Await the fiber thenable itself, not a second .await() chain: the test
// invariant host wraps plugin() with an eager readiness promise, and only
// the thenable settles it (a parallel .await() leaves it unhandled).
const ctx = new Context()
await ctx.plugin(SlotsService).await()
await expect(ctx.plugin({ apply })).rejects.toThrow(/without inject/)
})
it('registers SidebarRoot which renders from the live list', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
mountSlot(ctx, slots)
expect(screen.getByText('proj')).toBeTruthy()
expect(screen.getByText('1 session')).toBeTruthy()
it('fails loud when no live entry has declared the sidebar slot', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
ctx.provide('sessions', {})
ctx.provide('layout', {})
await expect(ctx.plugin({ inject: [...inject], apply })).rejects.toThrow(/slot "sidebar" is not declared/)
})
it('binds actions to layout/sessions off the root binding', async () => {
it('registers SidebarRoot with the thin three-callback inject surface', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const injected = injectedOf(slots)
// The whole business face: three plain callbacks, no hooks, no store lines.
expect(Object.keys(injected).sort()).toEqual(['onCreate', 'onOpen', 'onToggleSidebar'])
})
it('routes the callbacks to the layout/sessions services', async () => {
const { ctx, slots, sessions, layout } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
mountSlot(ctx, slots)
const injected = injectedOf(slots)
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
injected.onToggleSidebar()
expect(layout.toggleSidebar).toHaveBeenCalledOnce()
act(() => { fireEvent.click(screen.getByText('proj')) })
act(() => { fireEvent.click(screen.getByText('alpha')) })
expect(layout.open).toHaveBeenCalledWith('a')
injected.onOpen(sid('a'))
expect(sessions.open).toHaveBeenCalledWith('a')
act(() => { fireEvent.click(screen.getByText('New Session')) })
injected.onCreate()
expect(sessions.create).toHaveBeenCalledWith({})
// create-then-open lands after the create promise resolves.
await act(async () => { await Promise.resolve() })
expect(layout.open).toHaveBeenCalledWith('minted')
await Promise.resolve()
await Promise.resolve()
expect(sessions.open).toHaveBeenCalledWith('minted')
act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
injected.onCreate('/proj')
expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' })
})
it('throws from the inject factory when binding ctx lacks the services', async () => {
it('teardown unregisters the slot entry', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const bare = new Context()
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const surface = scopedSlots(slots.core, 'sidebar')
render(
<RootBindingProvider value={{ ctx: bare }}>
{surface.renderSlot('sidebar', {})}
</RootBindingProvider>,
)
// The slot error boundary absorbs the throw and logs it.
expect(document.querySelector('[data-slot-error="sidebar"]')).toBeTruthy()
} finally {
spy.mockRestore()
}
})
it('search input drives the plugin-owned tree store', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
mountSlot(ctx, slots)
act(() => {
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'zzz' } })
})
expect(screen.getByText('No matches')).toBeTruthy()
})
it('expansion toggles route through the injected tree actions', async () => {
const { ctx, slots, sessions } = await bench()
sessions.list.update((draft) => {
draft.ids.push(sid('kid'))
draft.byId[sid('kid')] = {
id: sid('kid'), title: 'child', displayTitle: 'child', cwd: '/proj', parentId: sid('a'), running: false, updatedAt: 2,
}
})
await ctx.plugin({ inject: [...inject], apply }).await()
mountSlot(ctx, slots)
act(() => { fireEvent.click(screen.getByText('proj')) })
expect(screen.getByText('alpha')).toBeTruthy()
act(() => { fireEvent.click(screen.getByLabelText('Expand')) })
expect(screen.getByText('child')).toBeTruthy()
})
it('teardown unregisters the slot and drops the list subscription', async () => {
const { ctx, slots, sessions } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('sidebar')).toHaveLength(1)
await fiber.dispose()
expect(slots.entries('sidebar')).toHaveLength(0)
// A post-teardown list change must not reach a disposed store.
expect(() => {
sessions.list.update((draft) => { draft.ids = [] })
}).not.toThrow()
})
})

View File

@@ -1,19 +1,26 @@
// @vitest-environment jsdom
/**
* SidebarRoot interaction spec on the real framework stack: real tree store
* (web-react SnapshotStore) feeding the component through the same selector
* hook the inject surface hands out. Covers expand/collapse, subtree unfold,
* search filtering, row activation, and the creation entries.
* SidebarRoot interaction spec, props-direct (slot-parity test doctrine:
* components are fed composed props, no assembly machinery). The standard
* useSessions hook is stubbed with a real web-react SnapshotStore selector;
* expansion/search live inside the component, so all viewing behavior is
* driven through the DOM. Covers expand/collapse, subtree unfold, search
* filtering, row activation, and the creation entries.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act } from 'react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { act, useSyncExternalStore } from 'react'
// Engine home: runtime/client since the store migration; the engine carries
// no hook (runtime is React-free), so the spec binds the selector locally.
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import {
createSidebarTreeStore, SidebarRoot,
type SidebarActions, type SidebarTreeStore,
} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
/** Minimal selector hook over an engine store (production binding lives in the renderer). */
function hookOf<T>(src: { getSnapshot(): T; subscribe(fn: () => void): () => void }) {
return <S,>(sel: (s: T) => S, _eq?: (a: S, b: S) => boolean): S =>
sel(useSyncExternalStore(src.subscribe.bind(src), src.getSnapshot.bind(src)))
}
const sid = (s: string) => s as SessionId
@@ -43,29 +50,36 @@ function summary(init: SummaryInit): SessionSummary {
function listStateOf(...summaries: SessionSummary[]): SessionListState {
const byId: Record<SessionId, SessionSummary> = {}
for (const s of summaries) byId[s.id] = s
return { ids: summaries.map((s) => s.id), byId }
return { ids: summaries.map((s) => s.id), byId, current: undefined }
}
afterEach(cleanup)
function mount(...summaries: SessionSummary[]) {
const list = createSnapshotStore<SessionListState>(listStateOf(...summaries))
const tree: SidebarTreeStore = createSidebarTreeStore({ list })
const current = createSnapshotStore<{ id: SessionId | undefined }>({ id: undefined })
const actions: SidebarActions = {
open: vi.fn((id: SessionId) => { current.update((d) => { d.id = id }) }),
create: vi.fn(),
toggleSidebar: vi.fn(),
}
const utils = render(
// Real engine store as the useSessions stub: same uSES selector shape the
// framework delivers, so list updates re-render exactly like production.
const sessions = createSnapshotStore<SessionListState>(listStateOf(...summaries))
const onOpen = vi.fn((id: SessionId) => { sessions.update((d) => { d.current = id }) })
const onCreate = vi.fn()
// The owner decides collapsed in production (AppFrame maps the preference);
// the harness mirrors that loop so the toggle drives a re-render.
let collapsed = false
const view = (width: number) => (
<SidebarRoot
useTree={tree.store.useSelector}
useCurrent={() => current.useSelector((s) => s.id)}
actions={actions}
tree={tree}
/>,
collapsed={collapsed}
width={width}
useSessions={hookOf(sessions)}
onOpen={onOpen}
onCreate={onCreate}
onToggleSidebar={onToggleSidebar}
/>
)
return { list, tree, current, actions, ...utils }
const onToggleSidebar = vi.fn(() => {
collapsed = !collapsed
utils.rerender(view(collapsed ? 56 : 300))
})
const utils = render(view(300))
return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
}
const projectData = () => [
@@ -74,6 +88,9 @@ const projectData = () => [
summary({ id: 'lone', title: 'elsewhere', cwd: '/other', updatedAt: 3 }),
]
/** Flush the store's microtask-batched notification into React. */
const flush = async () => { await act(async () => { await Promise.resolve() }) }
describe('SidebarRoot', () => {
it('renders chrome and collapsed project rows', () => {
mount(...projectData())
@@ -96,11 +113,13 @@ describe('SidebarRoot', () => {
expect(screen.queryByText('forked child')).toBeNull()
})
it('opens a session on row click and marks it selected', () => {
const { actions } = mount(...projectData())
it('opens a session on row click and marks it selected', async () => {
const { onOpen } = mount(...projectData())
act(() => { fireEvent.click(screen.getByText('proj')) })
act(() => { fireEvent.click(screen.getByText('root work')) })
expect(actions.open).toHaveBeenCalledWith('root')
expect(onOpen).toHaveBeenCalledWith('root')
// The mock routed the open into sessions.current — highlight follows.
await flush()
expect(screen.getByText('root work').closest('[role="treeitem"]')!.getAttribute('aria-selected')).toBe('true')
})
@@ -130,20 +149,91 @@ describe('SidebarRoot', () => {
})
it('routes the three creation entries with the right cwd', () => {
const { actions } = mount(...projectData())
const { onCreate } = mount(...projectData())
act(() => { fireEvent.click(screen.getByText('New Session')) })
expect(actions.create).toHaveBeenLastCalledWith()
expect(onCreate).toHaveBeenLastCalledWith()
act(() => { fireEvent.click(screen.getByLabelText('New workspace')) })
expect(actions.create).toHaveBeenLastCalledWith()
expect(onCreate).toHaveBeenLastCalledWith()
// Per-project "+" is hover-revealed by CSS; still clickable in jsdom.
act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
expect(actions.create).toHaveBeenLastCalledWith('/proj')
expect(onCreate).toHaveBeenLastCalledWith('/proj')
})
it('collapse button and group-by menu behave', () => {
const { actions } = mount(...projectData())
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
expect(actions.toggleSidebar).toHaveBeenCalledOnce()
it('collapse fades the wide content out, then the rail keeps the four controls', () => {
vi.useFakeTimers()
try {
const { onToggleSidebar, onCreate } = mount(...projectData())
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
expect(onToggleSidebar).toHaveBeenCalledOnce()
// Fade window: the wide chrome is still mounted while it fades.
expect(screen.getByText('HARNESS')).toBeTruthy()
expect(screen.getByRole('tree')).toBeTruthy()
// Settle: wide content unmounts, the rail controls remain.
act(() => { vi.advanceTimersByTime(300) })
expect(screen.queryByText('HARNESS')).toBeNull()
expect(screen.queryByText('New Session')).toBeNull()
expect(screen.queryByRole('tree')).toBeNull()
// Rail order mirrors the expanded rows: expand, new session, new workspace, search.
const rail = ['Expand sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']
.map((label) => screen.getByLabelText(label))
for (let i = 1; i < rail.length; i++) {
expect(rail[i - 1]!.compareDocumentPosition(rail[i]!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
}
// Rail creation entries route like their expanded counterparts.
act(() => { fireEvent.click(screen.getByLabelText('New session')) })
expect(onCreate).toHaveBeenLastCalledWith()
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy()
expect(screen.getByText('New Session')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('rail search expands the sidebar and focuses the search box', () => {
vi.useFakeTimers()
try {
const { onToggleSidebar } = mount(...projectData())
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
act(() => { vi.advanceTimersByTime(300) })
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
const input = screen.getByPlaceholderText('Search name, keywords...')
expect(document.activeElement).toBe(input)
} finally {
vi.useRealTimers()
}
})
it('expanded search focuses without toggling the sidebar', () => {
const { onToggleSidebar } = mount(...projectData())
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
expect(document.activeElement).toBe(input)
expect(onToggleSidebar).not.toHaveBeenCalled()
})
it('the search query survives a collapse/expand round trip', () => {
vi.useFakeTimers()
try {
mount(...projectData())
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { fireEvent.change(input, { target: { value: 'forked' } }) })
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
act(() => { vi.advanceTimersByTime(300) })
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
const restored = screen.getByPlaceholderText('Search name, keywords...') as HTMLInputElement
expect(restored.value).toBe('forked')
expect(screen.getByText('forked child')).toBeTruthy()
expect(screen.queryByText('elsewhere')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('group-by menu behaves', () => {
mount(...projectData())
expect(screen.queryByText('Update')).toBeNull()
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
expect(screen.getByText('Update')).toBeTruthy()
@@ -158,28 +248,27 @@ describe('SidebarRoot', () => {
})
it('re-renders when the sessions list gains a session', async () => {
const { list } = mount(...projectData())
const { sessions } = mount(...projectData())
act(() => {
list.update((draft) => {
sessions.update((draft) => {
draft.ids.push(sid('fresh'))
draft.byId[sid('fresh')] = summary({ id: 'fresh', title: 'brand new', cwd: '/fresh', updatedAt: 99 })
})
})
// Store notifications are microtask-batched.
await act(async () => { await Promise.resolve() })
await flush()
expect(screen.getByText('fresh')).toBeTruthy()
})
it('row "More" anchors swallow the click without opening or toggling', () => {
const { actions, tree } = mount(...projectData())
const { onOpen } = mount(...projectData())
act(() => { fireEvent.click(screen.getByText('proj')) })
const before = tree.store.getSnapshot().expandedProjects.length
// Project-row anchor: must not collapse the project.
// Project-row anchor: must not collapse the project (rows stay visible).
act(() => { fireEvent.click(screen.getAllByLabelText('More')[0]!) })
expect(tree.store.getSnapshot().expandedProjects).toHaveLength(before)
expect(screen.getByText('root work')).toBeTruthy()
// Session-row anchor: must not open the session.
act(() => { fireEvent.click(screen.getAllByLabelText('More')[1]!) })
expect(actions.open).not.toHaveBeenCalled()
expect(onOpen).not.toHaveBeenCalled()
})
it('shows the running state dot only for running sessions', () => {

View File

@@ -1,112 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { createSidebarTreeStore } from '@deepseek-ai/dsh-client-ui-sidebar/client'
const sid = (s: string) => s as SessionId
/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
interface SummaryInit {
id: string
title?: string
cwd?: string
parentId?: string
running?: boolean
updatedAt?: number
}
function summary(init: SummaryInit): SessionSummary {
const s: SessionSummary = {
id: sid(init.id),
title: init.title ?? init.id,
displayTitle: init.title ?? init.id,
running: init.running ?? false,
updatedAt: init.updatedAt ?? 0,
}
if (init.cwd !== undefined) s.cwd = init.cwd
if (init.parentId !== undefined) s.parentId = sid(init.parentId)
return s
}
function listStateOf(...summaries: SessionSummary[]): SessionListState {
const byId: Record<SessionId, SessionSummary> = {}
for (const s of summaries) byId[s.id] = s
return { ids: summaries.map(s => s.id), byId }
}
function setup(...summaries: SessionSummary[]) {
const list = createSnapshotStore<SessionListState>(listStateOf(...summaries))
const tree = createSidebarTreeStore({ list })
return { list, tree }
}
const flushMicrotasks = () => new Promise<void>((resolve) => { queueMicrotask(resolve) })
describe('createSidebarTreeStore', () => {
it('materializes rows from the initial list snapshot', () => {
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
expect(tree.store.getSnapshot().rows).toEqual([
expect.objectContaining({ type: 'project', key: '/p', sessionCount: 1 }),
])
})
it('re-derives when the sessions list changes', async () => {
const { list, tree } = setup(summary({ id: 'a', cwd: '/p' }))
list.update((draft) => {
draft.ids.push(sid('b'))
draft.byId[sid('b')] = summary({ id: 'b', cwd: '/q', updatedAt: 99 })
})
// Snapshot-store notifications are microtask-batched.
await flushMicrotasks()
expect(tree.store.getSnapshot().rows.map(r => r.type === 'project' && r.key)).toEqual(['/q', '/p'])
})
it('toggleProject expands and collapses synchronously', () => {
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
tree.toggleProject('/p')
expect(tree.store.getSnapshot().rows).toHaveLength(2)
tree.toggleProject('/p')
expect(tree.store.getSnapshot().rows).toHaveLength(1)
})
it('toggleSession unfolds a subtree', () => {
const { tree } = setup(
summary({ id: 'root', cwd: '/p', updatedAt: 2 }),
summary({ id: 'kid', cwd: '/p', parentId: sid('root'), updatedAt: 1 }),
)
tree.toggleProject('/p')
expect(tree.store.getSnapshot().rows).toHaveLength(2)
tree.toggleSession(sid('root'))
expect(tree.store.getSnapshot().rows).toHaveLength(3)
})
it('setQuery switches into search mode and back', () => {
const { tree } = setup(
summary({ id: 'a', title: 'needle', cwd: '/p' }),
summary({ id: 'b', title: 'other', cwd: '/q' }),
)
tree.setQuery('needle')
const rows = tree.store.getSnapshot().rows
expect(rows.map(r => r.type)).toEqual(['project', 'session'])
tree.setQuery('')
expect(tree.store.getSnapshot().rows.every(r => r.type === 'project')).toBe(true)
})
it('setGroupBy records the strategy and re-derives', () => {
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
tree.setGroupBy('workspace')
expect(tree.store.getSnapshot().groupBy).toBe('workspace')
expect(tree.store.getSnapshot().rows).toHaveLength(1)
})
it('dispose stops re-derivation on list changes', async () => {
const { list, tree } = setup(summary({ id: 'a', cwd: '/p' }))
tree.dispose()
list.update((draft) => {
draft.ids.push(sid('b'))
draft.byId[sid('b')] = summary({ id: 'b', cwd: '/q' })
})
await flushMicrotasks()
expect(tree.store.getSnapshot().rows).toHaveLength(1)
})
})

View File

@@ -3,7 +3,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d
import {
deriveRows, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL,
type SessionRow, type TreeView,
} from '@deepseek-ai/dsh-client-ui-sidebar/client'
} from '../src/client/tree.ts'
const sid = (s: string) => s as SessionId
@@ -34,12 +34,12 @@ function summary(init: SummaryInit): SessionSummary {
function listOf(...summaries: SessionSummary[]): SessionListState {
const byId: Record<SessionId, SessionSummary> = {}
for (const s of summaries) byId[s.id] = s
return { ids: summaries.map(s => s.id), byId }
return { ids: summaries.map(s => s.id), byId, current: undefined }
}
const view = (partial: Partial<TreeView> = {}): TreeView => ({
expandedProjects: partial.expandedProjects ?? new Set(),
expandedSessions: partial.expandedSessions ?? new Set(),
expandedProjects: partial.expandedProjects ?? [],
expandedSessions: partial.expandedSessions ?? [],
query: partial.query ?? '',
})
@@ -96,7 +96,7 @@ describe('deriveRows grouping', () => {
summary({ id: 'b', cwd: '/p', updatedAt: 2 }),
)
expect(deriveRows(list, view()).filter(r => r.type === 'session')).toHaveLength(0)
const rows = deriveRows(list, view({ expandedProjects: new Set(['/p']) }))
const rows = deriveRows(list, view({ expandedProjects: ['/p'] }))
expect(rows.slice(1)).toEqual([
expect.objectContaining({ type: 'session', id: 'b', depth: 0 }),
expect.objectContaining({ type: 'session', id: 'a', depth: 0 }),
@@ -114,8 +114,8 @@ describe('deriveRows session tree', () => {
it('nests children under expanded parents with increasing depth', () => {
const rows = deriveRows(treeList, view({
expandedProjects: new Set(['/p']),
expandedSessions: new Set(['root', 'kid']),
expandedProjects: ['/p'],
expandedSessions: ['root', 'kid'],
}))
expect(rows.slice(1)).toEqual([
expect.objectContaining({ id: 'other', depth: 0, hasChildren: false }),
@@ -126,7 +126,7 @@ describe('deriveRows session tree', () => {
})
it('collapses subtrees at unexpanded sessions', () => {
const rows = deriveRows(treeList, view({ expandedProjects: new Set(['/p']) }))
const rows = deriveRows(treeList, view({ expandedProjects: ['/p'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toEqual(['other', 'root'])
})
@@ -135,7 +135,7 @@ describe('deriveRows session tree', () => {
const rows = deriveRows(listOf(
summary({ id: 'p1', cwd: '/a', updatedAt: 2 }),
summary({ id: 'stray', cwd: '/b', parentId: sid('p1'), updatedAt: 1 }),
), view({ expandedProjects: new Set(['/a', '/b']) }))
), view({ expandedProjects: ['/a', '/b'] }))
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/a' }),
expect.objectContaining({ id: 'p1', depth: 0 }),
@@ -149,7 +149,7 @@ describe('deriveRows session tree', () => {
summary({ id: 'x', cwd: '/p', parentId: sid('y'), updatedAt: 2 }),
summary({ id: 'y', cwd: '/p', parentId: sid('x'), updatedAt: 1 }),
summary({ id: 'self', cwd: '/p', parentId: sid('self'), updatedAt: 3 }),
), view({ expandedProjects: new Set(['/p']), expandedSessions: new Set(['x', 'y', 'self']) }))
), view({ expandedProjects: ['/p'], expandedSessions: ['x', 'y', 'self'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toContain('self')
expect(ids).toContain('x')
@@ -162,7 +162,7 @@ describe('deriveRows session tree', () => {
summary({ id: 'b', cwd: '/p', updatedAt: 7 }),
summary({ id: 'a', cwd: '/p', updatedAt: 7 }),
summary({ id: 'c', cwd: '/p', updatedAt: 7 }),
), view({ expandedProjects: new Set(['/p']) }))
), view({ expandedProjects: ['/p'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toEqual(['a', 'b', 'c'])
})
@@ -172,7 +172,7 @@ describe('deriveRows session tree', () => {
summary({ id: 'p', cwd: '/p', updatedAt: 9 }),
summary({ id: 'old', cwd: '/p', parentId: sid('p'), updatedAt: 1 }),
summary({ id: 'new', cwd: '/p', parentId: sid('p'), updatedAt: 5 }),
), view({ expandedProjects: new Set(['/p']), expandedSessions: new Set(['p']) }))
), view({ expandedProjects: ['/p'], expandedSessions: ['p'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toEqual(['p', 'new', 'old'])
})
@@ -180,7 +180,7 @@ describe('deriveRows session tree', () => {
it('carries the running flag onto rows', () => {
const rows = deriveRows(
listOf(summary({ id: 'a', cwd: '/p', running: true })),
view({ expandedProjects: new Set(['/p']) }))
view({ expandedProjects: ['/p'] }))
expect(rows[1]).toEqual(expect.objectContaining({ id: 'a', running: true }))
})
})

View File

@@ -1,15 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"jsx": "react-jsx",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
"outDir": "lib/types"
},
"include": [
"src"

View File

@@ -1,8 +1,21 @@
# @deepseek-ai/dsh-client-ui-slots
Slot registry pure core: SlotMap declaration merging, SlotCore (single/list/keyed), ScopedSlots types. Contract: api-contracts v3 §1 + the slot type-chain design (composed-props registration).
Slot registry pure core, slot terminal design: SlotMap declaration merging, the single `register` composition API on SlotCore, the four-share component-props type family, the store-seat type family, and the renderer install-seam contract. React types only at runtime — the package is React-free and cordis-free.
A SlotMap entry declares `{ kind; scope; owner; children? }`. `owner` is the render-side props share the slot-owning package declares; registrants reference it through `OwnerOf<K>` and never re-state it. The registrant's injected share `I` stays a local type at the registration site, inferred from the inject factory (`InjectFactory<E, I, Ctx>`; context-narrowing wrappers pin `Ctx`). `SlotCore.register` constrains the component against `ComposedProps<K, I>` — owner share & bottom-typed standard share (`StandardOf`) & `children`-gated slots face (`SlotsFaceOf`) & `I` — through the bare-call-signature `SlotComponent` position. `children` optionally whitelists delegable sub-slot keys (`ChildrenOf`; constraint-side validation only — delivery stays with the renderer); `narrowSlots` narrows a `ScopedSlots` surface to a subset whitelist.
One `register({ name, children?, store?, inject?, ...kind }, Component)` call contributes a component into a declared slot and, in the same breath, declares child slots (declaration = render authorization = runtime spec, one table), a store seat, and the registrant's business face. The component is checked at the call site against `ComposedProps` — the intersection of four shares, each derived from its single source of truth:
| share | type | source |
|---|---|---|
| runtime | `PropsRuntime<K>` | SlotMap entry: `owner` (parent's renderSlot call site) + session standard kit + global seat |
| child render | `PropsRenderSlots<S>` | the register call's `children` key set (statically narrowed `renderSlot`) |
| store | `PropsStore<H>` | the declared handle: `useStore` selector hook + draft-stripped `actions` |
| business | `I` | inferred from the `inject` factory's return |
The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are declared empty here and merged by the runtime package (same declare-merge pattern as SlotMap keys). Inject factory parameters derive from the declaration (`InjectParams`): session slots get `sessionId`, a declared store appends baked `actions`, nothing else — data access lives in the apply closure's ctx.
The store family (`defineStore` spec in / `StoreHandle<T, A>` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in the runtime package (the engine's home) and satisfies the `DefineStore` contract exported here. Engine products and the renderer host contract carry bare snapshot sources (`getSnapshot`/`subscribe`), never React hooks — hook binding is the render machinery's side of the seam; only the props-contract hook type (`SnapshotSelectorHook`) lives here.
`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot.
## Model Experience
@@ -14,5 +27,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **`StandardOf` is a constraint-position bottom type (`useSession: never`), not the arriving hook type** — this zero-dependency layer cannot see the conversation snapshot; components declare the narrowed hook they consume, and what actually arrives is web-react's renderer responsibility.
- **The legacy `props` entry member (with `OwnerProps`'s Partial owner share) remains for migration** — entries not yet declaring `owner` keep the P-I full-props constraint; both forms disappear with the last legacy declaration.
- **`isLive` scans all records linearly** — fine at UI-plugin registration counts (tens); revisit with an entry→record backref if ledgers ever grow hot.
- **The `__renders` phantom anchor is visible on `PropsRenderSlots`** — the same accepted noise as the type-chain design's `__accepts`: generic method signatures compare loosely across key unions, so the contravariant marker is what enforces "component key set ⊆ children declaration".

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-slots",
"description": "Slot registry pure core: SlotMap declaration-merge surface, SlotCore (single/list/keyed), ScopedSlots types",
"description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam",
"version": "0.0.1",
"private": true,
"type": "module",

Some files were not shown because too many files have changed in this diff Show More