Merge remote-tracking branch 'origin/worktree-webslot2' into worktree/fix-collapsed-sidebar-rail

# Conflicts:
#	apps/web/tests/smoke-fixture.e2e.ts
#	packages/client/ui-layout/src/client/AppFrame.tsx
#	packages/client/ui-layout/src/client/columns.ts
#	packages/client/ui-layout/src/client/index.ts
#	packages/client/ui-layout/tests/app-frame.spec.tsx
#	packages/client/ui-layout/tests/columns.spec.ts
#	packages/client/ui-sidebar/README.md
#	packages/client/ui-sidebar/src/client/SidebarRoot.tsx
#	packages/client/ui-sidebar/src/client/contract/slots.ts
#	packages/client/ui-sidebar/src/client/index.ts
#	packages/client/ui-sidebar/tests/apply.spec.tsx
#	packages/client/ui-sidebar/tests/sidebar-root.spec.tsx
This commit is contained in:
imccyu
2026-07-23 04:25:01 +08:00
747 changed files with 34776 additions and 7336 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.
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, view/toolview entry types) 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, the view and toolview registries) 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 the slot/view/toolview registries 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

@@ -44,4 +44,3 @@ export function transportError<T>(error: unknown): RpcResult<T> {
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
}
}

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

@@ -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

@@ -18,7 +18,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.
## Model Experience

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

@@ -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 './store/index.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 './store/index.ts'
export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
} from './store/index.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>
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 '../store/index.ts'
import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'

View File

@@ -1,7 +1,9 @@
/**
* 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
@@ -13,8 +15,9 @@
*/
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 '../store/index.ts'
import { createSnapshotStore } from '../store/index.ts'
import { SessionManager } from './manager.ts'
import type { Session } from './session.ts'
@@ -28,8 +31,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 {
@@ -69,15 +76,26 @@ 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. */
private watched: SessionId | undefined
@@ -90,13 +108,29 @@ 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() })
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).
@@ -132,6 +166,23 @@ export class SessionsService {
return record.binding
}
/**
* Resolve the render-layer session cell (SessionProvider's feed through
* the renderer host; ctx never enters the render layer). Marks the session
* watched, same as {@link SessionsService.binding}.
* @param id - session id.
* @returns cell, or undefined for a session neither listed nor already scoped.
*/
cell(id: string): SessionCell | undefined {
const record = this.resolve(id as SessionId)
if (record === undefined) return undefined
if (this.watched !== id) {
this.watched = id as SessionId
this.sweepDeferred()
}
return record.cell
}
/**
* Breadcrumb feed: walk parentId links inside the list store.
* @param id - session id.
@@ -158,10 +209,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
@@ -183,7 +238,11 @@ 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)
}
@@ -197,10 +256,18 @@ export class SessionsService {
}
this.scopes.delete(id)
this.deferredRemovals.delete(id)
void record.fiber.dispose()
this.dropScope(id, record)
}
}
/** 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 watched (called when the watch moves). */
private sweepDeferred(): void {
for (const id of [...this.deferredRemovals]) {
@@ -220,7 +287,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 '../store/index.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

@@ -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

@@ -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,12 @@
/**
* 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 watch
* deferral), 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'
@@ -112,6 +114,97 @@ describe('scope tree', () => {
})
})
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('moves the watch like binding(): switching cells sweeps a deferred removal', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.cell('s1') // watched
await feedList(b, []) // removed while watched → deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBeDefined()
await feedList(b, [{ id: 's2' }])
b.svc.cell('s2') // watch moves → sweep tears s1 down
expect(b.svc.scope(sid('s1'))).toBeUndefined()
})
})
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.binding(sid('s2')) // s2 watched
await feedList(b, []) // s1 unwatched → immediate drop; s2 watched → deferred
expect(pruneStoreScope).toHaveBeenCalledWith('s1')
expect(pruneStoreScope).not.toHaveBeenCalledWith('s2')
await feedList(b, [{ id: 's3' }])
b.svc.binding(sid('s3')) // watch 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()

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/store/index.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

@@ -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,10 @@
# @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), ctx.toolviews named registry with bash samples, 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.
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 both the conversation and details registrations, so the two session slots share one instance per session (selection written by conversation, 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, view registry read face, startSession chain).
`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 `/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

View File

@@ -39,7 +39,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

@@ -2,20 +2,18 @@
* 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.
* 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).
*/
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 {
SessionId, SessionListState, SessionsService, SlotsService,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, 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 { SelectionTarget } from './contract/views.ts'
import type { 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'
@@ -37,20 +35,13 @@ function need<T>(ctx: Context, name: string): T {
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
}
/**
@@ -80,106 +71,64 @@ export function apply(ctx: Context): void {
() => 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)
// Shared store handle, constructed here so its identity lives and dies with
// this fiber (a module-level handle would be a de-facto singleton). Both
// session-slot registrations declare it; same scope key = same instance, so
// conversation writes and details reads meet in one store.
const chat = createChatStore()
/** 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() },
},
}
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
slots.register({
name: 'conversation',
store: chat,
inject: (sessionId: SessionId, actions: BoundActions<typeof chat>): ConversationInjected => {
const session = sessions.manager.get(sessionId)
const scoped = scopedConversation(sessions, sessionId)
// 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()
return {
views: {
list: () => conversation.views(),
subscribe: fn => conversation.subscribeViews(fn),
version: () => conversation.viewsVersion(),
},
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
}
openDetails: (target: SelectionTarget) => {
actions.select(target)
layout.openDetails()
},
loadOlder: () => { void session.loadOlder() },
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
}
slots.register({
name: 'details',
store: chat,
inject: (): DetailsInjected => ({
closeDetails: () => { layout.closeDetails() },
}),
}, DetailsPanel)
/** 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
}
slots.register('conversation', ConversationRoot, { inject: conversationInject })
slots.register('details', DetailsPanel, { inject: detailsInject })
slots.register('conversation.empty', EmptyState, { inject: emptyInject })
slots.register({
name: 'conversation.empty',
inject: (): EmptyStateInjected => ({
startSession: opts => conversation.startSession(opts),
}),
}, EmptyState)
}

View File

@@ -16,7 +16,7 @@ import {
import type {
ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, 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'
@@ -38,7 +38,7 @@ const FOLLOW_THRESHOLD = 24
type OpenDetails = (target: SelectionTarget) => void
/** web-react's UseSession is deliberately wide (dependency direction); the
/** 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>
@@ -122,7 +122,7 @@ function StreamingTail({ useSession, onGrow }: {
export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
const { toolviews, t } = deps
return function ChatView({ sessionId, useSession: useSessionWide, useSelection, actions }: ConvViewProps) {
return function ChatView({ sessionId, useSession: useSessionWide, useStore, actions }: ConvViewProps) {
const useSession = useSessionWide as UseConversation
const nodes = useSession((s) => s.nodes)
const runningCalls = useSession((s) => s.runningCalls)
@@ -131,7 +131,7 @@ export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
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 selectedCallId = useStore((s) => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])

View File

@@ -7,7 +7,7 @@
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 { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { ChromeProps } from '../contract/views.ts'
import css from './StatsLine.module.css'

View File

@@ -1,13 +1,12 @@
// 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.
// fallback for both a registry miss and a crashed custom row. Pure props
// machinery, zero React context: a registrant inject factory receives the
// sessionId this outlet already holds, is called once per (registration x
// session) and cached, mirroring the slot 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 { Component, useSyncExternalStore, type ReactNode } from 'react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolViewInject, ToolViewProps, ToolViewResolver } from '../contract/toolview.ts'
import { GenericToolCard } from './GenericToolCard.tsx'
@@ -19,19 +18,21 @@ export interface ToolViewOutletProps {
viewProps: ToolViewProps
}
/** Inject cache: per inject-factory (stable per registration) x binding object. */
const injectCache = new WeakMap<ToolViewInject<object>, WeakMap<object, object>>()
/** Inject cache: per inject-factory (stable per registration) x session id.
* The inner Map lives and dies with its factory (WeakMap entry), so entries
* are bounded by the session count over the registration's lifetime. */
const injectCache = new WeakMap<ToolViewInject<object>, Map<SessionId, object>>()
function cachedInject(inject: ToolViewInject<object>, binding: SessionBinding): object {
let perBinding = injectCache.get(inject)
if (!perBinding) {
perBinding = new WeakMap()
injectCache.set(inject, perBinding)
function cachedInject(inject: ToolViewInject<object>, sessionId: SessionId): object {
let perSession = injectCache.get(inject)
if (!perSession) {
perSession = new Map()
injectCache.set(inject, perSession)
}
let props = perBinding.get(binding)
let props = perSession.get(sessionId)
if (!props) {
props = inject(binding)
perBinding.set(binding, props)
props = inject(sessionId)
perSession.set(sessionId, props)
}
return props
}
@@ -61,16 +62,6 @@ class RowErrorBoundary extends Component<
}
}
/** 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),
@@ -83,7 +74,7 @@ export function ToolViewOutlet({ registry, sessionId, toolName, viewProps }: Too
<RowErrorBoundary resetKey={version} fallback={<GenericToolCard {...viewProps} />}>
{resolved.inject === undefined
? <Row {...viewProps} />
: <InjectedRow Row={Row} inject={resolved.inject} viewProps={viewProps} />}
: <Row {...{ ...cachedInject(resolved.inject, sessionId), ...viewProps }} />}
</RowErrorBoundary>
)
}

View File

@@ -1,63 +1,67 @@
/**
* 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.
* conversation.empty). Terminal slot design (§3): full component props are the
* automatic shares — PropsRuntime<K> (framework standard kit) & PropsStore<H>
* (declared store's read/write faces) & the injected business face declared
* here. No renderSlot share: none of the three registrations declares
* children, so the zero-renderSlot inference applies.
*/
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 { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { createChatStore } from '../stores.ts'
import type { SelectionTarget, ViewEntry } from './views.ts'
/** Injected share of the conversation slot (assembled by apply's inject factory). */
/** The shared chat store handle type (apply constructs one; conversation and details both 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} now; ancestry derives from the
* standard useSessions hook in-component; view rendering moved into the
* component, which holds every share a view needs.
*/
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). */
views: {
list(): readonly ViewEntry[]
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
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
openDetails(target: SelectionTarget): void
/** Pull one older history page. */
loadOlder(): 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 & store share & injected share. */
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsStore<ChatStore> & ConversationInjected
/** Injected share of the details slot. */
/**
* 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

@@ -6,8 +6,7 @@
* 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 { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { CallId, Translate } from './views.ts'
@@ -28,11 +27,13 @@ export interface ToolViewProps {
/**
* 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.
* `I`, called once per (registration x session) and cached by the render
* outlet. Mirrors the slot inject shape (parameters derive from the
* declaration): toolviews are session-domain by nature, so the factory
* receives the session id only — service access goes through the
* registrant's own apply-closure ctx (design §5; binding objects retired).
*/
export type ToolViewInject<I extends object> = (b: SessionBinding) => I
export type ToolViewInject<I extends object> = (sessionId: SessionId) => I
/** Options accepted by the toolview registry's register; `I` is inferred from the inject factory. */
export interface ToolViewOptions<I extends object = object> {

View File

@@ -1,12 +1,12 @@
/**
* 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.
* View-ring contract: the typed conversation view table, the chat store state
* shared through it, 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.
*/
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 { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
/**
@@ -57,12 +57,33 @@ 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. */
/**
* Chat store state (slot terminal design §4): the per-session store shared by
* the conversation and details registrations. `createChatStore` implements
* this shape; views read it through {@link ConvViewProps}'s pass-through hook.
* `view` may carry a stale persisted id after a view plugin unloads — the
* registry is the runtime validator (unknown ids fall back to the first 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; null falls back to the first registered view. */
view: ViewId | null
}
/**
* Props handed to registered conversation views. `useSession` and `useStore`
* are the framework hooks ConversationRoot received as a slot registrant,
* passed through unchanged (hook transfer is plain props passing; no
* business-made subscription exists on this path). No renderSlot share: the
* view ring delegates no sub-slots.
*/
export interface ConvViewProps {
sessionId: SessionId
useSession: UseSession
useSelection: SnapshotSelectorHook<SelectionTarget | null>
/** Chat store read face (selection is the only slice views consume today). */
useStore: SnapshotSelectorHook<ChatStoreState>
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>
}

View File

@@ -14,25 +14,17 @@ 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, ChromeProps, ChromePropsOf, ConversationViewMap, ConvViewProps,
ConvViewPropsOf, SelectionTarget, Translate, ViewEntry, ViewEntryDef, ViewId,
} from './contract/views.ts'
export type {
ResolvedToolView, ToolCallBlock, ToolViewOptions, ToolViewProps, ToolViewResolver,
} from './contract/toolview.ts'
export type {
ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
ChatStore, ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps,
} 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 {

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, view
* registry with a uSES read face, 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 per-scope store maps,
* lazy construction, and prune bookkeeping this service used to carry are
* retired; 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,11 +22,8 @@ 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'
import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ViewEntry, ViewId } from './index.ts'
/** Mutable view-registry cell (plain object: mutation never crosses the tracker proxy). */
interface ViewsState {
@@ -37,8 +36,6 @@ interface ViewsState {
/** 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(),
}
@@ -71,37 +68,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).
@@ -170,9 +136,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 +151,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,12 +166,6 @@ 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 {
@@ -236,16 +173,3 @@ function bumpViews(state: ViewsState): void {
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,43 +1,82 @@
// 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, and the injected business face.
// 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 { useMemo, useSyncExternalStore, type ReactNode } 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 type { ConvViewProps, ViewEntry } from '../contract/views.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,
views, send, stop, openDetails, loadOlder, open,
}: ConversationRootProps) {
useSyncExternalStore(views.subscribe, views.version)
const list = views.list()
const activeId = useActiveView() ?? 'chat'
// The store's persisted view id may be stale (view plugin unloaded); the
// registry is the runtime validator — unknown ids fall to the first view.
const activeId = useStore(s => s.view) ?? 'chat'
const active = list.find(v => v.id === activeId) ?? list[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
: { op: promptError.op, message: `${promptError.error.message}${promptError.error.code}` }
// Views receive the shares this component already holds (hook transfer is
// plain props passing); the callback slice is referentially stable per
// injected identity so memoized view rows hold.
const viewProps = useMemo<ConvViewProps>(() => ({
sessionId, useSession, useStore,
actions: { openDetails, loadOlder },
}), [sessionId, useSession, useStore, openDetails, loadOlder])
const renderView = (entry: ViewEntry): ReactNode => {
const Header = entry.chrome?.header
const Footer = entry.chrome?.footer
const View = entry.component
return (
<>
{Header !== undefined && <Header sessionId={sessionId} useSession={useSession} />}
<View {...viewProps} />
{Footer !== undefined && <Footer sessionId={sessionId} useSession={useSession} />}
</>
)
}
return (
<div className={css.root}>
<header className={css.header}>
@@ -52,7 +91,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.title}
</button>
@@ -74,7 +113,7 @@ export function ConversationRoot({
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>
@@ -93,9 +132,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, ViewId } 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: ViewId) => 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 (previously layout.viewFor — store seat is the
* cross-remount survival channel, null falls back to the first registered view).
* @returns the store handle (spec + identity + factory in one value).
*/
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
return defineStore({
// Anchored to the contract shape: views consume the store through
// ConvViewProps' 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: ViewId) => { d.view = view },
},
})
}

View File

@@ -1,26 +1,32 @@
// @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, watch-driven open,
// 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).
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 type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
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 {
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 +41,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', 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 +60,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 +72,143 @@ 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' | '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> } }
}
return { ctx, slots, binding, sessionFake, sessionsFake, layoutFake, mint, entryOf }
return { ctx, slots, hostFace, entryOf, conversationSurface, 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, pulls history through the watch signal, 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
}
const { injected } = b.conversationSurface(ROOT)
expect(b.sessionFake.open).toHaveBeenCalledTimes(1)
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)
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('openDetails writes the selection through the store actions and opens the panel', 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 { instance, injected } = b.conversationSurface(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)
})
it('renderView mounts chrome header/footer around the view body', async () => {
it('views read face forwards to the service registry (subscribe/version)', 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 { title: 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)
const conversation = b.ctx.get('conversation') as
import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
const off = conversation.registerView({ id: 'chat2', label: 'X', component: () => null } as never)
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'])
off()
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')
})
})

View File

@@ -1,19 +1,18 @@
// @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.
// three slot registrations land against a root entry's children declarations
// (the AppFrame role), the shared store handle rides both session slots, 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.
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'
const ROOT = 'root-1' as SessionId
const CHILD = 'child-1' as SessionId
@@ -29,31 +28,42 @@ async function bench() {
[ROOT]: { id: ROOT, title: 'R', running: false, updatedAt: 1 },
[CHILD]: { id: CHILD, title: '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' | 'details' | 'conversation.empty') {
return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
}
describe('apply wiring', () => {
it('provides conversation and toolviews services', async () => {
const b = await bench()
@@ -71,14 +81,20 @@ describe('apply wiring', () => {
expect(views[0]?.chrome?.footer).toBeDefined()
})
it('occupies conversation/details/conversation.empty with inject factories', async () => {
it('occupies the three slots; session pair shares 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 details = renderEntryOf(b.slots, 'details')
const empty = renderEntryOf(b.slots, 'conversation.empty')
expect(conversation?.inject).toBeTypeOf('function')
expect(details?.inject).toBeTypeOf('function')
expect(empty?.inject).toBeTypeOf('function')
// The shared handle: one apply-built store value on BOTH session entries.
expect(conversation?.store).toBeDefined()
expect(details?.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 () => {

View File

@@ -9,8 +9,8 @@ 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 { hookOf } from './hook.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolViewProps, Translate } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
@@ -85,41 +85,40 @@ describe('small branch tails', () => {
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine sessionId={SID} useSession={bindSnapshotSelector(source) as unknown as UseSession} />,
<StatsLine sessionId={SID} useSession={hookOf(source) as unknown as 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', () => {
it('caches the inject factory per (registration x session) and merges its props', () => {
const registry = new ToolViewRegistry()
const inject = vi.fn(() => ({ extra: 'injected' }))
const inject = vi.fn((sessionId: SessionId) => ({ extra: `injected:${sessionId}` }))
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')
// Pure props machinery: the outlet feeds its own sessionId to the
// factory — no provider/context needed (terminal channel form).
const view = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
expect(view.getByTestId('row').textContent).toBe(`injected:${SID}`)
expect(inject).toHaveBeenCalledTimes(1)
// Remount against the SAME binding: cache hit, factory not re-run.
// Remount under the SAME session: cache hit, factory not re-run.
view.unmount()
const second = render(<Provider />)
expect(second.getByTestId('row').textContent).toBe('injected')
const second = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
expect(second.getByTestId('row').textContent).toBe(`injected:${SID}`)
expect(inject).toHaveBeenCalledTimes(1)
// A different session is a distinct cache key: factory runs once more.
second.unmount()
const other = render(
<ToolViewOutlet registry={registry} sessionId={'s2' as SessionId} toolName="bash" viewProps={viewProps()} />,
)
expect(other.getByTestId('row').textContent).toBe('injected:s2')
expect(inject).toHaveBeenCalledTimes(2)
})
it('a crashing custom row falls back to GenericToolCard and retries on re-registration', () => {

View File

@@ -8,8 +8,8 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode,
} 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 { hookOf } from './hook.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
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'
@@ -78,7 +78,7 @@ describe('deriveStats', () => {
describe('StatsLine', () => {
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): ChromeProps {
return { sessionId: SID, useSession: bindSnapshotSelector(source) as unknown as UseSession }
return { sessionId: SID, useSession: hookOf(source) as unknown as UseSession }
}
it('renders the joined stats row and hides with zero steps', () => {
@@ -154,6 +154,7 @@ describe('bash toolview samples', () => {
const scope = childSessionScope({
getSnapshot: () => ({
ids: [root, child],
current: undefined,
byId: {
[root]: { id: root, title: 'r', running: false, updatedAt: 0 },
[child]: { id: child, title: 'c', parentId: root, running: false, updatedAt: 0 },

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,7 +4,7 @@ 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 type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import { classifyTool, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'

View File

@@ -3,20 +3,26 @@
// 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,
} 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 { hookOf } from './hook.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConvViewProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '../src/client/stores.ts'
import { createChatView } 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
@@ -68,33 +74,17 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
const ChatView = createChatView({ toolviews: registry, t: (k) => k })
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const loadOlder = vi.fn()
const selection = makeSelection()
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the ConvViewProps useStore share).
const chat = createChatStore().create()
const props: ConvViewProps = {
sessionId: SID,
useSession: bindSnapshotSelector(source) as unknown as UseSession,
useSelection: bindSnapshotSelector(selection.source),
useSession: hookOf(source) as unknown as UseSession,
useStore: hookOf(chat),
actions: { openDetails, loadOlder },
slots: { renderSlot: () => null } as never,
}
return { set, registry, ChatView, props, openDetails, loadOlder, setSelection: selection.set }
}
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)
},
},
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return { set, registry, ChatView, props, openDetails, loadOlder, setSelection }
}
describe('chat-flow derivation', () => {

View File

@@ -8,7 +8,7 @@ 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 type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationService, Translate, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply as nodeApply } from '../src/index.ts'

View File

@@ -1,23 +1,23 @@
// @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: apply's
// need() throw, AssistantMarkdown non-final reasoning, StatsLine usage-less
// node, DetailsPanel titleless selection, registry disposer after a foreign
// removal emptied the list. (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 type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/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 { apply, inject, ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { 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 +31,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', cwd: '/proj', running: false, updatedAt: 1 },
['x2' as SessionId]: { id: 'x2' as SessionId, title: 'b', cwd: '', running: false, updatedAt: 1 },
['x3' as SessionId]: { id: 'x3' as SessionId, title: '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 +54,7 @@ describe('render branch tails', () => {
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine sessionId={SID} useSession={bindSnapshotSelector(source) as unknown as UseSession} />,
<StatsLine sessionId={SID} useSession={hookOf(source) as unknown as UseSession<ConversationSnapshot>} />,
)
expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
})
@@ -114,13 +67,20 @@ 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={hookOf({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSessions={hookOf(emptyList)}
useStore={hookOf(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
expect(view.getByText('详情')).toBeTruthy()

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 → 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 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') }))
@@ -57,11 +131,9 @@ describe('selection survives list refreshes (M1a)', () => {
await flush()
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('s1')
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 → formal title).
feed(b, [{ id: 's1', cwd: '/w/proj-a' }])
@@ -69,53 +141,40 @@ describe('selection survives list refreshes (M1a)', () => {
await flush()
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('proj-a')
// 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: title 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,10 @@
// @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), views ordering, 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).
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
@@ -13,7 +14,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 +34,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 +48,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 +64,15 @@ 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) })
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 +110,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 +139,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.
@@ -165,7 +150,7 @@ describe('service-unavailable loud failures', () => {
})
})
describe('views ordering and draft persistence branches', () => {
describe('views ordering', () => {
it('orders by explicit order with undefined treated as zero (both comparator arms)', async () => {
const b = await bench()
const entry = (id: string, order?: number) => ({
@@ -177,15 +162,4 @@ describe('views ordering and draft persistence branches', () => {
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 { 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, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
// 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'
afterEach(cleanup)
@@ -32,37 +38,52 @@ function sessionSource(over?: Partial<ConversationSnapshot>) {
}
}
const summary = (id: string, title: string): SessionSummary =>
({ id: id as SessionId, 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: 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,
id: 'chat', label: 'Chat', component: () => <div data-testid="view-body" />,
} as unknown as ViewEntry
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 ?? []}
useSession={hookOf(sessionSource(over?.snapshot)) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook(over?.rows ?? [])}
useStore={hookOf(chat)}
actions={chat.actions}
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" />}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
loadOlder={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 +94,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 +117,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' as never)
const view = render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource()) as unknown as UseSession}
useAncestry={() => []}
useSession={hookOf(sessionSource()) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={hookOf(chat)}
actions={chat.actions}
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}`} />}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
loadOlder={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 +178,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 +231,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 +246,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 +254,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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import { bindSnapshotSelector, createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
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 { 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, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
// 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,38 @@ 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: hookOf(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: 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: hookOf(store) }
}
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 +86,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 +96,117 @@ describe('EmptyState', () => {
})
describe('ConversationRoot', () => {
function bench(views: ViewEntry[], active?: string) {
function bench(views: ViewEntry[], 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 as never)
const send = vi.fn()
const stop = vi.fn()
const ancestry: SessionSummary[] = [
{ id: sid('root'), title: 'proj', running: false, updatedAt: 1 },
{ id: sid('s1'), title: 'child', running: false, updatedAt: 1, parentId: sid('root') },
]
const rendered: string[] = []
const openDetails = vi.fn()
const loadOlder = vi.fn()
const open = vi.fn()
const ui = render(
<ConversationRoot
sessionId={sid('s1')}
useSession={useSession}
useAncestry={() => ancestry}
useSessions={useSessions}
useStore={hookOf(chat)}
actions={chat.actions}
views={{
list: () => views,
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}
openDetails={openDetails}
loadOlder={loadOlder}
open={open}
/>)
return { ui, openView, open, rendered, send, drafts }
return { ui, chat, send, stop, open }
}
const comp = (() => null) as unknown as FC<never>
/** View bodies record their mount via testid (renderView is in-component now). */
const view = (id: string, label: string): ViewEntry =>
({ id, label, component: comp }) as unknown as ViewEntry
({
id, label,
component: (() => <div data-testid={`view-${id}`} />) as unknown as FC<never>,
}) as unknown as ViewEntry
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([view('chat', 'Chat'), view('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([view('chat', 'Chat'), view('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([view('chat', 'Chat'), view('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('mounts chrome header/footer around the view body', () => {
const entry = {
id: 'chat', label: 'Chat',
component: () => <div data-testid="body" />,
chrome: {
header: () => <div data-testid="hd" />,
footer: () => <div data-testid="ft" />,
},
} as unknown as ViewEntry
bench([entry])
expect(screen.getByTestId('hd')).toBeTruthy()
expect(screen.getByTestId('body')).toBeTruthy()
expect(screen.getByTestId('ft')).toBeTruthy()
})
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
const { chat, send } = bench([view('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={hookOf(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

@@ -8,7 +8,6 @@
// 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'
@@ -66,8 +65,9 @@ describe('tool-ring full chain (positive dual)', () => {
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,
// Terminal channel form: the factory receives the session id only.
inject: (sessionId: SessionId): RowInjected => ({
useRuns: () => sessionId.length,
actions2: { rerun: () => {} },
}),
})
@@ -81,9 +81,7 @@ describe('tool-ring full chain (positive dual)', () => {
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 },
)
const injected = (global?.inject as (sessionId: SessionId) => RowInjected)(sid('ab'))
expect(injected.useRuns()).toBe(2)
// Unknown tool → undefined (caller falls back to the generic card).
expect(registry.resolve('ghost-tool', sid('other'))).toBeUndefined()

View File

@@ -69,6 +69,17 @@ describe('view-ring type-chain negatives (compile-time; body never runs)', () =>
chrome: { header: (props: ChromePropsOf<'vt-plain'> & { onlyPlain: true }) => null },
}
void mixed
// 6. Zero-renderSlot inference: the view ring declares no children, so
// view props carry no delegation face (the old hand-written
// ScopedSlots<never> empty surface is retired, not replaced).
const renderless = (props: ConvViewPropsOf<'vt-plain'>): ReactNode => {
// @ts-expect-error views receive no renderSlot — no sub-slot delegation
void props.renderSlot
// @ts-expect-error the legacy slots face is gone from view props
void props.slots
return null
}
void renderless
return null as ReactNode
}
expect(negatives).toBeTypeOf('function')

View File

@@ -2,7 +2,9 @@
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 60px control rail while details closes to zero width. 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

@@ -1,43 +1,36 @@
/**
* 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>
}
@@ -86,10 +79,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 +105,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,29 +114,46 @@ 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 }, [])
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={!sidebar.open || undefined}
data-sidebar-collapsed={cols.sidebar === 0 || undefined}
data-details-collapsed={cols.details === 0 || undefined}
>
<div className={css.sidebarCol}>{props.sidebar}</div>
{props.children}
{sidebar.open && cols.sidebar > 0
? <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} />
: null}
<div className={css.sidebarCol}>
{/* Render-site slot call with live concession output: the sidebar
stays mounted at zero width (CSS hides it), and sees its rendered
state as owner params decided here, not precomputed upstream. */}
{renderSlot('sidebar', { collapsed: cols.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>
{cols.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} />}
{cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} />}
</div>
)

View File

@@ -1,14 +1,12 @@
/**
* 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. A closed sidebar
* keeps its compact rail; persisted open/width preferences are never rewritten,
* so widening the window restores them. Center absorbs any remaining deficit.
* details first, then sidebar, then auto-closing details (derived zero width —
* 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).
*/
/** 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,8 +19,6 @@ export const SIDEBAR_MIN = 240
export const SIDEBAR_MAX = 420
/** Sidebar width before any user drag. */
export const SIDEBAR_DEFAULT = 300
/** Closed-sidebar rail: one 28px control between 16px horizontal paddings. */
export const SIDEBAR_COLLAPSED = 60
/** Details drag clamp floor. */
export const DETAILS_MIN = 300
/** Details drag clamp ceiling. */
@@ -46,14 +42,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, while a closed sidebar keeps its compact rail.
* @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).
*/
export function computeColumns(viewport: number, sidebar: PanelInput, details: PanelInput): Columns {
const s0 = sidebar.open ? clampWidth(sidebar.width, SIDEBAR_MIN, SIDEBAR_MAX) : SIDEBAR_COLLAPSED
const d0 = details.open ? clampWidth(details.width, DETAILS_MIN, DETAILS_MAX) : 0
export function computeColumns(viewport: number, sidebar: number, details: number): Columns {
const s0 = sidebar === 0 ? 0 : 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 }
@@ -63,14 +61,14 @@ export function computeColumns(viewport: number, sidebar: PanelInput, details: P
if (s0 + d1 + CENTER_MIN <= viewport) return { sidebar: s0, center: CENTER_MIN, details: d1 }
// Step 3: shrink sidebar toward its minimum.
const s1 = sidebar.open ? Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN) : SIDEBAR_COLLAPSED
const s1 = s0 === 0 ? 0 : 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 = sidebar.open ? Math.max(SIDEBAR_MIN, viewport - CENTER_MIN) : SIDEBAR_COLLAPSED
const s2 = s0 === 0 ? 0 : Math.max(SIDEBAR_MIN, viewport - CENTER_MIN)
return { sidebar: s2, center: Math.max(0, viewport - s2), details: 0 }
}

View File

@@ -1,23 +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_COLLAPSED, 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 {
@@ -27,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 }
@@ -40,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 concession chain rendered the column at zero width. */
collapsed: boolean
/** Rendered column width in px (0 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,33 @@
// @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, SIDEBAR_COLLAPSED } 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 { 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 +41,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 +89,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 +113,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,30 +160,21 @@ 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 and mounted slot content', () => {
const { frame, sidebar, getByTestId } = mountFrame()
act(() => { sidebar.update((d) => { d.open = false }) })
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360])
expect(getByTestId('sidebar-content')).toBeTruthy()
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
})
it('viewport shrink triggers the concession chain via ResizeObserver', () => {
const { frame } = mountFrame()
frameWidth = 1250
@@ -139,31 +186,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(() => {
@@ -174,11 +221,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(() => {
@@ -186,7 +233,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)', () => {
@@ -208,7 +255,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_COLLAPSED, SIDEBAR_DEFAULT, SIDEBAR_MIN,
} from '@deepseek-ai/dsh-client-ui-layout/client'
DETAILS_DEFAULT, DETAILS_MIN, 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,9 +22,8 @@ describe('computeColumns', () => {
expect(cols).toEqual({ sidebar: 300, center: 1920 - 300 - 360, details: 360 })
})
it('closed sidebar keeps its compact rail while details contributes zero width', () => {
expect(computeColumns(1920, closed(300), closed(360)))
.toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 1920 - SIDEBAR_COLLAPSED, details: 0 })
it('closed panels contribute zero width', () => {
expect(computeColumns(1920, closed(300), closed(360))).toEqual({ sidebar: 0, center: 1920, details: 0 })
})
it('preferences beyond the clamp range are clamped before solving', () => {
@@ -70,14 +70,10 @@ describe('computeColumns', () => {
})
it('sidebar-closed narrow window: details concedes then auto-closes', () => {
const fits = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN, closed(300), open(DETAILS_DEFAULT))
expect(fits).toEqual({ sidebar: SIDEBAR_COLLAPSED, center: CENTER_MIN, details: DETAILS_MIN })
const starved = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN - 1, closed(300), open(DETAILS_DEFAULT))
expect(starved).toEqual({
sidebar: SIDEBAR_COLLAPSED,
center: DETAILS_MIN + CENTER_MIN - 1,
details: 0,
})
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 })
})
it('tiny viewport: both panels yield everything to center', () => {
@@ -98,8 +94,8 @@ 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 the compact rail as the sidebar floor.
// Reaches step 4's re-solve with s0 = 0 (the closed-sidebar arm).
expect(computeColumns(500, closed(300), open(DETAILS_DEFAULT)))
.toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 500 - SIDEBAR_COLLAPSED, details: 0 })
.toEqual({ sidebar: 0, center: 500, 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, 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,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. Its collapsed render keeps the expand control and settings entry in the layout-owned compact rail. Contract: api-contracts v3 §6.
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. 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/sidebar-open hooks, 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,11 +1,12 @@
/**
* 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.
* 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).
*/
import { Fragment, useState } from 'react'
import { Fragment, useMemo, useState } from 'react'
import clsx from 'clsx'
import {
FishLogo,
@@ -14,6 +15,7 @@ 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'
@@ -24,14 +26,28 @@ const GROUP_BY_ITEMS = [
{ id: 'status', label: 'Status', disabled: true },
]
type SidebarBodyProps = Pick<SidebarRootComponentProps, 'useTree' | 'useCurrent' | 'actions' | 'tree'>
/** 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]
}
/** Expanded-only content; unmounting drops tree/current subscriptions while the rail is collapsed. */
function SidebarBody({ useTree, useCurrent, actions, tree }: SidebarBodyProps) {
const rows = useTree((s) => s.rows)
const query = useTree((s) => s.query)
const groupBy = useTree((s) => s.groupBy)
const current = useCurrent()
/**
* Render the sidebar column.
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
* @returns the sidebar element tree.
*/
export function SidebarRoot({ useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
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 [query, setQuery] = useState('')
const rows = useMemo(
() => deriveRows(list, { expandedProjects, expandedSessions, query }),
[list, expandedProjects, expandedSessions, query],
)
const [menuOpen, setMenuOpen] = useState(false)
const now = Date.now()
@@ -45,14 +61,39 @@ function SidebarBody({ useTree, useCurrent, actions, tree }: SidebarBodyProps) {
}
return (
<div className={css.listArea}>
<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={() => { onToggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
</div>
<button type="button" className={css.newSession} onClick={() => { onCreate() }}>
<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}
selectedId="workspace"
onSelect={() => { setMenuOpen(false) }}
align="end"
anchor={(
@@ -70,7 +111,7 @@ function SidebarBody({ useTree, useCurrent, actions, tree }: SidebarBodyProps) {
type="button"
className={css.iconButton}
aria-label="New workspace"
onClick={() => { actions.create() }}
onClick={() => { onCreate() }}
>
<IconProjectAddOutline16 />
</button>
@@ -83,14 +124,14 @@ function SidebarBody({ useTree, useCurrent, actions, tree }: SidebarBodyProps) {
type="text"
placeholder="Search name, keywords..."
value={query}
onChange={(e) => { tree.setQuery(e.target.value) }}
onChange={(e) => { setQuery(e.target.value) }}
/>
{query !== '' && (
<button
type="button"
className={css.clearButton}
aria-label="Clear search"
onClick={() => { tree.setQuery('') }}
onClick={() => { setQuery('') }}
>
<IconCloseFill14 />
</button>
@@ -109,8 +150,8 @@ function SidebarBody({ useTree, useCurrent, actions, tree }: SidebarBodyProps) {
<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>
)
@@ -120,72 +161,17 @@ function SidebarBody({ useTree, useCurrent, actions, tree }: SidebarBodyProps) {
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 (owner share + injected surface, contract/slots.ts).
* @returns the sidebar element tree.
*/
export function SidebarRoot(props: SidebarRootComponentProps) {
const open = props.useSidebarOpen()
return (
<div className={clsx(css.root, !open && css.collapsed)}>
<div className={css.headerBlock}>
<div className={css.logoRow}>
{open
? (
<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>
)
: null}
<button
type="button"
className={css.iconButton}
aria-label={open ? 'Collapse sidebar' : 'Expand sidebar'}
onClick={() => { props.actions.toggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
</div>
{open
? (
<button type="button" className={css.newSession} onClick={() => { props.actions.create() }}>
<IconNewChatOutline16 size={14} />
New Session
</button>
)
: null}
</div>
{open
? (
<SidebarBody
useTree={props.useTree}
useCurrent={props.useCurrent}
actions={props.actions}
tree={props.tree}
/>
)
: null}
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
<div className={clsx(css.foot)} role="button" tabIndex={0} aria-label="Settings">
<IconSettingsOutline14 />
{open ? <span>Settings</span> : null}
Settings
</div>
</div>
)

View File

@@ -1,51 +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
/** Sidebar open selector; the collapsed render keeps only persistent rail controls. */
useSidebarOpen: () => boolean
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,72 +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),
useSidebarOpen: () => layout.sidebar.useSelector(s => s.open),
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 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,57 +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', cwd: '/proj', running: false, updatedAt: 1 } },
current: undefined,
})
const sessions = { list, create: vi.fn(async () => sid('minted')) }
const sidebar = createSnapshotStore({ open: true, width: 300 })
const layout = {
current: createSnapshotStore<{ sessionId?: SessionId }>({}),
sidebar,
open: vi.fn(),
toggleSidebar: vi.fn(() => { sidebar.update((d) => { d.open = !d.open }) }),
}
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', () => {
@@ -60,105 +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()
expect(screen.getByLabelText('Expand sidebar')).toBeTruthy()
expect(screen.getByLabelText('Settings')).toBeTruthy()
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
expect(layout.toggleSidebar).toHaveBeenCalledTimes(2)
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', 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
@@ -42,31 +49,29 @@ 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 sidebar = createSnapshotStore({ open: true })
const actions: SidebarActions = {
open: vi.fn((id: SessionId) => { current.update((d) => { d.id = id }) }),
create: vi.fn(),
toggleSidebar: vi.fn(() => { sidebar.update((d) => { d.open = !d.open }) }),
}
// 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()
const onToggleSidebar = vi.fn()
const utils = render(
<SidebarRoot
useTree={tree.store.useSelector}
useCurrent={() => current.useSelector((s) => s.id)}
useSidebarOpen={() => sidebar.useSelector((s) => s.open)}
actions={actions}
tree={tree}
collapsed={false}
width={300}
useSessions={hookOf(sessions)}
onOpen={onOpen}
onCreate={onCreate}
onToggleSidebar={onToggleSidebar}
/>,
)
return { list, tree, current, sidebar, actions, ...utils }
return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
}
const projectData = () => [
@@ -75,6 +80,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())
@@ -97,11 +105,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')
})
@@ -131,32 +141,20 @@ 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('collapsed rail keeps the expand and settings controls', () => {
const { actions } = mount(...projectData())
it('collapse button and group-by menu behave', () => {
const { onToggleSidebar } = mount(...projectData())
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
expect(actions.toggleSidebar).toHaveBeenCalledOnce()
expect(screen.getByLabelText('Expand sidebar')).toBeTruthy()
expect(screen.getByLabelText('Settings')).toBeTruthy()
expect(screen.queryByText('HARNESS')).toBeNull()
expect(screen.queryByText('New Session')).toBeNull()
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
expect(actions.toggleSidebar).toHaveBeenCalledTimes(2)
expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy()
expect(screen.getByText('New Session')).toBeTruthy()
})
it('group-by menu behaves', () => {
mount(...projectData())
expect(onToggleSidebar).toHaveBeenCalledOnce()
expect(screen.queryByText('Update')).toBeNull()
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
expect(screen.getByText('Update')).toBeTruthy()
@@ -171,28 +169,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,111 +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,
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
@@ -32,12 +32,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 ?? '',
})
@@ -94,7 +94,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 }),
@@ -112,8 +112,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 }),
@@ -124,7 +124,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'])
})
@@ -133,7 +133,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 }),
@@ -147,7 +147,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')
@@ -160,7 +160,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'])
})
@@ -170,7 +170,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'])
})
@@ -178,7 +178,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,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",

View File

@@ -1,18 +1,23 @@
/**
* Slot registry pure core. Owners declare slot contracts by merging into
* {@link SlotMap}; `define` records the runtime spec, `register` contributes a
* component. Zero runtime dependencies (React types only).
* Slot registry pure core (slot terminal design). Owners declare slot
* contracts by merging into {@link SlotMap}; one `register` call contributes a
* component AND (optionally) declares child slots, a store seat, and the
* registrant's business face. Zero runtime dependencies (React types only).
*
* SlotMap and its companion types live directly in this entry module: consumer
* `declare module` augmentation merges with declarations lexically in the
* augmented module, not with re-exports.
* SlotMap and the standard-kit interfaces live directly in this entry module:
* consumer `declare module` augmentation merges with declarations lexically in
* the augmented module, not with re-exports.
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in THIS compilation unit (so the intersection reads as `never`), but every
* consumer merges keys in and the intersection is what keeps them string-typed.
* The rule fires on the empty-map view, not on real redundancy. */
import type { FC, ReactNode } from 'react'
import type { ReactNode } from 'react'
import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl } from './store.ts'
export * from './store.ts'
export * from './renderer.ts'
/** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */
export interface SlotMap {}
@@ -24,147 +29,238 @@ export type SlotKind = 'single' | 'list' | 'keyed'
export type SlotScope = 'root' | 'session'
/**
* One SlotMap entry: kind/scope axes, the owner-supplied props share, and an
* optional sub-slot whitelist. Ownership rule (a share's type lives with
* whoever wires it): `owner` is the render-side share declared by the
* slot-owning package and REFERENCED by registrants; the registrant's own
* injected share never enters this table — full component props are composed
* at the component as `OwnerOf<K> & <injector-narrowed standard> & OwnInjected`.
* `props` is the legacy full-props slot kept while consumers migrate to
* composed declarations (new entries declare `owner` and omit it).
* One SlotMap entry: kind/scope axes plus the optional owner-supplied props
* share (`owner` is what the parent passes at its renderSlot call site; the
* framework standard kit and the registrant's injected share never enter this
* table — full component props compose at the component as the four-share
* intersection, see {@link ComposedProps}).
*/
export interface SlotEntryDef {
kind: SlotKind
scope: SlotScope
props?: object
owner?: object
children?: keyof SlotMap & string
}
/**
* Owner-supplied props share for a slot key: the render-side contract half.
* Falls back to the legacy Partial form when the entry declares no `owner`.
* Runtime dispatch spec for one slot, recorded from a register call's
* `children` value. The literal is compile-time checked against the SlotMap
* entry (`SlotSpec<SlotMap[P]>` in {@link ChildrenDecl}), so type and value
* are declared at one point and validate each other.
*/
export type OwnerOf<K extends keyof SlotMap & string> =
SlotMap[K] extends { owner: infer O extends object } ? O : OwnerProps<SlotMap[K]>
/**
* CONSTRAINT-position standard share for register(): the framework supplies
* session slots' bound selector hook, so it is bottom-typed here — any
* registrant narrowing (e.g. a runtime-typed conversation hook) is accepted,
* and the type responsibility for what actually arrives lives with the
* injecting side (web-react's renderer). Do NOT compose component props from
* this type; declare the narrowed hook the component actually consumes.
*/
export type StandardOf<K extends keyof SlotMap & string> =
SlotMap[K]['scope'] extends 'session' ? { useSession: never } : object
/**
* Delegable sub-slot whitelist declared by an entry: the `children` key union,
* or `never` when the entry declares none (no delegation authorized).
*/
export type ChildrenOf<K extends keyof SlotMap & string> =
SlotMap[K] extends { children: infer C extends keyof SlotMap & string } ? C : never
/**
* B-b optional validation layer over the hand-written whitelist (B-a): the
* composed register constraint carries a `slots` face whose key union is the
* entry's `children` declaration. Components wanting a SUBSET whitelist
* accept it through ScopedSlots covariance; an out-of-union whitelist makes
* the component's `slots` parameter unsatisfiable and the register call site
* reports it. Entries without `children` add no slots constraint, so any
* hand-written whitelist registers freely (the layer is opt-in per entry).
* This is constraint-side only: whether slots are actually delivered stays
* with the renderer/owner (B-a trust).
*/
export type SlotsFaceOf<K extends keyof SlotMap & string> =
[ChildrenOf<K>] extends [never]
? object
: { slots: ScopedSlots<ChildrenOf<K>> }
/**
* The registration-boundary composed props constraint: owner share + standard
* share + registrant share, gated by the entry's `children` authorization
* (see {@link ChildrenChecked}). Entries without an `owner` declaration
* (legacy full-props form) keep the plain props constraint until they migrate.
*/
export type ComposedProps<K extends keyof SlotMap & string, I extends object> =
SlotMap[K] extends { owner: infer O extends object }
? O & StandardOf<K> & SlotsFaceOf<K> & I
: PropsShape<SlotMap[K]>
/**
* Registration-position component shape: the bare call signature, so the
* ComposedProps constraint checks through clean parameter contravariance
* (FC's propTypes/defaultProps statics add covariant noise that rejects
* legitimate narrowings of the bottom-typed standard share).
*/
export type SlotComponent<P> = (props: P) => ReactNode
/** The stored component's props shape: the legacy full-props slot, or wide for composed entries. */
export type PropsShape<E extends SlotEntryDef> =
E extends { props: infer P extends object } ? P : object
/**
* Session-scoped assembly handle passed to inject factories (apply world
* only, never into props). `Ctx` defaults to unknown at this zero-dependency
* layer; runtime re-exports the ClientContext-narrowed alias.
*/
export interface SessionBinding<Ctx = unknown> {
readonly sessionId: string
readonly session: SessionAccess
readonly ctx: Ctx
}
/** Root-scoped assembly handle passed to inject factories. */
export interface RootBinding<Ctx = unknown> { readonly ctx: Ctx }
/** Session subscription surface; web-react narrows `useSelector` to the typed hook. */
export interface SessionAccess { readonly useSelector: unknown }
/**
* Factory producing the registrant's private injected props, called once per
* (entry x session) for session slots or per entry for root slots. `I` is the
* registrant's own injected share, inferred at the registration site. `Ctx`
* parameterizes the binding's context (default unknown keeps this layer
* dependency-free); runtime's narrowed binding aliases flow through here so
* factories written against a narrowed ctx type-check without a cast.
*/
export type InjectFactory<E extends SlotEntryDef, I extends object = Record<string, unknown>, Ctx = unknown> =
(b: E['scope'] extends 'session' ? SessionBinding<Ctx> : RootBinding<Ctx>) => I
/** Runtime spec recorded at define time; must match the SlotMap declaration. */
export interface SlotSpec<E extends SlotEntryDef> { kind: E['kind']; scope: E['scope'] }
/**
* Registration options, shaped by the slot kind and the registrant's injected
* share `I`. `Ctx` flows through to the inject factory's binding parameter
* (narrowing wrappers fix it to their client context type).
* Child-slot declaration table for register(): keys are the declared (and
* thereby render-authorized) slot names, values are their runtime dispatch
* specs. Declaring is claiming: the registering entry becomes the only entry
* allowed to render these keys.
*/
export type SlotOptions<E extends SlotEntryDef, I extends object = Record<string, unknown>, Ctx = unknown> =
E['kind'] extends 'keyed' ? { key: string; inject?: InjectFactory<E, I, Ctx> }
: E['kind'] extends 'list' ? { id: string; order?: number; label?: string; inject?: InjectFactory<E, I, Ctx> }
: { inject?: InjectFactory<E, I, Ctx> }
export type ChildrenDecl = { [P in keyof SlotMap & string]?: SlotSpec<SlotMap[P]> }
/** register() trailing args: options are statically mandatory for keyed/list kinds (key/id live there). */
export type RegisterArgs<E extends SlotEntryDef, I extends object = Record<string, unknown>, Ctx = unknown> =
E['kind'] extends 'keyed' | 'list' ? [options: SlotOptions<E, I, Ctx>] : [options?: SlotOptions<E, I, Ctx>]
/** Owner-supplied props share for a slot key ({} for entries declaring no `owner`). */
export type OwnerOf<K extends keyof SlotMap & string> =
SlotMap[K] extends { owner: infer O extends object } ? O : object
/** One registered contribution: the component plus its registration options. */
export interface SlotEntry<E extends SlotEntryDef, I extends object = Record<string, unknown>> {
component: FC<PropsShape<E>>
options: SlotOptions<E, I>
/** Scope axis of a slot key's SlotMap entry. */
export type ScopeOf<K extends keyof SlotMap & string> = SlotMap[K]['scope']
/**
* Framework standard kit delivered to every session-scope slot component.
* Declared EMPTY here (zero-dependency layer): the runtime package merges the
* real members (`useSession` bound to the conversation snapshot and the
* framework-supplied `sessionId`) exactly as consumers merge SlotMap keys.
*/
export interface SessionStandardProps {}
/**
* Framework standard kit delivered to EVERY slot component (the global seat).
* Declared empty here; the runtime package merges `useSessions` (the session
* list selector hook — the sidebar tree's single derivation source).
*/
export interface GlobalStandardProps {}
/**
* The session id type as the runtime's SessionStandardProps merge declares it
* (branded); falls back to `string` in programs without the merge (this
* package's own tests).
*/
export type SessionIdOf = SessionStandardProps extends { sessionId: infer S } ? S : string
/**
* Runtime props share for a slot key: owner share (parent's renderSlot call
* site) + session standard kit (session scope only) + the global seat.
*/
export type PropsRuntime<K extends keyof SlotMap & string> =
OwnerOf<K> &
(ScopeOf<K> extends 'session' ? SessionStandardProps : object) &
GlobalStandardProps
/** renderSlot dispatch options: keyed dispatch key, list filtering, empty fallback. */
export interface RenderOpts { entryKey?: string; only?: string; fallback?: ReactNode }
/**
* Conversation-session selector hook alias for props contracts. Wide by
* default at this dependency-inverted layer; the runtime narrows at its
* export seam (`UseSession<ConversationSnapshot>`).
*/
export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
/** Props of the standard-kit SessionProvider seat (render-prop form). */
export interface SessionAreaProps {
/** No-session body (also covers a current id whose session cannot be resolved). */
empty?: (() => ReactNode) | undefined
/** Session body; the framework remounts it per session (key=sessionId). */
children: (sessionId: SessionIdOf) => ReactNode
}
/** Type-erased stored entry; public typing is restored at the entries() boundary. */
interface StoredEntry {
/**
* The framework-wired session area component (slot terminal design §7):
* subscribes to the current-session selection internally (design fiat ① —
* selection authority lives with runtime sessions) and switches between the
* session body and the empty branch. Delivered as a standard seat to every
* entry whose children declaration contains a session-scope slot (the
* derivation rides {@link PropsRenderSlots}); the value is injected by the
* installed renderer — business code never imports it.
*/
export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode
/**
* Child-slot render share: `renderSlot` statically narrowed to the entry's
* declared children keys. Delegation is plain props passing (hand
* `props.renderSlot` down); the authorizing identity stays the registering
* entry. `__renders` is a phantom variance anchor (never materialized):
* generic method signatures compare loosely across differing key unions, so
* this contravariant marker is what actually enforces "component key set ⊆
* children declaration" at the register call site.
*/
export type PropsRenderSlots<S extends keyof SlotMap & string> = {
/**
* Render a declared child slot.
* @param key - declared child key.
* @param owner - owner props share for that key (decided at the render site).
* @param opts - kind dispatch options.
* @returns rendered node(s).
*/
renderSlot: <K extends S>(key: K, owner: OwnerOf<K>, opts?: RenderOpts) => ReactNode
readonly __renders?: ((key: S) => void) | undefined
} & ('session' extends ScopeOf<S>
// The SessionProvider seat rides the same source as renderSlot: declaring
// a session-scope child is what makes a session area exist, so the seat
// derives from the children key set's scopes (renderer injects the value).
? { SessionProvider: SessionProviderComponent }
: object)
/**
* Registration-position component shape: the bare call signature, so composed
* constraints check through clean parameter contravariance (FC statics add
* covariant noise rejecting legitimate narrowings).
*/
export type SlotComponent<P> = (props: P) => ReactNode
/**
* The four-share component props intersection: runtime share (SlotMap) +
* child-render share (children declaration) + store share (declared handle) +
* the registrant's injected business face. Each share derives from its single
* source of truth; components reference this composition, never re-type it.
*/
export type ComposedProps<
K extends keyof SlotMap & string,
S extends keyof SlotMap & string,
H,
I extends object,
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & I
/**
* Inject factory parameter list, derived from the registration's declaration:
* session slots receive the framework-resolved `sessionId`; a declared store
* appends the baked `actions` (the same callbacks the component receives);
* root slots without a store take no parameters. Business data access happens
* through the apply closure's ctx — no binding object parameter exists.
*/
export type InjectParams<K extends keyof SlotMap & string, H> =
ScopeOf<K> extends 'session'
? ([H] extends [StoreDecl] ? [sessionId: SessionIdOf, actions: BoundActions<HandleOf<H>>] : [sessionId: SessionIdOf])
: ([H] extends [StoreDecl] ? [actions: BoundActions<HandleOf<H>>] : [])
/** Kind shape fields carried in register options (keyed dispatch key; list id/order/label). */
export type KindOptions<E extends SlotEntryDef> =
E['kind'] extends 'keyed' ? { key: string }
: E['kind'] extends 'list' ? { id: string; order?: number; label?: string }
: object
/**
* Compile-time presence check: an entry declaring children MUST consume
* `renderSlot` (declaring is claiming — an entry that does not render its
* children should not declare them). Evaluates to an unsatisfiable
* intersection member naming the declared keys when violated.
*/
type RendersCheck<C, D> =
[keyof D & keyof SlotMap & string] extends [never] ? unknown
: C extends (props: infer P) => ReactNode
? ('renderSlot' extends keyof P ? unknown
: { 'children declared but the component consumes no renderSlot': keyof D & keyof SlotMap & string })
: unknown
/** Common register options share (see {@link SlotCore.register} for semantics). */
type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H> = {
/** Target slot key (the entry contributes INTO this slot). */
name: K
/** Child-slot declaration + render authorization + runtime spec, in one table. */
children?: D
/** Store seat: a shared handle (apply-constructed) or an exclusive factory (framework-called per entry x scope). */
store?: H
/** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */
registrant?: string
} & KindOptions<SlotMap[K]>
/**
* One stored registration, as recorded by the core and read by the render
* machinery (type-erased at this boundary; the register seam already proved
* the shares against the component).
*/
export interface StoredEntry {
component: unknown
options: { key?: string; id?: string; order?: number; label?: string; inject?: unknown }
options: { key?: string; id?: string; order?: number; label?: string }
/** Registrant business face; positional params derive from the declaration (sessionId?, actions?). */
inject?: ((...args: never[]) => Record<string, unknown>) | undefined
/** Child-slot declaration table (declaration + authorization + runtime spec in one). */
children?: Readonly<Record<string, SlotSpec<SlotEntryDef>>> | undefined
/** Declared store seat (instance resolution and lifecycle live with the host machinery). */
store?: StoreDecl | undefined
/** Diagnostics label of who registered. */
registrant?: string | undefined
}
/** Per-key registry record. Created on first define/subscribe/version read; never removed (version stays monotonic across redefines). */
/**
* Type-erased options view the implementation works with. Optional members
* carry explicit `| undefined`: under exactOptionalPropertyTypes the public
* overloads (whose generics admit undefined) would otherwise fail
* overload-to-implementation compatibility.
*/
interface ErasedOptions {
name: string
key?: string | undefined
id?: string | undefined
order?: number | undefined
label?: string | undefined
children?: Record<string, SlotSpec<SlotEntryDef>> | undefined
store?: StoreDecl | undefined
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
* implementation-signature position only (both public overloads type inject
* exactly); `never[]` would fail overload-to-implementation compatibility
* against the per-declaration InjectParams tuples. */
inject?: ((...args: any) => Record<string, unknown>) | undefined
registrant?: string | undefined
}
/**
* Per-key registry record. Created on first touch; never removed (version
* stays monotonic across redeclarations).
*/
interface SlotRecord {
spec: SlotSpec<SlotEntryDef> | undefined
/** Diagnostics: which slot's entry declared this key ('(built-in)' for root). */
declaredBy: string | undefined
entries: readonly StoredEntry[]
version: number
listeners: Set<() => void>
@@ -173,8 +269,11 @@ interface SlotRecord {
const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
/**
* Pure slot registry (no cordis; event emission lives in the runtime Service
* wrapper via {@link SlotCore.onMutate}).
* Pure slot registry (no cordis; event emission and the renderer install seam
* live in the runtime Service wrapper).
*
* The 'root' slot is the one a-priori declaration, seeded at construction
* (single/root, declared by the framework) — the render tree's root hole.
*
* Change propagation contract: versions bump and {@link SlotCore.onMutate}
* fires synchronously per mutation (registry state is consistent when they
@@ -184,101 +283,187 @@ const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
export class SlotCore {
private records = new Map<string, SlotRecord>()
private mutateListeners = new Set<(key: string) => void>()
/** Shared-handle scope ledger: handle → the scope it first mounted under + live mount count. */
private handleScopes = new Map<object, { scope: SlotScope; count: number }>()
// Dirty records, not keys: records are never removed, so holding the
// reference skips a lookup (and an unreachable missing-record branch) at flush.
private dirty = new Set<SlotRecord>()
private flushScheduled = false
constructor() {
// The a-priori root hole. No markDirty: nothing can observe construction.
const root = this.record('root')
root.spec = { kind: 'single', scope: 'root' }
root.declaredBy = '(built-in)'
}
/**
* Record a slot's runtime spec. Registering into an undefined key throws;
* defining an already-defined key throws (one owner per slot).
* @param key - SlotMap key.
* @param spec - kind/scope spec matching the declaration.
* @returns disposer removing the definition and its entries (idempotent; a
* stale disposer after redefine is a no-op).
* Contribute a component to a declared slot and (optionally) declare child
* slots, a store seat, and the registrant's business face — the single
* composition API (the separate define API is retired).
*
* Load-time validation (misconfiguration fails loud; the render hot path
* re-checks nothing): registering into an undeclared slot throws; declaring
* an already-declared child key throws (one declarer per slot — the message
* names the first declarer); mounting one shared store handle under slots
* of different scopes throws. Kind constraints: single — duplicate
* registration throws; keyed — missing/duplicate `key` throws; list —
* missing/duplicate `id` throws.
*
* Lifecycle: the disposer removes the contribution AND collapses every
* declared child slot (child entries clear recursively; their stale
* disposers become no-ops) — one lifecycle axis, no dangling state.
*
* @param options - registration options: target `name`, `children`
* declaration table, `store` seat, `inject` business-face factory, kind
* shape fields (keyed `key`; list `id`/`order`/`label`).
* @param component - component honoring the four-share composed props
* contract ({@link ComposedProps}); checked at this call site.
* @returns disposer removing the registration and its declarations
* (idempotent; stale disposers after a cascade are no-ops).
*/
define<K extends keyof SlotMap & string>(key: K, spec: SlotSpec<SlotMap[K]>): () => void {
const rec = this.record(key)
if (rec.spec) throw new Error(`slot "${String(key)}" is already defined`)
const recorded: SlotSpec<SlotEntryDef> = spec
rec.spec = recorded
this.markDirty(key, rec)
register<
K extends keyof SlotMap & string,
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: BaseOptions<K, D, H> & { inject?: undefined },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object>>
& RendersCheck<C, D>,
): () => void
/**
* Inject-bearing overload: identical semantics to the overload above, plus
* the registrant's business face — `I` is inferred from the inject
* factory's return and joins the component's composed-props constraint
* (factory parameters derive from the declaration, {@link InjectParams}).
* @param options - registration options plus the `inject` business-face factory.
* @param component - component honoring the four-share composed props
* contract including the inject share `I`.
* @returns disposer removing the registration and its declarations.
*/
register<
K extends keyof SlotMap & string,
I extends object,
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: BaseOptions<K, D, H> & { inject: (...args: InjectParams<K, H>) => I },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I>>
& RendersCheck<C, D>,
): () => void
register(options: ErasedOptions, component: unknown): () => void {
const rec = this.records.get(options.name)
if (!rec?.spec) {
throw new Error(`slot "${options.name}" is not declared (a parent entry's children table must declare it)`)
}
const spec = rec.spec
// Kind constraints stay runtime checks for dynamically-composed callers;
// typed callers already satisfied KindOptions statically.
switch (spec.kind) {
case 'single':
if (rec.entries.length > 0) throw new Error(`single slot "${options.name}" already has a registration`)
break
case 'keyed':
if (options.key === undefined) throw new Error(`keyed slot "${options.name}" requires options.key`)
if (rec.entries.some(e => e.options.key === options.key)) {
throw new Error(`keyed slot "${options.name}" already has an entry for key "${options.key}"`)
}
break
case 'list':
if (options.id === undefined) throw new Error(`list slot "${options.name}" requires options.id`)
if (rec.entries.some(e => e.options.id === options.id)) {
throw new Error(`list slot "${options.name}" already has an entry with id "${options.id}"`)
}
break
}
if (options.children) {
for (const childKey of Object.keys(options.children)) {
const childRec = this.records.get(childKey)
if (childRec?.spec) {
throw new Error(`slot "${childKey}" is already declared (by ${childRec.declaredBy ?? 'an unknown entry'})`)
}
}
}
// Shared handles pin their scope on first mount; factories are exempt
// (the framework creates per-entry instances, no shared identity exists).
if (options.store !== undefined && typeof options.store !== 'function') {
const pinned = this.handleScopes.get(options.store)
if (pinned && pinned.scope !== spec.scope) {
throw new Error(
`store handle mounted under "${options.name}" (scope "${spec.scope}") is already mounted under scope "${pinned.scope}" — one handle, one scope`)
}
if (pinned) pinned.count += 1
else this.handleScopes.set(options.store, { scope: spec.scope, count: 1 })
}
const entry: StoredEntry = {
component,
options: {
...(options.key !== undefined ? { key: options.key } : {}),
...(options.id !== undefined ? { id: options.id } : {}),
...(options.order !== undefined ? { order: options.order } : {}),
...(options.label !== undefined ? { label: options.label } : {}),
},
...(options.inject !== undefined ? { inject: options.inject } : {}),
...(options.children !== undefined ? { children: options.children } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(options.registrant !== undefined ? { registrant: options.registrant } : {}),
}
const next = [...rec.entries, entry]
// Stable sort: order ascending, ties keep registration sequence.
if (spec.kind === 'list') next.sort((a, b) => (a.options.order ?? 0) - (b.options.order ?? 0))
rec.entries = next
this.markDirty(options.name, rec)
if (options.children) {
for (const [childKey, childSpec] of Object.entries(options.children)) {
const childRec = this.record(childKey)
childRec.spec = childSpec
childRec.declaredBy = `an entry in "${options.name}"${options.registrant ? ` (${options.registrant})` : ''}`
this.markDirty(childKey, childRec)
}
}
return () => {
if (rec.spec !== recorded) return
rec.spec = undefined
rec.entries = NO_ENTRIES
this.markDirty(key, rec)
if (!rec.entries.includes(entry)) return
rec.entries = rec.entries.filter(e => e !== entry)
this.markDirty(options.name, rec)
this.releaseEntry(entry)
}
}
/**
* Contribute a component to a defined slot. single: duplicate registration
* throws; keyed: missing or duplicate `options.key` throws; list: missing or
* duplicate `options.id` throws (duplicates would make `only`/`entryKey`
* dispatch ambiguous).
* @param key - SlotMap key.
* @param component - component honoring the entry's composed props contract (owner & standard & injected shares).
* @param args - kind-shaped registration options; statically mandatory for keyed/list (key/id live there).
* @returns disposer removing the registration (idempotent; a stale disposer
* after the slot's define disposer ran is a no-op).
* Whether a previously obtained entry is still registered (the render
* machinery's stale-authorization probe: a retained renderSlot binding
* whose entry left the ledger must not render).
* @param entry - a previously read entry.
* @returns false once the entry's registration was disposed.
*/
register<K extends keyof SlotMap & string, I extends object = Record<string, unknown>, Ctx = unknown>(
// NoInfer pins I's inference to the inject factory: letting the component
// position drive I would absorb any props drift into the constraint.
// Ctx flows from the options' inject-factory parameter annotation
// (narrowing wrappers fix it; the core stays context-agnostic).
key: K, component: SlotComponent<ComposedProps<K, NoInfer<I>>>, ...args: RegisterArgs<SlotMap[K], I, Ctx>): () => void {
const rec = this.records.get(key)
if (!rec?.spec) throw new Error(`slot "${String(key)}" is not defined`)
const opts = (args[0] ?? {}) as StoredEntry['options']
// keyed/list options are statically mandatory (RegisterArgs); the runtime
// checks below stay for dynamically-composed callers.
switch (rec.spec.kind) {
case 'single':
if (rec.entries.length > 0) throw new Error(`single slot "${String(key)}" already has a registration`)
break
case 'keyed':
if (opts.key === undefined) throw new Error(`keyed slot "${String(key)}" requires options.key`)
if (rec.entries.some(e => e.options.key === opts.key)) {
throw new Error(`keyed slot "${String(key)}" already has an entry for key "${opts.key}"`)
}
break
case 'list':
if (opts.id === undefined) throw new Error(`list slot "${String(key)}" requires options.id`)
if (rec.entries.some(e => e.options.id === opts.id)) {
throw new Error(`list slot "${String(key)}" already has an entry with id "${opts.id}"`)
}
break
}
const entry: StoredEntry = { component, options: opts }
const next = [...rec.entries, entry]
// Stable sort: order ascending, ties keep registration sequence.
if (rec.spec.kind === 'list') next.sort((a, b) => (a.options.order ?? 0) - (b.options.order ?? 0))
rec.entries = next
this.markDirty(key, rec)
return () => {
if (!rec.entries.includes(entry)) return
rec.entries = rec.entries.filter(e => e !== entry)
this.markDirty(key, rec)
isLive(entry: StoredEntry): boolean {
for (const rec of this.records.values()) {
if (rec.entries.includes(entry)) return true
}
return false
}
/**
* Snapshot the registered entries for a key. Returns the cached array
* reference (stable between mutations — safe as a uSES getSnapshot source);
* empty for keys not (or no longer) defined, so renderers may probe ahead of
* plugin load order.
* @param key - SlotMap key.
* empty for keys not (or no longer) declared, so renderers may probe ahead
* of plugin load order.
* @param key - slot key (dynamic: the render machinery holds keys as strings).
* @returns entries in registration (list: order) sequence.
*/
entries<K extends keyof SlotMap & string>(key: K): readonly SlotEntry<SlotMap[K]>[] {
return (this.records.get(key)?.entries ?? NO_ENTRIES) as unknown as readonly SlotEntry<SlotMap[K]>[]
entries(key: string): readonly StoredEntry[] {
return this.records.get(key)?.entries ?? NO_ENTRIES
}
/**
* Look up a slot's defined spec, narrowed by the SlotMap key.
* Look up a slot's declared spec, narrowed by the SlotMap key.
* @param key - SlotMap key.
* @returns the spec, or undefined before define.
* @returns the spec, or undefined while undeclared.
*/
spec<K extends keyof SlotMap & string>(key: K): SlotSpec<SlotMap[K]> | undefined {
return this.records.get(key)?.spec as SlotSpec<SlotMap[K]> | undefined
@@ -289,7 +474,7 @@ export class SlotCore {
* only hold as strings (generic dispatch) use this wide form; statically
* keyed callers use {@link SlotCore.spec}.
* @param key - candidate slot key.
* @returns the wide-typed spec, or undefined before define.
* @returns the wide-typed spec, or undefined while undeclared.
*/
specDynamic(key: string): SlotSpec<SlotEntryDef> | undefined {
return this.records.get(key)?.spec
@@ -297,12 +482,12 @@ export class SlotCore {
/**
* Subscribe to registration changes for a key (microtask-batched).
* Subscribing ahead of define is allowed; the define itself notifies.
* @param key - SlotMap key.
* Subscribing ahead of declaration is allowed; the declaration notifies.
* @param key - slot key.
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(key: keyof SlotMap & string, fn: () => void): () => void {
subscribe(key: string, fn: () => void): () => void {
const rec = this.record(key)
rec.listeners.add(fn)
return () => { rec.listeners.delete(fn) }
@@ -311,10 +496,10 @@ export class SlotCore {
/**
* Monotonic version for a key, bumped synchronously per mutation so a
* uSES getSnapshot read is never stale when its batched notification lands.
* @param key - SlotMap key.
* @param key - slot key.
* @returns current version (0 for untouched keys).
*/
getVersion(key: keyof SlotMap & string): number {
getVersion(key: string): number {
return this.records.get(key)?.version ?? 0
}
@@ -330,10 +515,35 @@ export class SlotCore {
return () => { this.mutateListeners.delete(fn) }
}
/**
* Cascade for a removed entry: release its store mount and collapse every
* child slot it declared — specs clear, contributions empty (their stale
* disposers no-op), recursively down the declaration tree. One lifecycle
* axis: ledger rows, slots, contributions, and store mounts die together.
*/
private releaseEntry(entry: StoredEntry): void {
if (entry.store !== undefined && typeof entry.store !== 'function') {
const pinned = this.handleScopes.get(entry.store)
if (pinned && --pinned.count === 0) this.handleScopes.delete(entry.store)
}
if (!entry.children) return
for (const childKey of Object.keys(entry.children)) {
const childRec = this.records.get(childKey)
/* v8 ignore next -- defensive: declaring always creates the record */
if (!childRec) continue
const doomed = childRec.entries
childRec.spec = undefined
childRec.declaredBy = undefined
childRec.entries = NO_ENTRIES
this.markDirty(childKey, childRec)
for (const dead of doomed) this.releaseEntry(dead)
}
}
private record(key: string): SlotRecord {
let rec = this.records.get(key)
if (!rec) {
rec = { spec: undefined, entries: NO_ENTRIES, version: 0, listeners: new Set() }
rec = { spec: undefined, declaredBy: undefined, entries: NO_ENTRIES, version: 0, listeners: new Set() }
this.records.set(key, rec)
}
return rec
@@ -359,49 +569,3 @@ export class SlotCore {
}
}
}
/**
* Whitelist-narrowed render surface handed to owner components via props
* (implementation lives in web-react's scopedSlots factory).
*/
export interface ScopedSlots<K extends keyof SlotMap & string> {
/**
* Render a slot's registered entries.
* @param key - whitelisted SlotMap key.
* @param props - owner-supplied share of the entry's props contract.
* @param opts - render options.
* @returns rendered node(s).
*/
renderSlot: <Key extends K>(key: Key, props: OwnerOf<Key>, opts?: RenderOpts) => ReactNode
/**
* Phantom variance anchor (never materialized at runtime): generic method
* signatures compare loosely across differing key-union constraints, so
* this contravariant marker is what actually enforces "a surface is
* assignable only where its whitelist covers the target's keys".
*/
readonly __accepts?: ((key: K) => void) | undefined
}
/** renderSlot options: keyed dispatch key, list filtering, empty fallback. */
export interface RenderOpts { entryKey?: string; only?: string; fallback?: ReactNode }
/**
* Narrow a slots surface to a subset whitelist for delegation to a child
* component (`K2` ⊆ `K1`). Pure type narrowing — ScopedSlots is covariant in
* its key union, so the same object is returned.
* @param slots - the owner's wider surface.
* @returns the same surface, typed to the subset.
*/
export function narrowSlots<K2 extends K1, K1 extends keyof SlotMap & string>(
slots: ScopedSlots<K1>): ScopedSlots<K2> {
return slots
}
/**
* The owner-supplied share of an entry's props. `useSession` is excluded (the
* framework injects it on session slots; owners must not shadow the bound
* hook). Registrant inject keys are per-registration and unknowable at the
* type level, so the remaining share stays Partial rather than exact.
*/
export type OwnerProps<E extends SlotEntryDef> =
E extends { props: infer P extends object } ? Partial<Omit<P, 'useSession'>> : object

View File

@@ -0,0 +1,130 @@
/**
* Renderer install seam (slot terminal design §8): the SlotRenderer interface
* web-react's machinery implements, the host surface the runtime SlotsService
* presents to the installed renderer, and the render-path authorization
* errors. Pure types plus two error classes — this package stays React-free
* at runtime (React types only).
*/
import type { ReactNode } from 'react'
import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts'
/** Minimal observable surface for host-provided standard-kit data sources. */
export interface HostObservable<T> {
getSnapshot(): T
subscribe(fn: () => void): () => void
}
/**
* Type-erased store instance face at the render seam (the typed twin is
* {@link StoreInstance}): a bare snapshot source plus the draft-stripped
* action callbacks. No React hook crosses this seam — the render machinery
* binds `useStore` from the source at its own side (cached per instance);
* typing lands at the component seam via {@link PropsStore}.
*/
export interface StoreInstanceLike {
/** Current state snapshot (uSES getSnapshot side). */
getSnapshot(): unknown
/**
* Subscribe to state changes (uSES subscribe side).
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void
/** Baked write callbacks (delivered to components as `actions`). */
readonly actions: Record<string, (...params: never[]) => void>
}
/** Session standard kit resolved per session id (identity-stable per session scope; a recreated scope yields a new cell). */
export interface SessionCell {
sessionId: string
/**
* Bare conversation-snapshot source (wide here; runtime narrows at its
* export seam). The React side binds the `useSession` hook per cell —
* hooks never appear on the host contract.
*/
session: HostObservable<unknown>
}
/** renderSlot dispatch options at the machinery level: keyed dispatch key, list filtering, empty fallback. */
export interface RenderOpts {
entryKey?: string
only?: string
fallback?: ReactNode
}
/** Host surface the runtime SlotsService presents to the installed renderer. */
export interface SlotRendererHost {
/**
* Subscribe to a key's registration changes (microtask-batched).
* @param key - slot key.
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(key: string, fn: () => void): () => void
/**
* Monotonic version for uSES pairing.
* @param key - slot key.
* @returns current version.
*/
getVersion(key: string): number
/**
* Snapshot the registered entries for a key (stable reference between mutations).
* @param key - slot key.
* @returns entries in registration (list: order) sequence.
*/
entriesOf(key: string): readonly StoredEntry[]
/**
* Declared runtime spec from the declarations ledger.
* @param key - slot key.
* @returns the spec, or undefined while the key is undeclared (outlets render empty).
*/
specOf(key: string): SlotSpec<SlotEntryDef> | undefined
/**
* Stale-authorization check: whether the entry is still in the ledger.
* @param entry - a previously rendered entry.
* @returns false once the entry's registration was disposed.
*/
isLive(entry: StoredEntry): boolean
/**
* Resolve (create or return cached) the store instance for an entry's
* declared handle under a scope key; lifecycle rides the ledger axis.
* @param entry - entry whose declaration carries the handle.
* @param scopeKey - session id for session-scope slots, undefined for root scope.
* @returns the instance, or undefined when the entry declares no store.
*/
storeOf(entry: StoredEntry, scopeKey: string | undefined): StoreInstanceLike | undefined
/** Session-side standard-kit sources. */
sessions: {
/** Session list source backing the useSessions standard hook. */
list: HostObservable<unknown>
/** Current-session source backing SessionProvider's self-wiring (design fiat ①). */
current: HostObservable<string | undefined>
/**
* Resolve the session standard kit.
* @param id - session id.
* @returns the cell, or undefined for an unknown session (provider falls to empty).
*/
cell(id: string): SessionCell | undefined
}
}
/** The install seam: runtime owns install()/renderSlot(); web-react implements rendering. */
export interface SlotRenderer {
/**
* Render the root slot tree over the host surface (the only ctx-level entry).
* @param host - the installing service's host surface.
* @param ownerProps - owner props from the shell's renderSlot('root', ...) call.
* @returns the rendered tree.
*/
renderRoot(host: SlotRendererHost, ownerProps: object): ReactNode
}
/** Thrown when a retained renderSlot binding is invoked after its declaring entry was disposed. */
export class StaleAuthorizationError extends Error {}
/**
* Thrown when a renderSlot binding is invoked for a key outside its entry's
* children declaration (plain-JS backstop; typed callers are narrowed
* statically).
*/
export class SlotOwnershipError extends Error {}

View File

@@ -0,0 +1,137 @@
/**
* Store-seat type family (slot terminal design §4): a registrant declares its
* shared/exclusive business store as data — schema (`init`), optional
* persistence key, and the complete write set (`actions`) — and the framework
* owns instance lifecycle (scope derives from the mounting entry's slot).
* ui-slots ships the contract types only; the engine-backed `defineStore`
* value lives in web-react (the snapshot-store engine's home) and must
* satisfy {@link DefineStore}.
*/
/**
* Typed selector hook over a snapshot source. Canonical shape for the whole
* slot system (web-react's engine hook is structurally identical; the
* framework is the only party that ever constructs one).
*/
export type SnapshotSelectorHook<T> = <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S
/**
* Action declaration table: pure immer-draft transforms over the store state,
* declared as the store's complete write set (the audit face — components can
* only write through these).
*/
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
* any[] (not unknown[]): each action carries its own parameter list, and
* unknown[] would reject every concrete signature under strict parameter
* contravariance. Params are re-inferred per action by BakedActions. */
export type ActionsDecl<T> = Record<string, (draft: T, ...params: any[]) => void>
/**
* Draft-stripped callback form of an actions table: what components
* (`props.actions`) and inject factories receive — the framework bakes the
* draft parameter away by binding each action to the resolved instance.
*/
export type BakedActions<T, A extends ActionsDecl<T>> = {
[K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never
}
/**
* Store declaration spec: initial-state factory (a lambda so every instance
* gets a fresh state), optional persistence key (mechanical, framework-run),
* and the actions write set.
*/
export interface StoreSpec<T, A extends ActionsDecl<T>> {
/** Initial-state factory; called once per framework-created instance. */
init: () => T
/** Opt-in persistence key (storage mechanics belong to the engine). */
persist?: string
/** Complete write set: pure draft transforms. */
actions: A
}
/**
* Live engine instance: the create() product consumed by the render machinery
* and by tests. A bare snapshot source plus the baked write set — no React
* hook rides the engine product (the engine lives in the React-free runtime);
* the render machinery binds the `useStore` hook from this source on its own
* side, cached per instance. Production components and render paths never
* call create() themselves — instance lifecycle is the framework's.
*/
export interface StoreInstance<T, A extends ActionsDecl<T>> {
/** Baked write callbacks (delivered to components as `actions`). */
readonly actions: BakedActions<T, A>
/** Current state snapshot (uSES getSnapshot side; test assertions). */
getSnapshot(): T
/**
* Subscribe to state changes (uSES subscribe side).
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void
/**
* Drop this instance's persisted value (no-op for non-persist specs). The
* framework calls it when the owning scope dies for good — a pruned session
* must not leave orphaned storage keys behind.
*/
clearPersisted(): void
}
/**
* Store handle: spec + state/actions types + shared identity + instance
* factory in one value. Handles are constructed in apply world (shared across
* registrations of one plugin) or by the framework from a registrant's
* factory (exclusive). Never export a handle at module level — module-cache
* identity is a disguised singleton across plugin reloads.
*/
export interface StoreHandle<T, A extends ActionsDecl<T>> {
/** The inert declaration this handle was defined from. */
readonly spec: StoreSpec<T, A>
/**
* Create a live engine instance (framework machinery and tests only).
* @param scopeKey - session id for session-scope instances; suffixes the
* persist key so per-session instances persist independently (root-scope
* instances omit it).
* @returns a fresh instance seeded from `spec.init()`.
*/
create(scopeKey?: string): StoreInstance<T, A>
}
/**
* Exclusive-store registration form: the registrant passes the factory itself
* and the framework calls it per entry x scope (no shared identity exists).
*/
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
* erased position accepting every StoreHandle instantiation; T/A are
* recovered per use site by conditional inference (HandleOf/BoundActions/
* PropsStore). */
export type StoreFactory = () => StoreHandle<any, any>
/** The register `store` option position: a shared handle or an exclusive factory. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- same erased-constraint position as StoreFactory (see above).
export type StoreDecl = StoreHandle<any, any> | StoreFactory
/** Normalize a store declaration to its handle type (factories yield their return). */
export type HandleOf<H> = H extends () => infer R ? R : H
/**
* Handle-keyed baked actions: the `actions` parameter of an inject factory
* whose registration declared a store — the same baked callback set the
* component receives via {@link PropsStore}.
*/
export type BoundActions<H> = H extends StoreHandle<infer T, infer A> ? BakedActions<T, A> : never
/**
* The store props share, derived from the declared handle: a typed selector
* hook plus the baked write set. Components never see the instance itself
* (no update/set — reads via useStore, writes via the declared actions only).
*/
export type PropsStore<H> = H extends StoreHandle<infer T, infer A>
? { useStore: SnapshotSelectorHook<T>; actions: BakedActions<T, A> }
: object
/**
* The defineStore contract (implementation lives in the runtime package,
* bound to the snapshot-store engine): spec in, handle out, with T inferred
* from `init` and the actions table constrained by T.
*/
export type DefineStore = <T, A extends ActionsDecl<T>>(spec: StoreSpec<T, A>) => StoreHandle<T, A>

View File

@@ -1,165 +1,290 @@
// SlotCore terminal-design behavior: the single register composition API —
// a-priori 'root', children declaration/authorization, load-time validation,
// one-axis lifecycle cascade, store scope pinning, subscription surface.
import { describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import type { RootBinding, SessionBinding, SlotOptions } from '@deepseek-ai/dsh-client-ui-slots'
import type { SlotComponent, StoreHandle } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
// 'root' is NOT merged here: the runtime package owns the built-in row, and
// the client aggregate program would see both merges collide.
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'test.single': { kind: 'single'; scope: 'root'; props: { label: string } }
'test.list': { kind: 'list'; scope: 'root'; props: { label: string } }
'test.keyed': { kind: 'keyed'; scope: 'session'; props: { label: string; useSession: unknown } }
'test.single': { kind: 'single'; scope: 'root' }
'test.session': { kind: 'single'; scope: 'session' }
'test.list': { kind: 'list'; scope: 'root' }
'test.keyed': { kind: 'keyed'; scope: 'session' }
'test.grandchild': { kind: 'single'; scope: 'root' }
}
}
const Comp: FC<{ label: string }> = () => null
const SessionComp: FC<{ label: string; useSession: unknown }> = () => null
// Wide-accepting fixture: assignable wherever the composed constraint is an
// object type (children-declaring fixtures erase via `as never` instead —
// RendersCheck would demand a renderSlot consumer).
const Comp: SlotComponent<object> = () => null
/** A minimal structurally-valid store handle (identity is what the ledger tracks). */
function fakeHandle(): StoreHandle<{ n: number }, Record<string, (d: { n: number }) => void>> {
return {
spec: { init: () => ({ n: 0 }), actions: {} },
create: () => { throw new Error('not under test') },
}
}
/** Register a root-frame entry declaring the four test child slots. */
function mountFrame(core: SlotCore) {
return core.register({
name: 'root',
children: {
'test.single': { kind: 'single', scope: 'root' },
'test.session': { kind: 'single', scope: 'session' },
'test.list': { kind: 'list', scope: 'root' },
'test.keyed': { kind: 'keyed', scope: 'session' },
},
// Type-level renderSlot presence is proven by the type-chain spec; erasing
// here keeps runtime fixtures terse.
}, Comp as never)
}
const flushMicrotasks = () => new Promise<void>((resolve) => { queueMicrotask(resolve) })
describe('SlotCore kind semantics', () => {
it('throws on register before define', () => {
describe('a-priori root and declaration gate', () => {
it('seeds root as single/root at construction', () => {
const core = new SlotCore()
expect(() => core.register('test.single', Comp)).toThrow('not defined')
expect(core.specDynamic('root')).toEqual({ kind: 'single', scope: 'root' })
})
it('throws on duplicate define', () => {
it('throws on registering into an undeclared slot', () => {
const core = new SlotCore()
core.define('test.single', { kind: 'single', scope: 'root' })
expect(() => core.define('test.single', { kind: 'single', scope: 'root' })).toThrow('already defined')
expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('not declared')
})
it('single: second registration throws, disposer frees the seat', () => {
it('root is single: a second frame registration throws', () => {
const core = new SlotCore()
core.define('test.single', { kind: 'single', scope: 'root' })
const dispose = core.register('test.single', Comp)
expect(() => core.register('test.single', Comp)).toThrow('already has a registration')
dispose()
mountFrame(core)
expect(() => core.register({ name: 'root' }, Comp)).toThrow('already has a registration')
})
it('children declaration makes child slots registerable, with specs recorded', () => {
const core = new SlotCore()
mountFrame(core)
expect(core.specDynamic('test.session')).toEqual({ kind: 'single', scope: 'session' })
expect(() => core.register({ name: 'test.single' }, Comp)).not.toThrow()
})
it('duplicate child declaration throws naming the first declarer', () => {
const core = new SlotCore()
mountFrame(core)
core.register({ name: 'test.single', children: { 'test.grandchild': { kind: 'single', scope: 'root' } } }, Comp as never)
expect(() => core.register(
{ name: 'test.session', children: { 'test.grandchild': { kind: 'single', scope: 'root' } }, registrant: 'imposter' },
Comp as never,
)).toThrow(/already declared.*test\.single/)
})
})
describe('lifecycle cascade (one axis)', () => {
it('disposing a declaring entry collapses child slots and their contributions recursively', () => {
const core = new SlotCore()
const disposeFrame = mountFrame(core)
const disposeChild = core.register(
{ name: 'test.single', children: { 'test.grandchild': { kind: 'single', scope: 'root' } } }, Comp as never)
core.register({ name: 'test.grandchild' }, Comp)
expect(core.entries('test.grandchild')).toHaveLength(1)
disposeFrame()
expect(core.specDynamic('test.single')).toBeUndefined()
expect(core.specDynamic('test.grandchild')).toBeUndefined()
expect(core.entries('test.single')).toHaveLength(0)
expect(() => core.register('test.single', Comp)).not.toThrow()
expect(core.entries('test.grandchild')).toHaveLength(0)
// Stale disposer of a cascaded-away entry is a no-op.
expect(() => { disposeChild() }).not.toThrow()
// Slots return to undeclared: contributing again throws until redeclared.
expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('not declared')
})
it('registration disposers are idempotent', () => {
const core = new SlotCore()
const dispose = mountFrame(core)
dispose()
dispose()
expect(core.entries('root')).toHaveLength(0)
// Redeclare works after collapse.
mountFrame(core)
expect(core.specDynamic('test.single')).toBeDefined()
})
it('isLive tracks ledger membership across dispose', () => {
const core = new SlotCore()
mountFrame(core)
const dispose = core.register({ name: 'test.single' }, Comp)
const entry = core.entries('test.single')[0]!
expect(core.isLive(entry)).toBe(true)
dispose()
expect(core.isLive(entry)).toBe(false)
})
})
describe('kind semantics', () => {
it('keyed: duplicate key throws, missing key throws', () => {
const core = new SlotCore()
core.define('test.keyed', { kind: 'keyed', scope: 'session' })
core.register('test.keyed', SessionComp, { key: 'a' })
expect(() => core.register('test.keyed', SessionComp, { key: 'a' })).toThrow('key "a"')
// Statically rejected since RegisterArgs made keyed options mandatory;
// the runtime guard stays for dynamically-composed callers.
// @ts-expect-error keyed registration requires options
expect(() => core.register('test.keyed', SessionComp)).toThrow('requires options.key')
expect(() => core.register('test.keyed', SessionComp, { key: 'b' })).not.toThrow()
mountFrame(core)
core.register({ name: 'test.keyed', key: 'a' }, Comp)
expect(() => core.register({ name: 'test.keyed', key: 'a' }, Comp)).toThrow('key "a"')
// Statically rejected (KindOptions); runtime guard stays for dynamic callers.
// @ts-expect-error keyed registration requires options.key
expect(() => core.register({ name: 'test.keyed' }, Comp)).toThrow('requires options.key')
expect(() => core.register({ name: 'test.keyed', key: 'b' }, Comp)).not.toThrow()
})
it('list: duplicate id throws, missing id throws, entries sort by order stably', () => {
const core = new SlotCore()
core.define('test.list', { kind: 'list', scope: 'root' })
core.register('test.list', Comp, { id: 'c', order: 10 })
core.register('test.list', Comp, { id: 'a' })
core.register('test.list', Comp, { id: 'b' })
expect(() => core.register('test.list', Comp, { id: 'a' })).toThrow('id "a"')
// @ts-expect-error list registration requires options (static since RegisterArgs)
expect(() => core.register('test.list', Comp)).toThrow('requires options.id')
const ids = core.entries('test.list').map(e => (e.options as { id: string }).id)
expect(ids).toEqual(['a', 'b', 'c'])
mountFrame(core)
core.register({ name: 'test.list', id: 'c', order: 10 }, Comp)
core.register({ name: 'test.list', id: 'a' }, Comp)
core.register({ name: 'test.list', id: 'b' }, Comp)
expect(() => core.register({ name: 'test.list', id: 'a' }, Comp)).toThrow('id "a"')
// @ts-expect-error list registration requires options.id
expect(() => core.register({ name: 'test.list' }, Comp)).toThrow('requires options.id')
expect(core.entries('test.list').map(e => e.options.id)).toEqual(['a', 'b', 'c'])
})
it('spec() exposes the definition; define disposer clears spec and entries', () => {
it('single: second registration throws, disposer frees the seat', () => {
const core = new SlotCore()
const dispose = core.define('test.single', { kind: 'single', scope: 'root' })
core.register('test.single', Comp)
expect(core.spec('test.single')).toEqual({ kind: 'single', scope: 'root' })
mountFrame(core)
const dispose = core.register({ name: 'test.single' }, Comp)
expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('already has a registration')
dispose()
expect(core.spec('test.single')).toBeUndefined()
expect(core.entries('test.single')).toHaveLength(0)
expect(() => core.register('test.single', Comp)).toThrow('not defined')
})
it('disposers are idempotent and stale disposers after redefine are no-ops', () => {
const core = new SlotCore()
const disposeDef = core.define('test.single', { kind: 'single', scope: 'root' })
const disposeReg = core.register('test.single', Comp)
disposeReg()
disposeReg()
disposeDef()
disposeDef()
core.define('test.single', { kind: 'single', scope: 'root' })
core.register('test.single', Comp)
disposeDef()
disposeReg()
expect(core.spec('test.single')).toBeDefined()
expect(core.entries('test.single')).toHaveLength(1)
expect(() => core.register({ name: 'test.single' }, Comp)).not.toThrow()
})
})
describe('SlotCore subscription surface', () => {
describe('store scope pinning', () => {
it('one shared handle under two scopes throws at load', () => {
const core = new SlotCore()
mountFrame(core)
const handle = fakeHandle()
core.register({ name: 'test.session', store: handle }, Comp as never)
expect(() => core.register({ name: 'test.single', store: handle }, Comp as never))
.toThrow('one handle, one scope')
})
it('same handle under same scope is fine; full unmount releases the pin', () => {
const core = new SlotCore()
mountFrame(core)
const handle = fakeHandle()
const d1 = core.register({ name: 'test.list', id: 'x', store: handle }, Comp as never)
const d2 = core.register({ name: 'test.list', id: 'y', store: handle }, Comp as never)
d1()
// Still mounted once — scope stays pinned.
expect(() => core.register({ name: 'test.session', store: handle }, Comp as never))
.toThrow('one handle, one scope')
d2()
// All mounts gone: the handle may pin a new scope.
expect(() => core.register({ name: 'test.session', store: handle }, Comp as never)).not.toThrow()
})
it('factories are exempt from pinning (no shared identity)', () => {
const core = new SlotCore()
mountFrame(core)
const factory = () => fakeHandle()
core.register({ name: 'test.session', store: factory }, Comp as never)
expect(() => core.register({ name: 'test.single', store: factory }, Comp as never)).not.toThrow()
})
it('cascade releases store pins of collapsed child entries', () => {
const core = new SlotCore()
const disposeFrame = mountFrame(core)
const handle = fakeHandle()
core.register({ name: 'test.session', store: handle }, Comp as never)
disposeFrame()
mountFrame(core)
expect(() => core.register({ name: 'test.single', store: handle }, Comp as never)).not.toThrow()
})
})
describe('subscription surface', () => {
it('entries() returns a stable cached reference between mutations', () => {
const core = new SlotCore()
core.define('test.list', { kind: 'list', scope: 'root' })
core.register('test.list', Comp, { id: 'a' })
mountFrame(core)
core.register({ name: 'test.list', id: 'a' }, Comp)
const first = core.entries('test.list')
expect(core.entries('test.list')).toBe(first)
core.register('test.list', Comp, { id: 'b' })
core.register({ name: 'test.list', id: 'b' }, Comp)
expect(core.entries('test.list')).not.toBe(first)
})
it('bumps version synchronously but batches notifications per microtask', async () => {
const core = new SlotCore()
mountFrame(core)
const fn = vi.fn()
core.subscribe('test.list', fn)
core.define('test.list', { kind: 'list', scope: 'root' })
core.register('test.list', Comp, { id: 'a' })
core.register('test.list', Comp, { id: 'b' })
expect(core.getVersion('test.list')).toBe(3)
const before = core.getVersion('test.list')
core.register({ name: 'test.list', id: 'a' }, Comp)
core.register({ name: 'test.list', id: 'b' }, Comp)
expect(core.getVersion('test.list')).toBe(before + 2)
expect(fn).not.toHaveBeenCalled()
await flushMicrotasks()
expect(fn).toHaveBeenCalledTimes(1)
core.register('test.list', Comp, { id: 'c' })
core.register({ name: 'test.list', id: 'c' }, Comp)
await flushMicrotasks()
expect(fn).toHaveBeenCalledTimes(2)
})
it('declaration itself notifies child-key subscribers (subscribe-ahead allowed)', async () => {
const core = new SlotCore()
const fn = vi.fn()
core.subscribe('test.single', fn)
mountFrame(core)
await flushMicrotasks()
expect(fn).toHaveBeenCalledTimes(1)
})
it('notifies only subscribers of the touched key; unsubscribe stops delivery', async () => {
const core = new SlotCore()
mountFrame(core)
await flushMicrotasks()
const single = vi.fn()
const list = vi.fn()
core.subscribe('test.single', single)
const unsubscribe = core.subscribe('test.list', list)
core.define('test.single', { kind: 'single', scope: 'root' })
core.register({ name: 'test.single' }, Comp)
await flushMicrotasks()
expect(single).toHaveBeenCalledTimes(1)
expect(list).not.toHaveBeenCalled()
unsubscribe()
core.define('test.list', { kind: 'list', scope: 'root' })
core.register({ name: 'test.list', id: 'a' }, Comp)
await flushMicrotasks()
expect(list).not.toHaveBeenCalled()
})
it('a mutation from inside a flush re-schedules instead of being lost', async () => {
const core = new SlotCore()
core.define('test.list', { kind: 'list', scope: 'root' })
mountFrame(core)
await flushMicrotasks()
const seen: number[] = []
let reentered = false
core.subscribe('test.list', () => {
seen.push(core.getVersion('test.list'))
if (!reentered) {
reentered = true
core.register('test.list', Comp, { id: 'reentrant' })
core.register({ name: 'test.list', id: 'reentrant' }, Comp)
}
})
core.register('test.list', Comp, { id: 'a' })
core.register({ name: 'test.list', id: 'a' }, Comp)
await flushMicrotasks()
await flushMicrotasks()
expect(seen).toHaveLength(2)
expect(core.entries('test.list')).toHaveLength(2)
})
it('getVersion is 0 for untouched keys and monotonic across redefine', () => {
it('getVersion is 0 for untouched keys and monotonic across redeclaration', () => {
const core = new SlotCore()
expect(core.getVersion('test.single')).toBe(0)
const dispose = core.define('test.single', { kind: 'single', scope: 'root' })
const dispose = mountFrame(core)
dispose()
const after = core.getVersion('test.single')
core.define('test.single', { kind: 'single', scope: 'root' })
mountFrame(core)
expect(core.getVersion('test.single')).toBeGreaterThan(after)
})
@@ -167,43 +292,14 @@ describe('SlotCore subscription surface', () => {
const core = new SlotCore()
const keys: string[] = []
const off = core.onMutate(key => keys.push(key))
core.define('test.single', { kind: 'single', scope: 'root' })
core.define('test.list', { kind: 'list', scope: 'root' })
core.register('test.list', Comp, { id: 'a' })
expect(keys).toEqual(['test.single', 'test.list', 'test.list'])
mountFrame(core)
// Contribution first, then each declared child key.
expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed'])
keys.length = 0
core.register({ name: 'test.list', id: 'a' }, Comp)
expect(keys).toEqual(['test.list'])
off()
core.register('test.list', Comp, { id: 'b' })
expect(keys).toHaveLength(3)
})
})
describe('SlotOptions typing', () => {
it('rejects kind-mismatched options and scope-mismatched inject bindings', () => {
// Compile-time negatives only: the body never runs (some rejected shapes
// would be legal at runtime, which validates kinds, not props).
const typeNegatives = (core: SlotCore) => {
// @ts-expect-error single options take no key
core.register('test.single', Comp, { key: 'x' })
// @ts-expect-error list options require id
core.register('test.list', Comp, { order: 1 })
// @ts-expect-error keyed options require key
core.register('test.keyed', SessionComp, { inject: () => ({}) })
// @ts-expect-error component props must match the SlotMap contract
core.register('test.single', SessionComp)
// @ts-expect-error kind must match the SlotMap declaration
core.define('test.single', { kind: 'list', scope: 'root' })
const rootInject: SlotOptions<{ kind: 'single'; scope: 'root'; props: { label: string } }> = {
// @ts-expect-error root slots bind RootBinding, which has no sessionId
inject: (b: RootBinding) => ({ sessionId: b.sessionId }),
}
return rootInject
}
expect(typeNegatives).toBeTypeOf('function')
const sessionInject: SlotOptions<{ kind: 'keyed'; scope: 'session'; props: { label: string } }> = {
key: 'k',
inject: (b: SessionBinding) => ({ sessionId: b.sessionId }),
}
expect(sessionInject.key).toBe('k')
core.register({ name: 'test.list', id: 'b' }, Comp)
expect(keys).toHaveLength(1)
})
})

View File

@@ -1,24 +1,31 @@
// Dynamic-key escape hatches and untouched-key behavior of the terminal core.
import { describe, expect, it } from 'vitest'
import type { FC } from 'react'
import type { ScopedSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { narrowSlots, SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import type { SlotComponent } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'surface.a': { kind: 'single'; scope: 'root'; props: { label: string } }
'surface.b': { kind: 'single'; scope: 'root'; props: { label: string } }
'surface.a': { kind: 'single'; scope: 'root' }
'surface.b': { kind: 'single'; scope: 'root' }
}
}
const Comp: FC<{ label: string }> = () => null
const Comp: SlotComponent<object> = () => null
describe('dynamic-key escape hatch', () => {
it('specDynamic reads wide-typed specs for string keys; undefined before define', () => {
it('specDynamic reads wide-typed specs for string keys; undefined while undeclared', () => {
const core = new SlotCore()
expect(core.specDynamic('surface.a')).toBeUndefined()
core.define('surface.a', { kind: 'single', scope: 'root' })
core.register({ name: 'root', children: { 'surface.a': { kind: 'single', scope: 'root' } } }, Comp as never)
expect(core.specDynamic('surface.a')).toEqual({ kind: 'single', scope: 'root' })
expect(core.specDynamic('never.defined')).toBeUndefined()
expect(core.specDynamic('never.declared')).toBeUndefined()
})
it('spec() narrows by SlotMap key', () => {
const core = new SlotCore()
core.register({ name: 'root', children: { 'surface.a': { kind: 'single', scope: 'root' } } }, Comp as never)
expect(core.spec('surface.a')).toEqual({ kind: 'single', scope: 'root' })
expect(core.spec('surface.b')).toBeUndefined()
})
it('entries/getVersion on an untouched key return the frozen empty array and 0', () => {
@@ -27,26 +34,9 @@ describe('dynamic-key escape hatch', () => {
expect(core.entries('surface.b')).toBe(core.entries('surface.b'))
expect(core.getVersion('surface.b')).toBe(0)
})
})
describe('narrowSlots', () => {
it('returns the same surface narrowed to the subset whitelist', () => {
const wide: ScopedSlots<'surface.a' | 'surface.b'> = { renderSlot: () => null }
const narrow = narrowSlots<'surface.a', 'surface.a' | 'surface.b'>(wide)
expect(narrow).toBe(wide)
const rejects = (s: ScopedSlots<'surface.a'>) => {
// @ts-expect-error 'surface.b' is outside the narrowed whitelist
return () => s.renderSlot('surface.b', {})
}
expect(rejects(narrow)).toBeTypeOf('function')
})
})
describe('registration typing', () => {
it('keyed/list registrations statically require options (runtime guard retained)', () => {
it('isLive is false for entries the core never held', () => {
const core = new SlotCore()
core.define('surface.a', { kind: 'single', scope: 'root' })
// single: options omissible.
expect(() => core.register('surface.a', Comp)).not.toThrow()
expect(core.isLive({ component: Comp, options: {} })).toBe(false)
})
})

View File

@@ -1,140 +1,178 @@
// Slot type-chain negative samples (design.md §9 item 2) plus the slots-ring
// full-chain positive: register→inject→render composed under the ownership
// rule (owner share referenced, injected share locally declared).
// Terminal-design compile-time samples (design.md §11 item 2): the four-share
// composed register constraint — children spec x SlotMap alignment, renderSlot
// key-set containment, store share matching, inject face completeness — plus
// the full positive chain. Bodies with @ts-expect-error sites never run.
import { describe, expect, it } from 'vitest'
import type { FC, ReactNode } from 'react'
import type { ReactNode } from 'react'
import type {
OwnerOf, RootBinding, ScopedSlots, SessionBinding, SlotMap, SlotOptions,
BoundActions, DefineStore, PropsRenderSlots, PropsRuntime, PropsStore, SlotComponent,
} from '@deepseek-ai/dsh-client-ui-slots'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
/** Owner share as the slot-owning package's contract would export it. */
interface ChainOwnerShare { sessionId: string }
/** Registrant's own injected share (locally declared — ownership rule). */
interface ChainInjected { useThing: () => number; actions: { open: () => void } }
// Only package-unique SlotMap keys are merged here. The standard-kit
// interfaces (SessionStandardProps/GlobalStandardProps) are NOT re-merged:
// the runtime package owns the real members, and in the client aggregate
// program a toy merge would collide with them — samples below stay
// shape-agnostic about kit member payloads for the same reason.
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'chain.session': { kind: 'single'; scope: 'session'; props: ChainSessionProps; owner: ChainOwnerShare }
'chain.root': { kind: 'single'; scope: 'root'; props: ChainRootProps; owner: object }
'chain.keyed': { kind: 'keyed'; scope: 'root'; props: ChainRootProps; owner: object }
'chain.frame': { kind: 'single'; scope: 'root' }
'chain.side': { kind: 'single'; scope: 'root'; owner: { collapsed: boolean; width: number } }
'chain.conv': { kind: 'single'; scope: 'session' }
'chain.tools': { kind: 'keyed'; scope: 'session' }
}
}
/** Full props = owner share (referenced) + standard share + own injected. */
type ChainSessionProps = ChainOwnerShare & { useSession: unknown } & ChainInjected
type ChainRootProps = ChainInjected
declare const defineStore: DefineStore
const SessionComp: FC<ChainSessionProps> = () => null
const RootComp: FC<ChainRootProps> = () => null
describe('type-chain negatives (compile-time; bodies never run)', () => {
it('holds the six negative samples as expect-error sites', () => {
const negatives = (core: SlotCore, slots: ScopedSlots<'chain.session'>) => {
// 1. Owner passing a registrant-injected key through renderSlot.
// OwnerOf<'chain.session'> = ChainOwnerShare — useThing is not in it.
slots.renderSlot('chain.session', {
sessionId: 's1',
// @ts-expect-error injected keys are not owner-suppliable
useThing: () => 1,
})
// 2. Inject factory returning a share that mismatches the registrant's
// declared own-injected slice (missing `actions`). The I-typed
// options form is where the mismatch surfaces (the full composed
// register constraint lands with the phase-2 consumer migration).
const mismatched: SlotOptions<SlotMap['chain.session'], ChainInjected> = {
// @ts-expect-error inject must supply the full registrant share
inject: () => ({ useThing: () => 1 }),
}
void mismatched
// 3. renderSlot on a key outside the whitelist.
// @ts-expect-error 'chain.root' is not whitelisted on this surface
slots.renderSlot('chain.root', {})
// 4. keyed registration without options.
// @ts-expect-error keyed kind requires options (RegisterArgs)
core.register('chain.keyed', RootComp)
// 5. Session-slot inject factory typed against RootBinding's surface.
const sessionOpts: SlotOptions<{ kind: 'single'; scope: 'session'; props: ChainSessionProps }, ChainInjected> = {
// @ts-expect-error session binding has sessionId; RootBinding-only factories don't type-check
inject: (b: RootBinding & { notSession: true }) => ({ useThing: () => 1, actions: { open: () => {} } }),
}
void sessionOpts
// 6. Hand-copied owner share drifting from the contract (wrong value type)
// — the composed-reference version right below compiles instead.
interface DriftedProps { sessionId: number }
const Drifted: FC<DriftedProps & ChainInjected> = () => null
// @ts-expect-error drifted hand-copy of the owner share fails at register
core.register('chain.session', Drifted)
return null as ReactNode
}
expect(negatives).toBeTypeOf('function')
/** Factory form (exclusive seat): module-level export, never a handle. */
function createPanelStore() {
return defineStore({
init: () => ({ sidebar: 280, details: 0 }),
persist: 'test.panels',
actions: {
setSidebar: (d, px: number) => { d.sidebar = px },
setDetails: (d, px: number) => { d.details = px },
},
})
it('full chain (positive dual): composed props register, inject, and render cleanly', () => {
const core = new SlotCore()
core.define('chain.session', { kind: 'single', scope: 'session' })
const dispose = core.register('chain.session', SessionComp, {
inject: (b: SessionBinding): ChainInjected => ({
useThing: () => b.sessionId.length,
actions: { open: () => {} },
}),
})
const entry = core.entries('chain.session')[0]!
// Storage erasure boundary: entries() returns the default-I view; the
// registrant share is restored after read-back (the budgeted cast).
const injected = (entry.options.inject as unknown as (b: SessionBinding) => ChainInjected)(
{ sessionId: 's1', session: { useSelector: undefined }, ctx: undefined })
expect(injected.useThing()).toBe(2)
// Owner share stays reference-typed at the render surface.
const ownerShare: OwnerOf<'chain.session'> = { sessionId: 's1' }
expect(ownerShare.sessionId).toBe('s1')
dispose()
expect(core.entries('chain.session')).toHaveLength(0)
})
})
// ── children validation layer (B-b, opt-in per entry) ───────────────────────
/** Delegating owner share: entry declares children, component carries a slots face. */
interface DelegOwnerShare { sessionId: string }
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'chain.deleg': { kind: 'single'; scope: 'root'; props: object; owner: DelegOwnerShare; children: 'chain.child-a' | 'chain.child-b' }
'chain.child-a': { kind: 'single'; scope: 'root'; props: object; owner: object }
'chain.child-b': { kind: 'single'; scope: 'root'; props: object; owner: object }
'chain.outside': { kind: 'single'; scope: 'root'; props: object; owner: object }
}
}
describe('children validation layer (compile-time; bodies never run)', () => {
it('accepts whitelists inside the authorized union, rejects outside keys', () => {
const cases = (core: SlotCore) => {
// Positive: slots face ⊆ children union (a strict subset is fine).
const InUnion: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.child-a'> }> = () => null
core.register('chain.deleg', InUnion)
// Positive: the full authorized union.
const FullUnion: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.child-a' | 'chain.child-b'> }> = () => null
core.register('chain.deleg', FullUnion)
// Positive: no slots face at all — delegation is optional.
const NoSlots: FC<DelegOwnerShare> = () => null
core.register('chain.deleg', NoSlots)
// Negative: a key outside the authorized union collapses the slots constraint.
const Outside: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.outside'> }> = () => null
// @ts-expect-error slots whitelist must stay inside the entry's children union
core.register('chain.deleg', Outside)
// Negative: smuggling an extra key alongside authorized ones still fails.
const Mixed: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.child-a' | 'chain.outside'> }> = () => null
// @ts-expect-error a partially-authorized whitelist is still out of union
core.register('chain.deleg', Mixed)
// Entries WITHOUT children stay unchecked: any slots face registers
// freely (I inferred from the inject factory as usual).
const FreeFace: FC<ChainOwnerShare & { useSession: unknown } & ChainInjected & { slots: ScopedSlots<'chain.outside'> }> = () => null
core.register('chain.session', FreeFace, {
inject: (): ChainInjected & { slots: ScopedSlots<'chain.outside'> } =>
({ useThing: () => 1, actions: { open: () => {} }, slots: { renderSlot: () => null } }),
})
const chatStore = () => defineStore({
init: () => ({ selection: null as { id: string } | null, draft: '' }),
actions: {
select: (d, t: { id: string }) => { d.selection = t },
setDraft: (d, text: string) => { d.draft = text },
clearDraft: (d) => { d.draft = '' },
},
})
type ChatHandle = ReturnType<typeof chatStore>
type FrameProps =
& PropsRuntime<'chain.frame'>
& PropsRenderSlots<'chain.side' | 'chain.conv'>
& PropsStore<ReturnType<typeof createPanelStore>>
& { openSettings: () => void }
type ConvProps =
& PropsRuntime<'chain.conv'>
& PropsStore<ChatHandle>
& { send: (t: string) => void }
// Component fixtures (never rendered; the register call sites are the test).
declare function Frame(props: FrameProps): ReactNode
declare function Conv(props: ConvProps): ReactNode
declare function Details(props: PropsRuntime<'chain.conv'> & PropsStore<ChatHandle>): ReactNode
declare function Tool(props: PropsRuntime<'chain.tools'>): ReactNode
declare function Over(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'chain.side' | 'chain.conv'>): ReactNode
declare function NoDecl(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'chain.side'>): ReactNode
declare function Blind(props: PropsRuntime<'chain.frame'>): ReactNode
declare function WrongStore(props: PropsRuntime<'chain.conv'> & PropsStore<ReturnType<typeof createPanelStore>>): ReactNode
declare function Needs(props: PropsRuntime<'chain.conv'> & { send: (t: string) => void }): ReactNode
describe('terminal-design type chain', () => {
it('holds the positive chain and the compile-time negatives', () => {
// Everything below is compile-surface only.
const samples = (core: SlotCore, chat: ChatHandle, fp: FrameProps, cp: ConvProps, acts: BoundActions<ChatHandle>) => {
// ── positive chain ─────────────────────────────────────────────
// Frame: children + factory store + inject; actions arrive baked.
core.register({
name: 'chain.frame',
children: {
'chain.side': { kind: 'single', scope: 'root' },
'chain.conv': { kind: 'single', scope: 'session' },
},
store: createPanelStore,
inject: (actions) => {
actions.setSidebar(0)
return { openSettings: () => {} }
},
}, Frame)
// Conv: shared handle; inject params derive as (sessionId, actions).
core.register({
name: 'chain.conv',
store: chat,
inject: (sessionId, actions) => ({
send: (text: string) => {
const sid: string = sessionId
actions.setDraft(text)
void sid
},
}),
}, Conv)
// Pure reader: same handle, no inject.
core.register({ name: 'chain.conv', store: chat }, Details)
// Owner + store shares arrive typed on the component face. Standard-kit
// member payloads are the runtime merge's property — not probed here
// (the runtime package's own tests cover them).
fp.renderSlot('chain.side', { collapsed: false, width: 280 })
const draft: string = cp.useStore((s) => s.draft)
cp.actions.select({ id: 'm1' })
void draft
// Keyed registration carries key.
core.register({ name: 'chain.tools', key: 'bash' }, Tool)
// ── negatives ──────────────────────────────────────────────────
// children spec must match the SlotMap entry.
core.register({
name: 'chain.frame',
// @ts-expect-error chain.conv is session-scoped in SlotMap
children: { 'chain.conv': { kind: 'single', scope: 'root' } },
}, (() => null) as SlotComponent<never>)
// renderSlot key set ⊄ children declaration.
// @ts-expect-error component renderSlot keys exceed the declaration
core.register({ name: 'chain.frame', children: { 'chain.side': { kind: 'single', scope: 'root' } } }, Over)
// renderSlot consumption without any children declaration.
// @ts-expect-error no children declaration authorizes rendering
core.register({ name: 'chain.frame' }, NoDecl)
// children declared but component consumes no renderSlot.
// @ts-expect-error children declared, component consumes no renderSlot
core.register({ name: 'chain.frame', children: { 'chain.side': { kind: 'single', scope: 'root' } } }, Blind)
// store share mismatch.
// @ts-expect-error component's store share doesn't match the declared handle
core.register({ name: 'chain.conv', store: chat }, WrongStore)
// inject face incomplete for the component's business share.
// @ts-expect-error inject face missing `send`
core.register({ name: 'chain.conv', inject: () => ({ notSend: 1 }) }, Needs)
// @ts-expect-error nothing provides `send` (no inject at all)
core.register({ name: 'chain.conv' }, Needs)
// root-scope inject takes no sessionId.
core.register({
name: 'chain.side',
// @ts-expect-error root-scope inject has no sessionId parameter
inject: (sessionId: string) => ({ x: sessionId }),
}, ((_p) => null) as SlotComponent<PropsRuntime<'chain.side'> & { x: string }>)
// keyed registration without key.
// @ts-expect-error keyed registration requires options.key
core.register({ name: 'chain.tools' }, Tool)
// renderSlot owner share typed at the call site.
// @ts-expect-error owner shape mismatch (width missing)
fp.renderSlot('chain.side', { collapsed: false })
// @ts-expect-error key not in this render share
fp.renderSlot('chain.tools', {})
// baked actions strip the draft parameter.
acts.setDraft('x')
// @ts-expect-error wrong payload type
acts.setDraft(1)
// SessionProvider seat: derives from a session-scope child declaration.
fp.SessionProvider({ empty: () => null, children: () => null })
const sideOnly: PropsRenderSlots<'chain.side'> = null as never
// @ts-expect-error only root-scope children declared → no SessionProvider seat
void sideOnly.SessionProvider
}
expect(cases).toBeTypeOf('function')
expect(samples).toBeTypeOf('function')
})
})

View File

@@ -35,7 +35,6 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"react": "^18.2.0"
},
"peerDependencies": {

View File

@@ -6,7 +6,7 @@
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 { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { ChromeProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { deriveSpans, deriveSpanStats } from './spans.ts'
import css from './TrajectoryStatsHeader.module.css'

View File

@@ -4,7 +4,7 @@
import { 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 { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { deriveSpans } from './spans.ts'
import css from './views.module.css'

View File

@@ -4,7 +4,7 @@
import { 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 { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { deriveSpans } from './spans.ts'
import css from './views.module.css'

View File

@@ -9,10 +9,8 @@ import { TrajectoryStatsHeader, type TrajectoryChromeProps } from './TrajectoryS
import { TrajectoryView } from './TrajectoryView.tsx'
import { WaterfallView, type WaterfallExtraProps } from './WaterfallView.tsx'
export { deriveSpans, deriveSpanStats, type SpanStats, type TurnSpan } from './spans.ts'
export { TrajectoryStatsHeader, type TrajectoryChromeProps } from './TrajectoryStatsHeader.tsx'
export { TrajectoryView } from './TrajectoryView.tsx'
export { WaterfallView, type WaterfallExtraProps } from './WaterfallView.tsx'
export type { TrajectoryChromeProps } from './TrajectoryStatsHeader.tsx'
export type { WaterfallExtraProps } from './WaterfallView.tsx'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ConversationViewMap {

View File

@@ -2,27 +2,39 @@
/**
* View registration acceptance on the real framework stack: the plugin fiber
* registers trajectory/waterfall into a real ConversationService, tabs switch
* inside ConversationRoot without collapsing chat, chrome.header renders the
* span stats bar, and fiber disposal removes both tabs. Span derivation edge
* cases ride along.
* inside ConversationRoot (four-share props form; view rendering is
* in-component now) without collapsing chat, chrome.header renders the span
* stats bar, and fiber disposal removes both tabs. Span derivation edge cases
* ride along.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createElement, Fragment, type FC, type ReactNode } from 'react'
import { bindSnapshotSelector, createSnapshotStore } 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, ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConvViewProps, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
import {
apply, deriveSpans, deriveSpanStats, inject, TrajectoryStatsHeader, TrajectoryView, WaterfallView,
} from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { createElement, type FC } from 'react'
import { bindSnapshotSelector } from '../../web-react/src/bind.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 { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { ConversationRoot } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import type { ConvViewProps, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { deriveSpans, deriveSpanStats } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx'
import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx'
import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/WaterfallView.tsx'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
const SID = 's1' as SessionId
afterEach(cleanup)
// The chat store persists under its declared key; clear so one case's active
// view cannot rehydrate into the next.
beforeEach(() => {
localStorage.clear()
})
/** Node fixture: user prologue, two turns, one tool result inside turn 1. */
const NODES = [
@@ -34,7 +46,25 @@ const NODES = [
function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore<{ nodes: ConversationSnapshot['nodes'] }>({ nodes })
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
/** Empty sessions-list hook stub (breadcrumbs fall back to the raw id). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
return bindSnapshotSelector(store)
}
/** Chat-view stand-in props for standalone view mounts. */
function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
const chat = createChatStore().create()
return {
sessionId: SID,
useSession: fakeSession(nodes).useSession,
useStore: bindSnapshotSelector(chat),
actions: { openDetails: vi.fn(), loadOlder: vi.fn() },
} as unknown as ConvViewProps
}
/** Real-stack bench: root Context + real ConversationService + the plugin fiber. */
@@ -48,45 +78,29 @@ async function bench() {
return { ctx, svc, fiber }
}
/** Mount ConversationRoot over the service's registry face, rendering chrome like the conversation apply does. */
/** Mount ConversationRoot over the service's registry face (four-share form: chrome/view rendering is in-component). */
function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] = NODES) {
const { useSession } = fakeSession(nodes)
const activeStore = createSnapshotStore<string | undefined>(undefined)
const ancestry: SessionSummary[] = [{ id: SID, title: 'self', running: false, updatedAt: 1 }]
const viewProps = {
sessionId: SID, useSession,
useSelection: () => null,
actions: { openDetails: vi.fn(), loadOlder: vi.fn() },
slots: undefined,
} as unknown as ConvViewProps
const renderView = (entry: ViewEntry): ReactNode => {
const children: ReactNode[] = []
if (entry.chrome?.header !== undefined) {
children.push(createElement(entry.chrome.header, { key: 'h', sessionId: SID, useSession }))
}
children.push(createElement(entry.component, { key: 'b', ...viewProps }))
if (entry.chrome?.footer !== undefined) {
children.push(createElement(entry.chrome.footer, { key: 'f', sessionId: SID, useSession }))
}
return createElement(Fragment, null, children)
}
const sessionSnapshot = createSnapshotStore<{ running: boolean; removed: boolean; promptError: null; nodes: ConversationSnapshot['nodes'] }>({
running: false, removed: false, promptError: null, nodes,
})
const chat = createChatStore().create()
return render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSnapshot) as unknown as UseSession}
useAncestry={() => ancestry}
useSession={bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>}
useSessions={emptySessions()}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
views={{
list: () => svc.views(),
subscribe: (fn) => svc.subscribeViews(fn),
version: () => svc.viewsVersion(),
}}
useActiveView={() => activeStore.useSelector((s) => s) as ViewId | undefined}
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
actions={{ openView: ((v: string) => { activeStore.set(v) }) as (v: never) => void, open: vi.fn() }}
renderView={renderView}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
loadOlder={vi.fn()}
open={vi.fn()}
/>,
)
}
@@ -163,28 +177,22 @@ describe('span derivation', () => {
const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes'])
const { container } = render(createElement(TrajectoryStatsHeader, { sessionId: SID, useSession }))
expect(container.firstChild).toBeNull()
render(createElement(TrajectoryView as FC<ConvViewProps>, {
sessionId: SID, useSession, useSelection: () => null,
actions: { openDetails: vi.fn(), loadOlder: vi.fn() }, slots: undefined,
} as unknown as ConvViewProps))
render(createElement(TrajectoryView as FC<ConvViewProps>,
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
})
})
describe('WaterfallView standalone branches', () => {
const props = (nodes: ConversationSnapshot['nodes']) => ({
sessionId: SID, useSession: fakeSession(nodes).useSession, useSelection: () => null,
actions: { openDetails: vi.fn(), loadOlder: vi.fn() }, slots: undefined,
} as unknown as ConvViewProps)
it('empty window renders the placeholder copy', () => {
render(createElement(WaterfallView as FC<ConvViewProps>, props([] as unknown as ConversationSnapshot['nodes'])))
render(createElement(WaterfallView as FC<ConvViewProps>,
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
})
it('a turn without tool calls renders the node bar only', () => {
const nodes = [{ kind: 'user', seq: 1 }] as unknown as ConversationSnapshot['nodes']
render(createElement(WaterfallView as FC<ConvViewProps>, props(nodes)))
render(createElement(WaterfallView as FC<ConvViewProps>, standaloneProps(nodes)))
expect(screen.getByTitle('1 nodes')).toBeTruthy()
expect(screen.queryByTitle(/tool calls/)).toBeNull()
})

View File

@@ -1,10 +1,10 @@
# @deepseek-ai/dsh-client-web-react
ctx↔React glue: createSnapshotStore (zustand vanilla + immer + subscribeWithSelector + rafFlush + opt-in persist), bindSnapshotSelector, SessionProvider (dependency-inverted), scopedSlots outlet, RootBindingProvider, useInvoke. Contract: api-contracts v3 §2.
Shell-side React glue for the slot terminal design: createSlotRenderer (the SlotRenderer implementation the shell installs into the runtime SlotsService), SessionProvider (framework-wired render prop, also injected as a standard seat to entries declaring session-scope children), bindSnapshotSelector (the one hook constructor — hosts and engines traffic in bare observable sources; every hook binds here, cached per source), useInvoke. The snapshot-store engine and defineStore live in runtime (store relocation); business plugins depend on ui-slots types only, never on this package.
## Model Experience
None, as the ctx↔React glue runs entirely in the browser; nothing here reaches a model request.
None, as the ctx↔React machinery runs entirely in the browser; nothing here reaches a model request.
#### KV Cache effect
@@ -12,6 +12,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **The persist middleware corrupts primitive-state stores** — it object-spreads state on save, so a `SnapshotStore<string>` round-trips as a character map; consumers with primitive state hand-roll persistence instead (ui-conversation drafts is the precedent).
- **The persist middleware corrupts primitive-state stores** — it object-spreads state on save, so a `SnapshotStore<string>` round-trips as a character map; the engine hand-rolls persistence instead (see `attachPersistence`).
- **`UseSession` is deliberately wide (`object` snapshot)** — the dependency direction (runtime → web-react, never the reverse) keeps the real `ConversationSnapshot` type out of reach; session-slot consumers narrow once at their boundary.
- **renderSlot is the single P-I form** — no Suspense, no per-entry lazy loading; the progressive-rendering surface returns with its own project.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-web-react",
"description": "ctx-to-React glue: createSnapshotStore (zustand engine), bindSnapshotSelector, SessionProvider, scopedSlots outlet, useInvoke",
"description": "Shell-side React glue: createSlotRenderer, SessionProvider, bindSnapshotSelector (uSES bridge), useInvoke",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -15,20 +15,14 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./store": {
"types": "./lib/types/store/index.d.ts",
"default": "./lib/store/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"immer": "^10.1.1",
"react": "^18.2.0",
"use-sync-external-store": "1.2.0",
"zustand": "~4.4.7"
"use-sync-external-store": "1.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
@@ -42,7 +36,6 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/store/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"

View File

@@ -1,19 +1,21 @@
/**
* uSES bridge: turns any {@link ObservableSnapshot} into a typed selector
* hook. Client-side-rendered only, so no server snapshot is wired.
* uSES bridge: turns any bare observable snapshot source into a typed
* selector hook. Client-side-rendered only, so no server snapshot is wired.
* This is the ONE hook constructor in the client stack — engines and hosts
* traffic in bare sources; binding happens on the React side.
*/
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector.js'
import type { ObservableSnapshot, SnapshotSelectorHook } from './store/index.ts'
import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
/**
* Bind an observable snapshot source to a typed uSES selector hook.
* Bind a bare observable source to a typed uSES selector hook.
* subscribe/getSnapshot are captured once per source into stable closures
* (also re-binds `this` for method-based sources), so components never
* resubscribe across renders. Equality defaults to Object.is.
* @param w - snapshot source (Session object or snapshot store).
* @param w - snapshot source (engine store, Session object, store instance).
* @returns the selector hook.
*/
export function bindSnapshotSelector<T>(w: ObservableSnapshot<T>): SnapshotSelectorHook<T> {
export function bindSnapshotSelector<T>(w: HostObservable<T>): SnapshotSelectorHook<T> {
const subscribe = (fn: () => void) => w.subscribe(fn)
const getSnapshot = () => w.getSnapshot()
return function useSelector<S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean): S {

View File

@@ -1,14 +1,15 @@
/**
* ctx-to-React glue: uSES bridge, SessionProvider (dependency-inverted),
* scopedSlots outlet factory, useInvoke. Contract: api-contracts v3 section 2.
* Shell-side React glue (slot terminal design §8): createSlotRenderer (the
* install-seam implementation), SessionProvider (framework-wired render
* prop, also delivered as a standard seat to session-area entries),
* bindSnapshotSelector (the one hook constructor), and useInvoke. The
* snapshot-store engine and defineStore live in runtime (store relocation);
* contract types are ui-slots authority — this face re-exports only what its
* own values traffic in. React contexts stay in-package: business components
* see none.
*/
import type { ReactNode } from 'react'
import type { SnapshotSelectorHook } from './store/index.ts'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
export type {
ObservableSnapshot, SnapshotSelectorHook, SnapshotStore,
} from './store/index.ts'
export { createSnapshotStore, shallowEqual } from './store/index.ts'
export { bindSnapshotSelector } from './bind.ts'
/**
@@ -19,23 +20,15 @@ export { bindSnapshotSelector } from './bind.ts'
*/
export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
/** Session assembly handle narrowed from ui-slots' structural form. */
export interface SessionBinding<Snap extends object = object> {
readonly sessionId: string
readonly session: { useSelector: UseSession<Snap> }
readonly ctx: unknown
}
// -- renderer: the install-seam implementation; contract lives in ui-slots --
export type {
HostObservable, RenderOpts, SessionCell, SnapshotSelectorHook,
SlotRenderer, SlotRendererHost, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
export { SlotOwnershipError, StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots'
export { createSlotRenderer } from './scoped-slots.tsx'
/** SessionProvider dependency surface (inverted: web-react never imports runtime). */
export interface SessionProviderDeps {
useCurrent: () => string | undefined
resolveBinding: (id: string) => SessionBinding | undefined
/** Assembler-owned body: the shell closes over its own scopedSlots to render the session slots. */
renderBody: (id: string) => ReactNode
}
export { createSessionProvider, RootBindingProvider, SlotAssemblyError, useRootBinding, useSessionBinding } from './session-provider.tsx'
export { scopedSlots } from './scoped-slots.tsx'
// -- session area: the framework-wired provider; binding contexts stay internal --
export { SessionProvider, SlotAssemblyError, type SessionProviderProps } from './session-provider.tsx'
export { useInvoke } from './use-invoke.ts'

View File

@@ -1,62 +1,97 @@
/**
* ScopedSlots factory: the sole render surface over the slot registry.
* renderSlot subscribes through uSES (SlotCore.subscribe/getVersion), renders
* per slot kind, wraps every entry in an error boundary, and merges props from
* three sources: standard injection (session slots get useSession), the
* registrant's cached inject factory, then owner props (owner wins).
*
* Typing model (slot type-chain design §4): the key stays generic (`K`) from
* renderSlot down to the outlet, so `entries<K>()` returns typed entries and
* the per-entry render path is monomorphic — no existential casts in loops.
* createSlotRenderer(): the outlet machinery behind the runtime install seam
* (slot terminal design §8). renderRoot mounts the host channel and renders
* the built-in 'root' key; every deeper slot renders through a per-entry
* renderSlot binding synthesized from the entry's children declaration.
* Standard-kit synthesis per entry: the global useSessions hook, the session
* pair (useSession + sessionId) under SessionProvider, the store pair
* (useStore + actions) for store-declaring entries, and the renderSlot
* binding (entry-identity bound, stale-checked) for children-declaring
* entries. Inject factories run inside the entry component bodies ON PURPOSE
* — the per-entry error boundary contains a throwing factory to its own
* entry; parameters follow the declaration (sessionId for session slots,
* baked actions when a store is declared).
*/
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import type {
RenderOpts, RootBinding, ScopedSlots, SessionBinding as SlotSessionBinding,
SlotCore, SlotEntry, SlotMap,
import {
SlotOwnershipError, StaleAuthorizationError,
type RenderOpts, type SessionCell, type SlotRenderer, type SlotRendererHost,
type StoredEntry,
} from '@deepseek-ai/dsh-client-ui-slots'
import { SlotAssemblyError, useRootBinding, useSessionBinding } from './session-provider.tsx'
import {
HostContext, SessionProvider, SlotAssemblyError, observableHook, useHost, useSessionCell,
} from './session-provider.tsx'
type AnyKey = keyof SlotMap & string
type EntryOf<K extends AnyKey> = SlotEntry<SlotMap[K]>
type InjectedProps = Record<string, unknown>
/**
* Inject results cache: root slots per entry, session slots per (entry x binding).
* WeakMap keys are the entry objects (stable across entries() snapshots per
* the SlotCore contract); values are the registrant's injected share. Storage
* erases the per-entry `I` — the single budgeted cast per cache restores it.
*/
const rootInjectCache = new WeakMap<object, InjectedProps>()
const sessionInjectCache = new WeakMap<object, WeakMap<object, InjectedProps>>()
/** Owner-facing renderSlot binding shape (typed narrowing lands on the wave-1 props seam). */
type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => ReactNode
function cachedRootInject<K extends AnyKey>(entry: EntryOf<K>, binding: RootBinding): InjectedProps {
const inject = entry.options?.inject
/**
* Per-entry renderSlot bindings. The binding is identity-stable per entry
* (memoized components must not resubscribe on unrelated re-renders) and dies
* with the entry: a retained closure calling after the entry's disposal hits
* the in-ledger check and throws.
*/
const renderSlotCache = new WeakMap<StoredEntry, RenderSlotBinding>()
function boundRenderSlot(host: SlotRendererHost, entry: StoredEntry): RenderSlotBinding {
let binding = renderSlotCache.get(entry)
if (!binding) {
binding = (key, owner, opts) => {
if (!host.isLive(entry)) {
throw new StaleAuthorizationError(`renderSlot('${key}') from a disposed registration`)
}
// Plain-JS backstop; typed callers are narrowed to the declared keys.
if (entry.children?.[key] === undefined) {
throw new SlotOwnershipError(`slot '${key}' is not declared by this entry's children`)
}
return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} />
}
renderSlotCache.set(entry, binding)
}
return binding
}
/**
* Inject results cache: root entries per entry, session entries per
* (entry x session cell). WeakMap keys are entry/cell objects (both
* identity-stable per registration/session scope), so cache lifetime rides
* the same axes as the values it memoizes.
*/
const rootInjectCache = new WeakMap<StoredEntry, InjectedProps>()
const sessionInjectCache = new WeakMap<StoredEntry, WeakMap<SessionCell, InjectedProps>>()
function runInject(entry: StoredEntry, cell: SessionCell | undefined, actions: object | undefined): InjectedProps {
const inject = entry.inject
if (!inject) return {}
// Declaration-derived positional arguments: sessionId for session scope,
// baked actions when a store is declared.
const args: unknown[] = []
if (cell !== undefined) args.push(cell.sessionId)
if (actions !== undefined) args.push(actions)
return (inject as (...args: unknown[]) => InjectedProps)(...args)
}
function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps {
let props = rootInjectCache.get(entry)
if (!props) {
// Root-scope factories accept RootBinding; the conditional-type parameter
// only fails to dispatch because K is generic here — the outlet's
// spec.scope branch guarantees the scope side (budgeted cast, one per cache).
props = (inject as (b: RootBinding) => InjectedProps)(binding)
props = runInject(entry, undefined, actions)
rootInjectCache.set(entry, props)
}
return props
}
function cachedSessionInject<K extends AnyKey>(entry: EntryOf<K>, binding: SlotSessionBinding): InjectedProps {
const inject = entry.options?.inject
if (!inject) return {}
let perBinding = sessionInjectCache.get(entry)
if (!perBinding) {
perBinding = new WeakMap()
sessionInjectCache.set(entry, perBinding)
function cachedSessionInject(entry: StoredEntry, cell: SessionCell, actions: object | undefined): InjectedProps {
let perCell = sessionInjectCache.get(entry)
if (!perCell) {
perCell = new WeakMap()
sessionInjectCache.set(entry, perCell)
}
let props = perBinding.get(binding)
let props = perCell.get(cell)
if (!props) {
// Same scope-dispatch note as the root cache: the session branch of the
// outlet guarantees this factory's binding side (budgeted cast).
props = (inject as (b: SlotSessionBinding) => InjectedProps)(binding)
perBinding.set(binding, props)
props = runInject(entry, cell, actions)
perCell.set(cell, props)
}
return props
}
@@ -83,68 +118,87 @@ class SlotErrorBoundary extends Component<
}
}
interface OutletProps<K extends AnyKey> {
core: SlotCore
slotKey: K
ownerProps: object
opts?: RenderOpts | undefined
/**
* Standard-kit synthesis shared by both scope branches: the global
* useSessions hook, the session pair, the store pair when declared, the
* renderSlot binding when children are declared, and the SessionProvider
* seat when the children declare a session-scope slot. Hosts hand out BARE
* observable sources (hooks never cross the host contract); every hook is
* bound HERE, cached per source (observableHook), so spreading a fresh kit
* object per render never churns child subscriptions.
*/
function standardKit(host: SlotRendererHost, entry: StoredEntry, cell: SessionCell | undefined): {
kit: InjectedProps; actions: object | undefined
} {
const kit: InjectedProps = { useSessions: observableHook(host.sessions.list) }
if (cell !== undefined) {
kit['useSession'] = observableHook(cell.session)
kit['sessionId'] = cell.sessionId
}
const store = host.storeOf(entry, cell?.sessionId)
if (store !== undefined) {
// The instance IS an observable snapshot source (contract getSnapshot/
// subscribe); the useStore hook binds here, cached per instance.
kit['useStore'] = observableHook(store)
kit['actions'] = store.actions
}
if (entry.children !== undefined) {
kit['renderSlot'] = boundRenderSlot(host, entry)
// SessionProvider standard seat: entries declaring a session-scope child
// render the session area, so the framework hands them the self-wired
// provider (module-level component = stable reference; no value import).
if (Object.values(entry.children).some((spec) => spec.scope === 'session')) {
kit['SessionProvider'] = SessionProvider
}
}
return { kit, actions: store?.actions }
}
/**
* One rendered entry: standard injection + cached inject + owner props.
* Inject factories run inside these component bodies ON PURPOSE — the outlet
* wraps every Entry element in the per-entry error boundary, so a throwing
* factory blacks out only its own entry. The three-source merge composes the
* entry's full props contract; TS cannot prove the composition against
* `SlotMap[K]['props']` (the shares are erased at the registry boundary), so
* each Entry renders through a props-widened view of the component — the
* design-budgeted composition point, one per scope branch.
* One rendered entry: standard kit + cached inject + owner props (owner
* wins). The kit and injected shares are erased at the render boundary — the
* register seam already proved the composed contract — so each Entry renders
* through a props-widened view of the component (the design-budgeted
* composition point, one per scope branch).
*/
function SessionEntry<K extends AnyKey>({ entry, ownerProps }: {
entry: EntryOf<K>; ownerProps: object
}) {
const binding = useSessionBinding()
function SessionEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
const host = useHost()
const cell = useSessionCell()
const Comp = entry.component as FC<InjectedProps>
const injected = cachedSessionInject(entry, binding)
return <Comp useSession={binding.session.useSelector} {...injected} {...ownerProps} />
const { kit, actions } = standardKit(host, entry, cell)
const injected = cachedSessionInject(entry, cell, actions)
return <Comp {...kit} {...injected} {...ownerProps} />
}
function RootEntry<K extends AnyKey>({ entry, ownerProps }: {
entry: EntryOf<K>; ownerProps: object
}) {
const hasInject = entry.options?.inject !== undefined
function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
const host = useHost()
const Comp = entry.component as FC<InjectedProps>
// Only inject-bearing entries need the root binding channel; plain entries
// must render fine in shells that never mounted RootBindingProvider.
if (!hasInject) return <Comp {...ownerProps} />
return <RootInjectEntry entry={entry} ownerProps={ownerProps} />
const { kit, actions } = standardKit(host, entry, undefined)
const injected = cachedRootInject(entry, actions)
return <Comp {...kit} {...injected} {...ownerProps} />
}
function RootInjectEntry<K extends AnyKey>({ entry, ownerProps }: {
entry: EntryOf<K>; ownerProps: object
function SlotOutlet({ slotKey, ownerProps, opts }: {
slotKey: string; ownerProps: object; opts?: RenderOpts | undefined
}) {
const binding = useRootBinding()
const Comp = entry.component as FC<InjectedProps>
const injected = cachedRootInject(entry, binding)
return <Comp {...injected} {...ownerProps} />
}
function SlotOutlet<K extends AnyKey>({ core, slotKey, ownerProps, opts }: OutletProps<K>) {
// Version tick drives entries() re-read; SlotCore batches per microtask.
const host = useHost()
// Version tick drives entries() re-read; the host batches per microtask.
useSyncExternalStore(
(fn) => core.subscribe(slotKey, fn),
() => core.getVersion(slotKey),
(fn) => host.subscribe(slotKey, fn),
() => host.getVersion(slotKey),
)
const spec = core.spec(slotKey)
if (!spec) throw new Error(`renderSlot('${slotKey}') before define`)
const entries = core.entries(slotKey)
const Entry: FC<{ entry: EntryOf<K>; ownerProps: object }> =
spec.scope === 'session' ? SessionEntry : RootEntry
const spec = host.specOf(slotKey)
// Undeclared (or no-longer-declared) keys render empty: a declaring entry's
// unload returns the slot to the undeclared state while retained elements
// may still be mounted — natural empty, not an ownership failure (§9).
if (!spec) return null
const entries = host.entriesOf(slotKey)
const Entry = spec.scope === 'session' ? SessionEntry : RootEntry
// The boundary must wrap the Entry ELEMENT, not live inside it: inject
// factories and binding lookups run in the Entry body and must land in the
// factories and kit synthesis run in the Entry body and must land in the
// per-entry fallback rather than escaping to the tree above.
const guarded = (entry: EntryOf<K>, key?: string | number) => (
const guarded = (entry: StoredEntry, key?: string | number) => (
<SlotErrorBoundary slotKey={slotKey} key={key}>
<Entry entry={entry} ownerProps={ownerProps} />
</SlotErrorBoundary>
@@ -156,15 +210,15 @@ function SlotOutlet<K extends AnyKey>({ core, slotKey, ownerProps, opts }: Outle
return guarded(entry)
}
if (spec.kind === 'keyed') {
const entry = entries.find((e) => e.options && 'key' in e.options && e.options.key === opts?.entryKey)
const entry = entries.find((e) => e.options?.key === opts?.entryKey)
if (!entry) return <>{opts?.fallback ?? null}</>
return guarded(entry)
}
// list: registration order refined by explicit order, optional id filter.
const withListOptions = entries.map((entry) => ({
entry,
id: entry.options && 'id' in entry.options ? entry.options.id : undefined,
order: entry.options && 'order' in entry.options ? entry.options.order ?? 0 : 0,
id: entry.options?.id,
order: entry.options?.order ?? 0,
}))
let list = [...withListOptions].sort((a, b) => a.order - b.order)
if (opts?.only !== undefined) list = list.filter((item) => item.id === opts.only)
@@ -172,20 +226,36 @@ function SlotOutlet<K extends AnyKey>({ core, slotKey, ownerProps, opts }: Outle
return <>{list.map((item, i) => guarded(item.entry, item.id ?? i))}</>
}
/** Root outlet: the shell's single ctx-level render entry — an unregistered 'root' is a boot-order failure, never a silent blank (§1). */
function RootOutlet({ ownerProps }: { ownerProps: object }) {
const host = useHost()
useSyncExternalStore(
(fn) => host.subscribe('root', fn),
() => host.getVersion('root'),
)
const entry = host.entriesOf('root')[0]
if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)")
return (
<SlotErrorBoundary slotKey="root">
<RootEntry entry={entry} ownerProps={ownerProps} />
</SlotErrorBoundary>
)
}
/**
* Build a whitelist-narrowed ScopedSlots render surface over a SlotCore.
* The type parameter narrows compile-time access; the runtime whitelist
* backstops plain-JS callers.
* @param core - the slot registry core.
* @param keys - whitelisted slot keys the caller may render.
* @returns the ScopedSlots facade.
* Build the renderer the shell installs into the runtime SlotsService
* (ctx.slots.install(createSlotRenderer()) at boot; the service owns the
* install/renderSlot seam and the double-install/not-installed throws).
* @returns the renderer.
*/
export function scopedSlots<K extends AnyKey>(core: SlotCore, ...keys: K[]): ScopedSlots<K> {
const allowed = new Set<string>(keys)
export function createSlotRenderer(): SlotRenderer {
return {
renderSlot(key, props, opts) {
if (!allowed.has(key)) throw new Error(`slot '${key}' is not in this ScopedSlots whitelist`)
return <SlotOutlet core={core} slotKey={key} ownerProps={props} opts={opts} />
renderRoot(host, ownerProps) {
return (
<HostContext.Provider value={host}>
<RootOutlet ownerProps={ownerProps} />
</HostContext.Provider>
)
},
}
}

View File

@@ -1,15 +1,16 @@
/**
* SessionProvider (dependency-inverted; never imports runtime) plus the two
* binding contexts the slot outlet reads: per-session {@link BindingContext}
* written here, and the root-binding channel written by the shell through
* {@link RootBindingProvider}.
* SessionProvider (framework-wired render prop, slot terminal design §7) plus
* the two internal channels the render machinery shares: the renderer host
* context (written once by createSlotRenderer's root) and the per-session
* binding context (written here, read by session-scope outlets). Both
* contexts are in-package machinery — they are NOT exported from the package
* index; business components see zero React contexts.
*/
import { createContext, useContext, type FC, type ReactNode } from 'react'
import type { RootBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionBinding, SessionProviderDeps } from './index.ts'
/** Session binding for the subtree under SessionProvider (module-private write). */
const BindingContext = createContext<SessionBinding | null>(null)
import { createContext, useContext, type ReactNode } from 'react'
import type {
HostObservable, SessionCell, SlotRendererHost, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import { bindSnapshotSelector } from './bind.ts'
/**
* A missing-provider assembly error: the shell wired the tree wrong. The slot
@@ -19,56 +20,75 @@ const BindingContext = createContext<SessionBinding | null>(null)
*/
export class SlotAssemblyError extends Error {}
/** Renderer host channel: written by createSlotRenderer's root element (in-package machinery only). */
export const HostContext = createContext<SlotRendererHost | null>(null)
/**
* Read the enclosing session binding; throws outside a SessionProvider
* subtree (session slots must not render without a session).
* @returns the enclosing binding.
* Read the installed renderer host; throws outside the rendered root tree
* (framework components must not render detached from the renderer).
* @returns the host surface.
*/
export function useSessionBinding(): SessionBinding {
const binding = useContext(BindingContext)
if (!binding) throw new SlotAssemblyError('session slot rendered outside SessionProvider')
return binding
export function useHost(): SlotRendererHost {
const host = useContext(HostContext)
if (!host) throw new SlotAssemblyError('slot machinery rendered outside the installed renderer tree')
return host
}
const RootBindingContext = createContext<RootBinding | null>(null)
/** Per-session binding channel for the subtree under SessionProvider (in-package machinery only). */
const BindingContext = createContext<SessionCell | null>(null)
/**
* Root-binding supply channel: the shell mounts this once at the top so root
* slot inject factories receive their assembly handle.
* Read the enclosing session cell; throws outside a SessionProvider subtree
* (session slots must not render without a session).
* @returns the enclosing cell.
*/
export const RootBindingProvider: FC<{ value: RootBinding; children?: ReactNode }> =
({ value, children }) => (
<RootBindingContext.Provider value={value}>{children}</RootBindingContext.Provider>
)
/**
* Read the root binding; throws when the shell forgot to mount
* {@link RootBindingProvider} (root inject factories need ctx).
* @returns the root binding.
*/
export function useRootBinding(): RootBinding {
const binding = useContext(RootBindingContext)
if (!binding) throw new SlotAssemblyError('root slot inject requires RootBindingProvider above')
return binding
export function useSessionCell(): SessionCell {
const cell = useContext(BindingContext)
if (!cell) throw new SlotAssemblyError('session slot rendered outside SessionProvider')
return cell
}
/**
* Build the single SessionProvider component: subscribes to the current
* session id, resolves its binding (stable reference), remounts the body
* under key={id}, and delegates body rendering to the assembler's renderBody
* (slot ownership stays with layout; the provider knows no slot names).
* @param deps - inverted dependencies.
* @returns the provider component.
* Identity-stable selector hook per host observable. uSES resubscribes when
* the subscribe reference changes, so the bound hook must be created once per
* source — cached here by source identity (sources are host-owned singletons).
* @param source - host-provided observable.
* @returns the cached selector hook.
*/
export function createSessionProvider(deps: SessionProviderDeps): FC<{ renderEmpty?: () => ReactNode }> {
return function SessionProvider({ renderEmpty }) {
const id = deps.useCurrent()
const binding = id === undefined ? undefined : deps.resolveBinding(id)
if (id === undefined || !binding) return <>{renderEmpty?.() ?? null}</>
return (
<BindingContext.Provider value={binding} key={id}>
{deps.renderBody(id)}
</BindingContext.Provider>
)
export function observableHook<T>(source: HostObservable<T>): SnapshotSelectorHook<T> {
let hook = hookCache.get(source)
if (hook === undefined) {
hook = bindSnapshotSelector(source)
hookCache.set(source, hook)
}
return hook as SnapshotSelectorHook<T>
}
const hookCache = new WeakMap<object, unknown>()
/** SessionProvider surface: render-prop body plus the no-session branch. */
export interface SessionProviderProps {
/** No-session body (also covers a current id whose session cannot be resolved). */
empty?: (() => ReactNode) | undefined
/** Session body; remounted per session via key={sessionId}. */
children: (sessionId: string) => ReactNode
}
/**
* Framework-wired session area: subscribes to the host's current-session
* source (design fiat ① — selection authority lives with runtime sessions),
* resolves the session cell, and remounts the body under key={sessionId} so
* a session switch rebuilds the whole session subtree. Ids speak plain
* string at this dependency-inverted layer; branding lands on the component
* props seam (PropsRuntime).
*/
export function SessionProvider({ empty, children }: SessionProviderProps) {
const host = useHost()
const id = observableHook(host.sessions.current)((s) => s)
const cell = id === undefined ? undefined : host.sessions.cell(id)
if (id === undefined || cell === undefined) return <>{empty?.() ?? null}</>
return (
<BindingContext.Provider value={cell} key={id}>
{children(id)}
</BindingContext.Provider>
)
}

View File

@@ -1,154 +0,0 @@
/**
* Snapshot store engine (zustand vanilla + immer + subscribeWithSelector +
* rafFlush middleware + opt-in persist + dev freeze). The only data contract
* consumed by React is {@link ObservableSnapshot}.
*/
import { createStore, type StoreApi } from 'zustand/vanilla'
import { subscribeWithSelector } from 'zustand/middleware'
import { shallow } from 'zustand/shallow'
import { produce } from 'immer'
import { bindSnapshotSelector } from '../bind.ts'
/** 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 with an attached typed selector hook. */
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
readonly useSelector: SnapshotSelectorHook<T>
}
/** Typed selector hook: equality defaults to Object.is; pass shallowEqual for object slices. */
export type SnapshotSelectorHook<T> = <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S
/**
* Shallow equality for selector slices (re-export of zustand/shallow semantics).
* @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) }
}
}
const store: SnapshotStore<T> = {
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)
},
useSelector: undefined as unknown as SnapshotSelectorHook<T>,
}
;(store as { useSelector: SnapshotSelectorHook<T> }).useSelector = bindSnapshotSelector(store)
return store
}
/**
* 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])
}
}

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