Merge remote-tracking branch 'origin/master' into feature/subagent-policy-inheritance

# Conflicts:
#	docs/event-producer-consumer.md
This commit is contained in:
kingwl
2026-07-25 11:01:40 +08:00
1570 changed files with 67657 additions and 14316 deletions

View File

@@ -19,6 +19,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
Naming notes:
- **Package tsconfig shape:** extends `tsconfig.base.json` (client: `tsconfig.base.client.json`), `rootDir: src`, `outDir: lib/types`, a `references` entry per workspace dependency plus `support/invariants`; registered in exactly one aggregate — host packages in `tsconfig.host.json`, client in `tsconfig.client.json` ([layout](../docs/development.md#typescript-project-layout)).
- `src/types.ts` contains only types — no runtime code.
- Tests live at package level under `tests/`, not `src/__tests__/`.
- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code.

View File

@@ -32,7 +32,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval: logical corpus, bounded reads, lineage, and event relationships | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface |
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface |

View File

@@ -49,6 +49,8 @@ The overlay is computed from the current `ToolExecution` and passed through the
Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`.
The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The Native renderer preserves the text above, including exactly `started background task <id>`; programmatic consumers use the typed fields without parsing those strings. Executor stream caps remain acquisition limits on `BashRunResult` and carry their spill paths.
When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
## UI presentation

View File

@@ -11,8 +11,9 @@
import { Service, type Context } from 'cordis'
import z from 'schemastery'
import { isAbsolute, resolve as resolvePath } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-session-persistence'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -22,7 +23,7 @@ import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandb
import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
import type { BashRunResult, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths'
import { processOutcome } from './background.ts'
import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'
@@ -206,7 +207,7 @@ export class BashEnvRegistry extends Service {
}
}
/** Parsed tool args; execute validates value constraints absent from SchemaSpec. */
/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */
interface BashToolArgs {
command: string
description: string
@@ -318,6 +319,38 @@ function resolveWorkdir(
return modelWorkdir
}
/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */
function canonicalBashResult(result: BashRunResult) {
const output = (stream: BashRunResult['stdout']) => ({
text: stream.text,
truncated: stream.truncated,
...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {},
})
return {
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
aborted: result.aborted,
timeoutMs: result.timeoutMs,
stdout: output(result.stdout),
stderr: output(result.stderr),
...result.sandbox !== undefined ? {
sandbox: {
mode: result.sandbox.mode,
denied: result.sandbox.denied,
...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {},
...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {},
},
} : {},
}
}
/** Canonical background-handle properties shared by the bash output union. */
const BACKGROUND_OUTPUT_PROPERTIES = {
kind: { type: 'string', required: true, const: 'background' },
taskId: { type: 'string', required: true },
} as const
export function apply(ctx: Context, config: Config = {}): void {
const bashEnv = new BashEnvRegistry(ctx, config)
bashEnv.register({
@@ -415,6 +448,65 @@ export function apply(ctx: Context, config: Config = {}): void {
},
} : {},
},
output: {
schema: {
oneOf: [
{
type: 'object',
additionalProperties: false,
properties: BACKGROUND_OUTPUT_PROPERTIES,
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'foreground' },
exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
timedOut: { type: 'boolean', required: true },
aborted: { type: 'boolean', required: true },
timeoutMs: { type: 'number', required: true },
stdout: {
type: 'object',
additionalProperties: false,
required: true,
properties: {
text: { type: 'string', required: true },
truncated: { type: 'boolean', required: true },
spillPath: { type: 'string' },
},
},
stderr: {
type: 'object',
additionalProperties: false,
required: true,
properties: {
text: { type: 'string', required: true },
truncated: { type: 'boolean', required: true },
spillPath: { type: 'string' },
},
},
sandbox: {
type: 'object',
additionalProperties: false,
properties: {
mode: { type: 'string', required: true },
denied: { type: 'boolean', required: true },
enforcement: { type: 'string' },
runnerFailed: { type: 'boolean' },
},
},
},
},
],
},
render: (_args, value) => [{
type: 'text',
text: value.kind === 'background'
? `started background task ${value.taskId}`
: renderResult(value as { kind: 'foreground' } & BashRunResult, escalationModes),
}],
},
async execute(args: BashToolArgs, exec) {
validateBashArgs(args)
// Description is display metadata; workdir defaults to the caller's session.
@@ -444,7 +536,11 @@ export function apply(ctx: Context, config: Config = {}): void {
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
}
// The caller owns cancellation until TaskService commits detached ownership.
if (exec.signal.aborted) return []
if (exec.signal.aborted) {
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
error.name = 'AbortError'
throw error
}
// Task preflight finishes before the starter can spawn a process.
const id = tasks.start({
kind: 'bash',
@@ -459,14 +555,14 @@ export function apply(ctx: Context, config: Config = {}): void {
}
},
})
return [{ type: 'text', text: `started background task ${id}` }]
return { kind: 'background' as const, taskId: id }
}
const result = await ctx.bash.run(ctx.bash.resolve({
...request,
signal: exec.signal,
}))
if (result.aborted) throw new Error('command aborted')
return [{ type: 'text', text: renderResult(result, escalationModes) }]
return { kind: 'foreground' as const, ...canonicalBashResult(result) }
},
presentCall: presentBashCall,
presentResult: presentBashResult,

View File

@@ -109,7 +109,7 @@ describe('bash tool through the agent loop', () => {
const location = ctx.sessionPersistence.locate(agent.session.header)
expect(location?.kind).toBe('jsonl')
agent.send([{ type: 'text', text: 'inspect the current session' }])
agent.followup([{ type: 'text', text: 'inspect the current session' }])
await waitForIdle(ctx, agent)
const result = findEvent(events(agent), 'tool/result')
@@ -128,7 +128,7 @@ describe('bash tool through the agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'run echo integration-ok' }])
agent.followup([{ type: 'text', text: 'run echo integration-ok' }])
await waitForIdle(ctx, agent)
const log = events(agent)
@@ -160,7 +160,7 @@ describe('bash tool through the agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'run exit 9' }])
agent.followup([{ type: 'text', text: 'run exit 9' }])
await waitForIdle(ctx, agent)
const toolResult = findEvent(events(agent), 'tool/result')
@@ -168,7 +168,7 @@ describe('bash tool through the agent loop', () => {
expect(resultText(toolResult)).toContain('[exit code: 9]')
})
it('background: start ack → completion notice as context/message → task_output collects it', async () => {
it('background: start ack → completion notice as user/message → task_output collects it', async () => {
// The task id is deterministic (a fresh TaskService counts per kind from 1),
// so the script can name `bash-1` without threading a generated id.
const adapter = new MockAdapter([
@@ -180,7 +180,7 @@ describe('bash tool through the agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }])
agent.followup([{ type: 'text', text: 'run echo bg-ok in the background' }])
await waitForIdle(ctx, agent)
const firstResult = findEvent(events(agent), 'tool/result')
@@ -188,17 +188,19 @@ describe('bash tool through the agent loop', () => {
expect(resultText(firstResult)).toBe('started background task bash-1')
// The task settles on its own; the tool-tasks notice listener injects a
// durable context/message into the owning agent's session (settlement may
// race turn end, so poll for it).
await pollUntil(() => events(agent).some(event => event.type === 'context/message'))
const notice = findEvent(events(agent), 'context/message')
// durable plugin-sourced user/message into the owning agent's session
// (settlement may race turn end, so poll for it).
const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> =>
e.type === 'user/message' && e.data.source.kind === 'plugin'
await pollUntil(() => events(agent).some(isNotice))
const notice = events(agent).find(isNotice)!
expect(notice.data.content.some(
block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'),
)).toBe(true)
expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' })
// The next turn collects the output through the generic task tool.
agent.send([{ type: 'text', text: 'collect it' }])
agent.followup([{ type: 'text', text: 'collect it' }])
await waitForIdle(ctx, agent)
const readResult = findEvent(events(agent), 'tool/result', 'last')
expect(readResult.data.isError).toBe(false)

View File

@@ -122,7 +122,13 @@ class RecordingSandboxExecutor extends BashExecutor {
timeoutMs: spec.timeoutMs,
stdout: { text: 'ok', truncated: false },
stderr: { text: '', truncated: false },
sandbox: { mode: spec.sandboxPolicy?.mode ?? 'read-only', denied: false },
sandbox: {
mode: spec.sandboxPolicy?.mode ?? 'read-only',
denied: false,
...spec.command === 'without optional sandbox facts'
? {}
: { enforcement: 'full' as const, runnerFailed: false },
},
})
}
@@ -213,6 +219,16 @@ describe('bash tool', () => {
const ctx = await setup()
const result = await call(ctx, 'bash', { command: 'echo hello', description: 'test command' })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected bash success')
expect(result.value).toMatchObject({
kind: 'foreground',
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
stdout: { text: 'hello\n', truncated: false },
stderr: { text: '', truncated: false },
})
expect(text(result)).toBe('hello\n')
})
@@ -296,7 +312,7 @@ describe('bash tool', () => {
})
// Type and required-key violations are rejected by the harness
// (defineTool validates against the SchemaSpec — the arg-validation Agent Note) before execute.
// (defineTool validates against the ParameterSchemaSpec — the arg-validation Agent Note) before execute.
it.each([
[{}, /missing required property "command"/],
[{ command: 42, description: 'd' }, /"command" must be a string/],
@@ -312,7 +328,7 @@ describe('bash tool', () => {
expect(text(result)).toMatch(pattern)
})
// Value constraints the SchemaSpec can't express stay in the tool body.
// Value constraints the ParameterSchemaSpec can't express stay in the tool body.
it.each([
[{ command: ' ', description: 'd' }, /invalid command/],
[{ command: 'x', description: ' ' }, /invalid description/],
@@ -408,6 +424,8 @@ describe('background execution through the task runtime', () => {
const ctx = await setupWithTasks()
const started = await call(ctx, 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true })
expect(started.isError).toBe(false)
if (started.isError) throw new Error('expected background bash success')
expect(started.value).toEqual({ kind: 'background', taskId: 'bash-1' })
expect(text(started)).toBe('started background task bash-1')
const read = await callUntilText(ctx, 'task_output', { task_id: 'bash-1' }, 'bg-ok')
@@ -479,7 +497,10 @@ describe('background execution through the task runtime', () => {
signal: controller.signal,
})
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
expect(result.error).toEqual({
message: 'tool call aborted before dispatch',
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
expect(text(result)).toBe('Error: tool call aborted before dispatch')
expect((ctx.bash as CountingStartExecutor).starts).toBe(0)
})
@@ -633,7 +654,10 @@ describe('sandbox escalation through the generic task producer', () => {
signal: controller.signal,
})
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED })
expect(result.error).toEqual({
message: 'tool call aborted',
info: { name: 'AbortError', code: TOOL_ABORTED },
})
expect(text(result)).toBe('Error: tool call aborted')
expect(start).not.toHaveBeenCalled()
})
@@ -647,6 +671,22 @@ describe('sandbox escalation through the generic task producer', () => {
expect(bash.modes).toEqual(['workspace-write', 'danger-full-access'])
})
it('omits sandbox facts the executor did not acquire from the canonical result', async () => {
const { ctx } = await setupSandboxed()
const result = await call(ctx, 'bash', {
command: 'without optional sandbox facts',
description: 'exercise optional sandbox facts',
})
if (result.isError) throw new Error('expected foreground bash success')
expect(result.value).toMatchObject({
kind: 'foreground',
sandbox: { mode: 'read-only', denied: false },
})
expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('enforcement')
expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('runnerFailed')
})
it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => {
const { ctx } = await setupSandboxed(true)
ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome)

View File

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

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-connection
Wire consumer layer (moved verbatim from web-runtime): IApiClient family (WebApiClient/FixtureApiClient), ConnectionController (SSE dual-stream + backoff reconnect), WEB_EVENTS. Contract: api-contracts v3 §3, export inventory in §3.2.
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
## Model Experience

View File

@@ -14,7 +14,10 @@ export type {
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
// transportError moved down to the apiproxy api layer (it belongs beside
// RpcResult, its subject); re-exported here so connection consumers keep one
// contract entry point.
export { RpcId, transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
@@ -31,16 +34,3 @@ import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
export function resultOf<T>(response: RpcResponse<T>): RpcResult<T> {
return response.result
}
/**
* Fold a transport exception into the RpcResult error branch (unified error
* surface; 'internal' as the catch-all code).
* @param error - the thrown value from the carrier.
* @returns the error branch of an RpcResult.
*/
export function transportError<T>(error: unknown): RpcResult<T> {
return {
ok: false,
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
}
}

View File

@@ -2,8 +2,8 @@
// RpcRequest<P> and returns RpcResponse<T> (echoing the rpcId); streams yield RpcRequest<frame>
// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
// and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable);
// prompt triggers a chunked streaming replay; cancel stops the replay; one resident pending
// approval (placeholder-card material, subscribed-baseline-replay semantics: stable rpcId reuse).
// prompt triggers a chunked streaming replay; cancel stops the replay; resident pending
// approval/question requests exercise replay and composer takeover with stable rpcIds.
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
@@ -24,6 +24,28 @@ function text(t: string): ContentBlock[] {
return [{ type: 'text', text: t }]
}
const MARKDOWN_FIXTURE = [
'# Markdown fixture',
'',
'Assistant output renders **strong text**, *emphasis*, and `inline code`.',
'',
'- first item',
' - nested item',
'',
'| Surface | State |',
'| --- | --- |',
'| history | rendered |',
'| streaming | stable |',
'',
'[DeepSeek](https://www.deepseek.com)',
'',
'```ts',
'const markdown = true',
'```',
].join('\n')
const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
function sid(id: string): SessionId {
return id as SessionId
}
@@ -40,16 +62,28 @@ function buildAlphaLog(): SessionEvent[] {
}
for (let turn = 0; turn < 60; turn++) {
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } })
const userSeq = push({
type: 'user/message', surfaceOp: 'append',
data: {
content: text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`),
source: { kind: 'user' },
},
})
if (turn === 0) {
push({
type: 'session/title',
data: { title: 'Fixture 历史会话', messageSeqs: [userSeq], source: { kind: 'fallback' } },
})
}
if (turn % 9 === 4) {
push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } })
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } })
}
push({ type: 'step/start', data: { turn, step: 0 } })
const withTool = turn % 5 === 2
const withReasoning = turn % 3 === 1
const blocks: ContentBlock[] = []
if (withReasoning) blocks.push({ type: 'reasoning', text: `思考过程 ${turn}:这是一段可折叠的 reasoning 内容。` })
blocks.push({ type: 'text', text: `回答 ${turn}:这是 fixture 生成的历史回复正文。` })
blocks.push({ type: 'text', text: turn === 59 ? MARKDOWN_FIXTURE : `回答 ${turn}:这是 fixture 生成的历史回复正文。` })
if (withTool) {
const callId = `fx-call-${turn}`
blocks.push({ type: 'tool-call', id: callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } as ContentBlock)
@@ -69,8 +103,9 @@ function buildAlphaLog(): SessionEvent[] {
}
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
// Three view-sample turns (60-62) for the tool-card wire acceptance: one per built-in card
// type. `echo` above stays presenter-less on purpose — it is the no-view fallback sample.
// Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in
// turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above
// stays presenter-less as the unknown fallback.
const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
const callId = `fx-call-${turn}`
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
@@ -87,7 +122,8 @@ function buildAlphaLog(): SessionEvent[] {
}
toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'fx-note', '{"note":"三型卡验收样本"}', '已记录')
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
return events as unknown as SessionEvent[]
}
@@ -112,8 +148,10 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
card: 'diff', title: `Write ${str(args.path)}`,
diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
}
case 'fx-note':
return { card: 'generic', title: '记录笔记', kind: 'edit', rawInput: args }
case 'edit':
return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args }
case 'write':
return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args }
default:
return undefined // echo et al: the documented no-view fallback path
}
@@ -155,6 +193,20 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
return undefined
}
/** Fold the latest fixture title into the host's control-frame projection. */
function titleFrameOf(id: SessionId, log: readonly SessionEvent[]): Extract<MuxFrame, { type: 'session/title' }> | undefined {
const event = log.findLast(item => (item as { type: string }).type === 'session/title')
if (event === undefined) return undefined
const titleEvent = event as unknown as { seq: number; time: number; data: { title: string } }
return {
type: 'session/title',
sessionId: id,
title: titleEvent.data.title,
eventSeq: titleEvent.seq,
updatedAt: titleEvent.time,
}
}
/**
* Message-boundary paging (mirrors the host's paging contract): count
* maxMessages messages
@@ -249,6 +301,41 @@ export function createFixtureApi(): ApiProxy {
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
const pendingApprovalRpcId = mint()
const pendingQuestionRpcId = mint()
let questionPending = true
const fixtureQuestions: Extract<MuxFrame, { type: 'question/requested' }>['questions'] = [
{
id: 'harness-profile',
header: '偏好',
question: '你现在更想招哪类 Agent/Harness 候选人?',
options: [
{ label: '工程落地型 (Recommended)', description: '更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。' },
{ label: '研究潜力型', description: '更看重 Agent 理解、训练评测思路和长期成长空间。' },
{ label: '均衡型', description: '同时要求工程能力和 Agent 认知,但可能筛选门槛更高。' },
],
},
{
id: 'work-mode',
header: '方式',
question: '你希望候选人优先展示哪种工作方式?',
options: [
{ label: '先做小型原型 (Recommended)', description: '用可运行结果尽快验证关键假设。' },
{ label: '先写完整设计', description: '先收敛边界、协议和风险,再开始实现。' },
],
},
{
id: 'signals',
header: '信号',
question: '哪些面试信号最重要?',
detail: '按当前招聘目标选择;跳过则视为不设偏好。',
multiSelect: true,
options: [
{ label: '系统设计' },
{ label: '代码质量' },
{ label: 'Agent 产品判断' },
],
},
]
const muxConns = new Set<StreamConn<MuxFrame>>()
const hostConns = new Set<StreamConn<HostFrame>>()
@@ -294,6 +381,10 @@ export function createFixtureApi(): ApiProxy {
emitMux(view === undefined
? { type: 'session/event', sessionId: id, event }
: { type: 'session/event', sessionId: id, event, view })
if ((event as { type: string }).type === 'session/title') {
// The raw title is already in this log, so the latest-title fold must find it.
emitMux(titleFrameOf(id, log) as Extract<MuxFrame, { type: 'session/title' }>)
}
}
/** At most one in-flight replay per session; cancel clears it. */
@@ -322,6 +413,12 @@ export function createFixtureApi(): ApiProxy {
appendUser(id: string, msg: string): void {
append(sid(id), { type: 'user/message', surfaceOp: 'append', data: { content: text(msg), source: { kind: 'user' } } })
},
/** Append a later durable title revision through the normal raw-event + control-frame path. */
appendTitle(id: string, title: string): void {
const log = logOf(sid(id))
const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq)
append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } })
},
/** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */
appendSilent(id: string, msg: string): void {
const log = logOf(sid(id))
@@ -339,8 +436,8 @@ export function createFixtureApi(): ApiProxy {
const step = 0
append(id, { type: 'step/start', data: { turn, step } })
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
/* v8 ignore next -- the ?? arm needs a null match, but replyText is never empty (prompt always prefixes 回声). */
const pieces = replyText.match(/.{1,6}/gu) ?? [replyText]
/* v8 ignore next -- the ?? arm needs a null match, but every fixture reply is non-empty. */
const pieces = replyText.match(/[\s\S]{1,6}/gu) ?? [replyText]
let i = 0
const finish = (aborted: boolean): void => {
replays.delete(id)
@@ -406,7 +503,13 @@ export function createFixtureApi(): ApiProxy {
setRunning(id, true)
append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
append(id, { type: 'user/message', surfaceOp: 'append', data: { content, source: { kind: 'user' } } })
startReply(id, turn, `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`)
startReply(
id,
turn,
userText === 'render markdown'
? MARKDOWN_FIXTURE
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
)
return ok(request, { accepted: true as const })
},
cancel: (request) => {
@@ -429,10 +532,12 @@ export function createFixtureApi(): ApiProxy {
muxConns.add(conn)
const breakNow = (): void => { conn.breakNow() }
streamBreakers.add(breakNow)
// Open baseline: subscribed for attached (running) sessions + pending approval replay (stable rpcId).
// Open baseline: subscribed sessions + pending interactions replayed with stable rpcIds.
for (const s of sessions) {
if (!s.running) continue
conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: (logs.get(s.sessionId)?.length ?? 0) - 1 } })
const title = titleFrameOf(s.sessionId, logs.get(s.sessionId) ?? [])
if (title !== undefined) conn.push({ rpcId: mint(), payload: title })
}
conn.push({
rpcId: pendingApprovalRpcId,
@@ -442,6 +547,14 @@ export function createFixtureApi(): ApiProxy {
toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
},
})
if (questionPending) {
conn.push({
rpcId: pendingQuestionRpcId,
payload: {
type: 'question/requested', sessionId: sid('fx-alpha'), questions: fixtureQuestions,
},
})
}
try {
yield* conn.drain(signal)
} finally {
@@ -471,9 +584,16 @@ export function createFixtureApi(): ApiProxy {
},
},
respond(message: ClientResponse): Promise<RpcReceipt> {
// The v1 UI never answers (PendingCard is visible but not answerable); implemented for type completeness, always not-pending.
void message
return Promise.resolve({ accepted: false, reason: 'not-pending' })
if (!questionPending || message.rpcId !== pendingQuestionRpcId) {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
}
questionPending = false
emitMux({
type: 'question/resolved', sessionId: sid('fx-alpha'),
questionRpcId: pendingQuestionRpcId,
outcome: message.result.ok ? 'answered' : 'cancelled',
})
return Promise.resolve({ accepted: true })
},
}
}

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

@@ -18,6 +18,7 @@ interface TimingHooks {
setHistoryDelay(ms: number): void
failNextHistory(): void
appendUser(id: string, msg: string): void
appendTitle(id: string, title: string): void
appendSilent(id: string, msg: string): void
breakStreams(): void
}
@@ -113,7 +114,7 @@ describe('createFixtureApi', () => {
const missing = await api.sessions.prompt(req({ sessionId: sid('ghost'), mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'ghost' } } })
// Real prompt: replay starts (running flips true), cancel freezes it.
const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '取消我' }] }))
const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'render markdown' }] }))
expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } })
await new Promise(resolve => setTimeout(resolve, 120)) // a couple of typewriter ticks
await api.sessions.cancel(req({ sessionId: id }))
@@ -148,14 +149,14 @@ describe('createFixtureApi', () => {
expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn
})
it('mux open replays the baseline: subscribed for running sessions + the resident approval with a stable rpcId', async () => {
it('mux open replays subscribed sessions and resident interactions with stable rpcIds', async () => {
const api = createFixtureApi()
const openOnce = async (): Promise<RpcRequest<MuxFrame>[]> => {
const abort = new AbortController()
const envelopes: RpcRequest<MuxFrame>[] = []
for await (const envelope of api.events.mux(req({}), abort.signal)) {
envelopes.push(envelope)
if (envelopes.length >= 2) abort.abort()
if (envelopes.length >= 4) abort.abort()
}
return envelopes
}
@@ -163,8 +164,11 @@ describe('createFixtureApi', () => {
const second = await openOnce()
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
expect(first[1]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[1]?.payload).toMatchObject({ type: 'session/title', sessionId: 'fx-alpha', title: 'Fixture 历史会话' })
expect(first[2]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[2]?.rpcId).toBe(first[2]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[3]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[3]?.rpcId).toBe(first[3]?.rpcId)
})
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
@@ -217,9 +221,38 @@ describe('createFixtureApi', () => {
}
})
it('respond is a typed stub: always not-pending', async () => {
it('respond resolves the resident question once and rejects duplicate or unrelated ids', async () => {
const api = createFixtureApi()
expect(await api.respond({ type: 'client-response', rpcId: RpcId('x'), result: { ok: true, value: {} } })).toEqual({ accepted: false, reason: 'not-pending' })
const abort = new AbortController()
let question: RpcRequest<MuxFrame> | undefined
for await (const envelope of api.events.mux(req({}), abort.signal)) {
if (envelope.payload.type !== 'question/requested') continue
question = envelope
abort.abort()
}
if (question === undefined) throw new Error('fixture question missing')
const response = { type: 'client-response' as const, rpcId: question.rpcId, result: { ok: true as const, value: {} } }
expect(await api.respond(response)).toEqual({ accepted: true })
expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' })
const replayAbort = new AbortController()
const replayed = await collect(api.events.mux(req({}), replayAbort.signal), replayAbort, frames => frames.length === 2)
expect(replayed.every(frame => frame.type !== 'question/requested')).toBe(true)
const cancelledApi = createFixtureApi()
const cancelAbort = new AbortController()
let cancelQuestion: RpcRequest<MuxFrame> | undefined
for await (const envelope of cancelledApi.events.mux(req({}), cancelAbort.signal)) {
if (envelope.payload.type !== 'question/requested') continue
cancelQuestion = envelope
cancelAbort.abort()
}
if (cancelQuestion === undefined) throw new Error('fixture cancellation question missing')
expect(await cancelledApi.respond({
type: 'client-response', rpcId: cancelQuestion.rpcId,
result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } },
})).toEqual({ accepted: true })
})
it('describe answers the fixture identity', async () => {
@@ -248,10 +281,15 @@ describe('createFixtureApi', () => {
await new Promise(resolve => setTimeout(resolve, 10))
hooks.appendSilent('fx-alpha', '静默丢帧')
hooks.appendUser('fx-alpha', '正常直播')
hooks.appendTitle('fx-alpha', 'Fixture 修订标题')
await vi.waitFor(() => {
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true)
expect(seen.some(f => f.type === 'session/title' && f.title === 'Fixture 修订标题')).toBe(true)
})
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false)
const rawTitleIndex = seen.findIndex(f => f.type === 'session/event' && (f.event as { type: string }).type === 'session/title')
const titleControlIndex = seen.findIndex(f => f.type === 'session/title' && f.title === 'Fixture 修订标题')
expect(titleControlIndex).toBe(rawTitleIndex + 1)
// But history serves the silent event (the client's repull finds it).
const repull = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))
if (!repull.result.ok) throw new Error('repull failed')

View File

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

View File

@@ -0,0 +1,19 @@
# @deepseek-ai/dsh-client-hmr
Hot reload for fetch-arrival client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
The plugin subscribes to the webserver's system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. Rebuild detection lives on the webserver: in dev mode it stat-polls each plugin's built `lib/client.js` (`fs.watchFile`) and broadcasts the `rebuilt` frame when the bundle's rev changes, so any tsdown watch process producing the bundle triggers HMR with no builder→host channel.
## Model Experience
None, as the reload driver is browser-side machinery; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out.
- **No failure rollback** — a reload that fails leaves the entry FAILED and loud in the loader status projection; restoring the previous bundle automatically is deferred until a real need shows.
- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless (the bundle endpoint serves no-cache); rev refresh lands with the reconnect-handshake mechanism.

View File

@@ -0,0 +1,51 @@
{
"name": "@deepseek-ai/dsh-client-hmr",
"description": "Dev-only hot-reload driver for fetch-arrival client entries: SSE rebuilt frames → prefetch/invalidate → fiber swap through the vendored Loader entry",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [],
"platform": "web",
"immediately": true
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-client-modules": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,191 @@
/**
* client-hmr, browser half: hot-reload driver for client plugin entries.
*
* Listens on the host's system SSE channel (`GET /plugins/events`); on a
* `rebuilt` frame it re-fetches the entry's bundle and swaps the cordis
* fiber in place. Every graph entry is a plugin bundle under the web2 model
* — `immediately` rows differ only in stage-one prefetch (a boot
* optimization), so all nine plugin packages share these reload semantics;
* normal packages (react family, cordis, shell, pure libs) are not entries
* and shell changes still mean a page reload. Cascade is zero-touch:
* downstream fibers key their activation epoch on provider fiber uids
* (vendor/cordis/src/fiber.ts `_refresh`), so replacing a provider fiber
* re-cascades natively — reloading a data-layer plugin (connection/runtime)
* cascades into its UI dependents with no HMR-side bookkeeping.
*
* Reload order (lazy CJS table): invalidate (drop the stale factory and
* materialized record) → prefetch (fetch + execute + register the fresh
* factory) → registry-first teardown → drain old fiber unload → remove
* owned `<style data-plugin>` tags → `entry.refresh()` materializes the new
* factory. Invalidate MUST precede prefetch: a live factory makes prefetch
* a no-op, and re-executing a bundle over an undeleted registration is a
* loud duplicate. The swap is safe because execution is pure registration
* under the lazy model — every module side effect (CSS injection included)
* lives in the factory closure and runs at materialization, inside
* refresh(). That also keeps the CSS ordering guarantee: owned styles are
* removed after the old fiber's disposers drained (SlotCore one-owner
* unregister) and before materialization re-injects tags under the same
* stable tag ids.
*
* Failure window: if prefetch rejects after invalidate, the module is left
* unregistered while the OLD fiber keeps running untouched (teardown never
* started) — degraded but recoverable, the next rebuilt frame retries from
* scratch. Consistent with the v1 no-rollback policy below. Known dev-only
* race: a rebuilt frame overlapping a still-in-flight boot arrival shares
* that arrival's task and may materialize the pre-rebuild bytes; the next
* rebuilt frame self-heals.
*
* Why not the naive `entry.fiber.dispose()` → `entry.refresh()` path —
* confirmed against vendor sources:
* 1. `Entry.fiber` is never cleared on dispose (vendor/loader/src/config/
* entry.ts assigns it only in `_init`), so `refresh()` hits its
* `if (this.fiber) return` guard and no-ops.
* 2. A bare `fiber.dispose()` lands in Loader's self-dispose branch
* (vendor/loader/src/index.ts `internal/plugin` case 4: the registry
* still holds the runtime at emit time), which flags the entry
* `disabled: true` — permanently.
* vendor/hmr's reload skeleton documents the fix: delete the runtime record
* FIRST (`registry.delete` → case 4 returns early, the entry stays enabled),
* then rebuild. We additionally clear `entry.fiber` ourselves so
* `entry.refresh()` re-imports and re-plugins through the Loader's own
* `_init` (entry-resolved config, automatic `fiber.entry` rebinding) instead
* of hand-rolling `registry.plugin`. Client entries have exactly one fiber
* per runtime, so `registry.delete` never collaterally disposes siblings.
*
* Self-reload: this plugin is itself a graph entry, so a rebuilt frame may
* name it. The in-flight reload keeps running in the old bundle's closure
* (its EventSource closes with the old fiber's effects); the new bundle's
* apply opens a fresh channel. Frames arriving during the gap are lost —
* acceptable for the dev channel, the next rebuild renotifies.
*
* Failure policy (v1): no rollback. An import failure leaves the entry
* fiberless (the next rebuilt frame retries from scratch); an apply failure
* leaves a FAILED fiber for the shell's status projection. Both log loudly.
*/
import type { Context } from 'cordis'
import type { Entry, Loader } from '@cordisjs/plugin-loader'
import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
/**
* Frames on the `GET /plugins/events` system SSE channel (owned host-side by
* dsh-host-webserver's PluginEventFrame). Mirrored here because this is a
* wire boundary: frames arrive as JSON text and are validated at the parse
* point, not shared as a same-process typed seam.
*/
export type PluginsEventFrame =
| { type: 'graph'; graph: WebBootGraph }
| { type: 'rebuilt'; id: string; rev: string }
/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
export const EVENTS_ENDPOINT = '/plugins/events'
/** Cordis plugin name. */
export const name = 'client-hmr'
/** Required services: the vendored Loader (entry governance) and the client module system (boot provide, service name `modules`). */
export const inject = ['loader', 'modules']
/** Find the loader entry whose module specifier is `id` (entry tree ids are random; the package name lives in `options.name`). */
function findEntry(loader: Loader, id: string): Entry | undefined {
for (const entry of loader.entries()) {
if (entry.options.name === id) return entry
}
return undefined
}
/** Remove every `<style data-plugin>` tag owned by `id` (attribute compared verbatim — no CSS-selector escaping pitfalls). */
function removeOwnedStyles(id: string): void {
for (const el of document.querySelectorAll('style[data-plugin]')) {
if (el.getAttribute('data-plugin') === id) el.remove()
}
}
/**
* Mount the HMR driver: subscribe to the system SSE channel and hot-swap
* rebuilt entries.
* @param ctx - plugin context with `loader` and `modules` available.
*/
export function apply(ctx: Context): void {
// Both are declared injections (typed Context merges: `modules` from the
// client module loader package, `loader` from the vendored Loader).
const modLoader = ctx.modules
const loader: Loader = ctx.loader
async function reload(id: string): Promise<void> {
const entry = findEntry(loader, id)
if (entry === undefined) {
ctx.logger.warn(`client-hmr: rebuilt frame for unknown entry "${id}" (not in the loader tree)`)
return
}
// Invalidate first (drop stale factory + record — a live factory makes
// prefetch a no-op and re-registration a loud duplicate), then run the
// async half while the old fiber still serves: fetch + execute registers
// the fresh factory with zero side effects (lazy CJS — module bodies run
// at materialization, not execution).
modLoader.invalidate(id)
await modLoader.prefetch(id)
const oldFiber = entry.fiber
if (oldFiber !== undefined) {
// Registry-first teardown (see module comment): the runtime record must
// be gone before the fiber's disposer emits internal/plugin, or the
// Loader flags the entry disabled.
const runtime = oldFiber.runtime
if (runtime !== null) entry.ctx.registry.delete(runtime.callback)
// Drain the unload: effect disposers (slots, subscriptions) must finish
// before the new bundle executes and the new apply re-registers.
while (oldFiber.inertia !== undefined) await oldFiber.inertia
delete entry.fiber
}
// Old owned styles go before materialization re-injects them (the CSS
// idempotency guard keys on stable tag ids).
removeOwnedStyles(id)
// Re-init through the entry: fiber cleared above, so refresh() re-imports
// — materializing the prefetched factory (CSS injects here) — and
// re-plugins under the entry context. Import failures are logged by
// Entry._init and leave the entry fiberless (retryable).
await entry.refresh()
// Surface apply failures loudly (v1: no rollback, FAILED state stays).
await entry.fiber?.await()
}
// Serialize reloads: frames can arrive faster than a swap completes, and
// interleaved dispose/execute chains would corrupt the single-slot handoff.
let queue: Promise<void> = Promise.resolve()
const handle = (frame: PluginsEventFrame): void => {
switch (frame.type) {
case 'rebuilt':
queue = queue.then(() => reload(frame.id)).catch((error: unknown) => {
ctx.logger.error(`client-hmr: reload of "${frame.id}" failed`)
ctx.logger.error(error)
})
break
case 'graph':
// Connect-time snapshot, unused in v1. The loader's cached graph rev
// goes stale after rebuilds — harmless, since prefetch hits the
// network anyway (host serves bundles no-cache); graph rev refresh
// lands with the reconnect-handshake mechanism.
break
default:
// Merge-extensible frame union: unknown frame types from newer hosts
// are ignored by design.
break
}
}
ctx.effect(() => {
const source = new EventSource(EVENTS_ENDPOINT)
source.addEventListener('message', (event: MessageEvent<string>) => {
let frame: PluginsEventFrame
try {
frame = JSON.parse(event.data) as PluginsEventFrame
} catch {
// Wire boundary: a malformed dev-channel frame is dropped loudly.
ctx.logger.warn(`client-hmr: unparseable event frame: ${event.data}`)
return
}
handle(frame)
})
return () => { source.close() }
}, 'client-hmr: event source')
}

View File

@@ -0,0 +1,9 @@
/**
* HMR plugin, node half. The package IS a dshClient plugin (dev-only row in
* the host graph): the reload driver lives in its client half in full
* (src/client/); the empty apply exists so the plugin appears in the host
* Loader (lifecycle governance + dshClient discovery).
*/
/** Host plugin body — no host-side behavior for the HMR plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,33 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-hmr`.
* @module @deepseek-ai/dsh-client-hmr/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr'
/** Cordis companion plugin name. */
export const name = 'client-hmr-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a dev-only reload driver — it consumes the loader
* entry tree and module cache but owns no events and no cross-plugin mutable
* state; reload correctness (dispose → style removal → re-execute ordering)
* is observable only through the assembled browser runtime, not a host-side
* event relation.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,14 @@
/**
* Node half of the HMR plugin: an empty apply placeholder (the reload driver
* lives in the client half) whose only contract is mounting and disposing
* cleanly in the host Loader.
*/
import { describe, expect, it } from 'vitest'
import { apply } from '@deepseek-ai/dsh-client-hmr'
describe('hmr node half', () => {
it('apply is a no-op host placeholder', () => {
apply()
expect(true).toBe(true) // reaching here without throw is the contract
})
})

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../modules"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-hmr', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -27,13 +27,9 @@
"platform": "web",
"immediately": true
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-web-react": "workspace:^"
"@deepseek-ai/dsh-client-runtime": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-modules
Client module system: the browser peer of Node's internal ESM loader, built as a lazy CJS table. The web shell mounts the vendored cordis Loader for entry governance (fiber lifecycle, inject waiting, update/refresh) and injects this package's `ClientModuleLoader` as its `internal` seam — the vendored side's only consumption point is `EntryTree.import`, so replacing `internal` replaces exactly "how plugin code arrives" and nothing else.
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → export surface, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id name the same surface (a plugin bundle IS its package's client half).
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
## Model Experience
None, as the module loader is browser-side kernel machinery; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (loadCache/edges/invalidate) is shaped for a general module graph so the externalization granularity can change without an interface change.
- **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record.

View File

@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-client-modules",
"description": "Client module loader: the browser peer of Node's internal ESM loader, consumed by the vendored cordis Loader as its internal seam (resolve/import/loadCache/invalidate over seed table, static registry and fetch bundles)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"license": "BSD-3-Clause",
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,175 @@
/**
* Client module system: the browser peer of Node's internal ESM loader, built
* as a lazy CJS table. The vendored cordis Loader consumes this object
* through its `internal` seam (the only call site is `EntryTree.import` →
* `internal.import`), which keeps entry governance (fiber lifecycle, inject
* waiting, update/refresh) entirely on the vendored side while this package
* owns code arrival.
*
* Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its
* factory (`window.__ModuleLoader__.load({id, factory})`); every module body
* side effect — including CSS injection — lives inside the factory closure
* and runs at materialization, not at script execution. Materialization
* (factory(require) → export surface) happens on first import/require and is
* memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
* another registered-but-unmaterialized module materializes it recursively,
* so load order needs no external sequencing.
*
* Resolution branch order (import): seed word → shell instance; memoized
* record → surface; static registry (shell-own modules, e.g. app-shell) →
* module; registered factory → materialize; graph row → fetch + execute +
* materialize; anything else → throw (loud — the runtime mirror of the
* build-time bundle purity gate). The synchronous `require` handed to
* factories walks the same order minus the fetch branch: fetching is async,
* so only already-executed bundles can be required — and cross-plugin value
* imports are a build error anyway.
* @module @deepseek-ai/dsh-client-modules
*/
import { ClientModuleLoaderImpl } from './loader.ts'
export { ClientModuleLoaderImpl }
declare module 'cordis' {
interface Context {
/** The client module system the web shell provides at boot (contract C5). */
modules: ClientModuleLoader
}
}
/**
* One composed client entry pushed by the host (web2 §0 graph row).
* `immediately` marks stage-one prefetch; `inject` is informational graph
* metadata (the authoritative edges live in each package's dshClient
* declaration and reach fibers through entry creation).
*
* Wire contract, held on both sides: the producing peer lives in
* `@deepseek-ai/dsh-host-webserver` (host packages keep zero workspace
* dependencies, so neither side imports the other's shape — drift between
* the two declarations is a bug against the web2 contract).
*/
export interface WebBootEntry {
/** Entry name == package name (or a shell-owned pseudo id, e.g. app-shell). */
id: string
/**
* Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. Absent only on
* shell-owned pseudo rows (app-shell) whose module is statically registered
* — a row that is neither fetchable nor static-registered fails loud.
*/
url?: string
/** Bundle content hash (cache-busting consistency anchor); absent with url. */
rev?: string
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
inject?: string[]
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
immediately?: boolean
}
/** The composed client entry graph the host injects as `window.__DSH_BOOT__` (dual-held wire contract — see {@link WebBootEntry}). */
export interface WebBootGraph {
/** Consistency anchor over the whole graph (content + bundle hashes). */
rev: string
/** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
entries: WebBootEntry[]
}
/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */
export interface ClientPluginHandoff {
/** Plugin id (package name) — the registration key; must match the graph row being executed. */
id: string
/**
* Closure factory holding the whole bundle body: receives the synchronous
* require bound to the module table and returns the bundle's export
* surface. Runs once, at materialization.
*/
factory: (require: (spec: string) => unknown) => Record<string, unknown>
}
/** Window surface this loader owns (bundle side of the handoff protocol) plus the host-injected graph. */
export interface DshWindow {
/** Host-composed entry graph, injected before the shell bundle runs. */
__DSH_BOOT__?: WebBootGraph
/** Bundle registration sink; installed once per page by {@link createClientModuleLoader} (contract C6). */
__ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
}
/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */
export interface ClientModuleRecord {
/** Module id (entry name / package name). */
id: string
/** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
surface: unknown
/** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
styles: string[]
/** Observed `require()` edges (module-graph seam; only table words can appear today). */
edges: Set<string>
}
/**
* The internal-seam subset the vendored Loader and the client HMR plugin
* consume. Mounted on `ctx.loader.internal` by the shell boot and provided
* as `ctx.modules` (contract C5).
*/
export interface ClientModuleLoader {
/** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
version: 'client'
/** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
loadCache: Map<string, ClientModuleRecord>
/**
* Internal seam consumed by the vendored Loader's `tree.import`. Resolves
* `specifier` through the branch order documented on the module, fetching
* and executing a bundle when needed.
* @param specifier - module specifier (entry name or table word).
* @param parentURL - importer URL (unused — the client module graph is flat).
* @param attrs - import attributes (unused; interface parity with Node's seam).
* @returns the module's export surface.
*/
import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
/**
* Register a shell-own module (app-shell — code that ships inside the shell
* bundle and never arrives as a plugin bundle).
* @param id - entry name (shell-owned pseudo id).
* @param module - the statically imported module namespace.
*/
registerStatic(id: string, module: unknown): void
/**
* Stage-one arrival: fetch the entry's bundle and execute it, registering
* its factory (no materialization — module side effects wait for import).
* No-op for static-registered ids and ids whose factory is already
* registered; concurrent calls share one in-flight task. To force a fresh
* fetch (HMR), {@link invalidate} first.
* @param id - graph entry name.
*/
prefetch(id: string): Promise<void>
/**
* Full reset of one module: drop its registered factory, its materialized
* record, and any consumed bundle text, so the next prefetch/import
* refetches and re-executes (the HMR invalidation hook).
* @param id - entry name to invalidate.
*/
invalidate(id: string): void
}
/** Options for {@link createClientModuleLoader} (assembled by the web shell at boot). */
export interface ClientModuleLoaderOptions {
/** Host-composed entry graph. */
graph: WebBootGraph
/** Module-table seed: platform-singleton specifier → shell instance. */
staticModules: Record<string, unknown>
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
fetchBundle?: (url: string) => Promise<string>
/**
* Bundle execution seam (synchronously performs the load() registration).
* Defaults to a <script> element carrying the code.
*/
executeBundle?: (code: string, url: string) => void
}
/**
* Build the client module system.
* @param options - entry graph, module-table staticModules, fetch/execute seams.
* @returns the loader the shell mounts as `ctx.loader.internal` and provides as `ctx.modules`.
*/
export function createClientModuleLoader(options: ClientModuleLoaderOptions): ClientModuleLoader {
return new ClientModuleLoaderImpl(options)
}

View File

@@ -0,0 +1,34 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-modules`.
* @module @deepseek-ai/dsh-client-modules/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-modules'
/** Cordis companion plugin name. */
export const name = 'client-modules-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the module loader is pre-plugin kernel machinery —
* it emits no cordis events (the vendored Loader owns entry lifecycle events)
* and its mutable state (loadCache, handoff slot) lives below the plugin
* layer where invariant observers cannot mount before it runs; resolve branch
* order and handoff discipline are asserted by the web boot specs against the
* real execution path.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,239 @@
/**
* ClientModuleLoaderImpl — the implementation behind the {@link ClientModuleLoader}
* seam. The conceptual contract (lazy CJS model, resolution branch order) is
* documented on the package module and the public interfaces in `./index.ts`;
* this file owns the state tables and the fetch/execute/materialize machinery.
*/
import type {
ClientModuleLoader, ClientModuleLoaderOptions, ClientModuleRecord,
ClientPluginHandoff, DshWindow, WebBootEntry,
} from './index.ts'
/** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
interface RegisteredFactory {
factory: ClientPluginHandoff['factory']
url: string
}
/** Default bundle fetch seam: same-origin fetch().text(). */
const defaultFetchBundle = async (url: string): Promise<string> => {
const res = await fetch(url)
if (!res.ok) throw new Error(`client-modules: bundle fetch ${url} answered ${String(res.status)}`)
return res.text()
}
/** Default bundle execution seam: a <script> element carrying the code. */
const defaultExecuteBundle = (code: string, url: string): void => {
const el = document.createElement('script')
// Inline execution (not src) so the fetch half stays parallelizable; the
// sourceURL comment keeps devtools stack frames attributed to the bundle.
el.textContent = `${code}\n//# sourceURL=${url}`
document.head.appendChild(el)
// Execution is synchronous for inline scripts: the factory is registered by
// now, so the node (and its source text) has no further job. Removing it
// keeps repeated HMR rebuilds from accumulating dead script nodes.
el.remove()
}
const urlOf = (row: WebBootEntry): string => {
// url is conditional on the wire (shell-own pseudo rows omit it); those
// ids resolve through the static registry and never reach a fetch.
if (row.url === undefined) throw new Error(`client-modules: entry "${row.id}" has no bundle url and no static registration`)
return row.url
}
/**
* A plugin bundle IS its package's client half: `<id>/client` (the exports
* subpath external bundles emit) and the bare graph id name the same
* surface, so table lookups normalize the suffix away.
*/
const stripClientSuffix = (spec: string): string =>
spec.endsWith('/client') ? spec.slice(0, -'/client'.length) : spec
/**
* Claim and inventory the <style> tags a factory injected during
* materialization: preset-emitted tags arrive pre-tagged with data-plugin;
* any untagged tag is claimed for the materializing plugin (HMR bookkeeping).
*/
const claimStyles = (id: string): string[] => {
if (typeof document === 'undefined') return []
for (const el of document.querySelectorAll('style:not([data-plugin])')) {
el.setAttribute('data-plugin', id)
}
const owned: string[] = []
for (const el of document.querySelectorAll(`style[data-plugin=${JSON.stringify(id)}]`)) {
owned.push(el.getAttribute('data-plugin-css') ?? id)
}
return owned
}
/**
* The client module system: state tables plus the arrival/materialization
* machinery implementing {@link ClientModuleLoader} (whose members carry the
* seam contract docs). Construction indexes the boot graph and installs the
* `window.__ModuleLoader__` registration sink (contract C6) — once per page.
*/
export class ClientModuleLoaderImpl implements ClientModuleLoader {
readonly version = 'client'
readonly loadCache = new Map<string, ClientModuleRecord>()
private readonly seed: Map<string, unknown>
private readonly statics = new Map<string, unknown>()
private readonly factories = new Map<string, RegisteredFactory>()
/** In-flight prefetch (fetch + execute) per id; concurrent callers share it. */
private readonly pendingArrival = new Map<string, Promise<void>>()
/** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
private readonly materializing = new Set<string>()
private readonly graphRows = new Map<string, WebBootEntry>()
// Execution URL of the bundle currently being executed (bound into the
// factory registration so diagnostics can name the source).
private executingUrl = ''
// Graph id of the row currently being executed ('' outside arrive):
// the load sink cross-checks the handoff id against it so a mis-stamped
// bundle cannot register under another entry's identity.
private executingId = ''
private readonly fetchBundle: (url: string) => Promise<string>
private readonly executeBundle: (code: string, url: string) => void
/**
* Build the module system over the host graph.
* @param options - entry graph, module-table staticModules, fetch/execute seams.
*/
constructor(options: ClientModuleLoaderOptions) {
this.seed = new Map(Object.entries(options.staticModules))
this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
this.executeBundle = options.executeBundle ?? defaultExecuteBundle
for (const entry of options.graph.entries) {
if (this.graphRows.has(entry.id)) throw new Error(`client-modules: duplicate graph entry "${entry.id}"`)
this.graphRows.set(entry.id, entry)
}
const win = globalThis as DshWindow
if (win.__ModuleLoader__ !== undefined) throw new Error('client-modules: window.__ModuleLoader__ already installed (double boot?)')
win.__ModuleLoader__ = {
load: (handoff: ClientPluginHandoff): void => {
// Registration is keyed by the handoff id; a duplicate means a bundle
// executed twice without an invalidate — always a bug, always loud.
if (this.factories.has(handoff.id)) throw new Error(`client-modules: duplicate factory registration for "${handoff.id}" (bundle executed twice without invalidate?)`)
// A fetched row's bundle must register the id its row names — a
// mis-stamped bundle registering under another entry's identity
// would let that entry silently materialize foreign exports.
if (this.executingId !== '' && handoff.id !== this.executingId) {
throw new Error(`client-modules: bundle ${this.executingUrl} registered "${handoff.id}" while arriving for "${this.executingId}" (mis-stamped bundle id)`)
}
this.factories.set(handoff.id, { factory: handoff.factory, url: this.executingUrl })
},
}
}
/** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
private arrive(row: WebBootEntry): Promise<void> {
const { id } = row
const pending = this.pendingArrival.get(id)
if (pending !== undefined) return pending
if (this.factories.has(id)) return Promise.resolve()
const task = (async (): Promise<void> => {
const url = urlOf(row)
const code = await this.fetchBundle(url)
this.executingUrl = url
this.executingId = id
try {
this.executeBundle(code, url)
} finally {
this.executingUrl = ''
this.executingId = ''
}
if (!this.factories.has(id)) {
throw new Error(`client-modules: bundle ${url} executed without registering "${id}" via __ModuleLoader__.load`)
}
})().finally(() => { this.pendingArrival.delete(id) })
this.pendingArrival.set(id, task)
return task
}
/** Materialize a registered factory (synchronous; memoized in loadCache). */
private materialize(id: string): ClientModuleRecord {
const existing = this.loadCache.get(id)
if (existing !== undefined) return existing
const registered = this.factories.get(id)
/* v8 ignore next -- callers check the factory branch before dispatching here. */
if (registered === undefined) throw new Error(`client-modules: no registered factory for "${id}"`)
if (this.materializing.has(id)) {
throw new Error(`client-modules: require cycle through "${id}" (factory-form CJS cannot deliver partial exports)`)
}
this.materializing.add(id)
try {
const edges = new Set<string>()
const surface = registered.factory(this.makeRequire(edges))
const record: ClientModuleRecord = { id, surface, styles: claimStyles(id), edges }
this.loadCache.set(id, record)
return record
} finally {
this.materializing.delete(id)
}
}
/**
* The synchronous require answered to factories: seed → static → memoized
* record → registered factory (recursive materialization — this is what
* makes load order self-resolving). Fetching is async and therefore
* unreachable from here; an unregistered plugin specifier is loud (and a
* cross-plugin value import is already a build error upstream).
*/
private makeRequire(edges: Set<string>): (spec: string) => unknown {
return (spec: string): unknown => {
edges.add(spec)
if (this.seed.has(spec)) return this.seed.get(spec)
if (this.statics.has(spec)) return this.statics.get(spec)
const id = stripClientSuffix(spec)
const record = this.loadCache.get(id)
if (record !== undefined) return record.surface
if (this.factories.has(id)) return this.materialize(id).surface
throw new Error(
`client-modules: require("${spec}") missed the module table — not a platform seed word, not a shell-own module, `
+ 'and no registered factory (a build-time externals drift, or a forbidden cross-plugin value import)',
)
}
}
async import(specifier: string): Promise<unknown> {
if (this.seed.has(specifier)) return this.seed.get(specifier)
const existing = this.loadCache.get(specifier)
if (existing !== undefined) return existing.surface
if (this.statics.has(specifier)) {
const surface = this.statics.get(specifier)
this.loadCache.set(specifier, { id: specifier, surface, styles: [], edges: new Set() })
return surface
}
if (!this.factories.has(specifier)) {
const row = this.graphRows.get(specifier)
if (row === undefined) {
throw new Error(
`client-modules: cannot resolve "${specifier}" — not a seed word, not a shell-own module, `
+ 'and not a row in the boot graph (the runtime mirror of the bundle purity gate)',
)
}
await this.arrive(row)
}
return this.materialize(specifier).surface
}
registerStatic(id: string, module: unknown): void {
if (this.statics.has(id)) throw new Error(`client-modules: shell-own module "${id}" registered twice`)
this.statics.set(id, module)
}
async prefetch(id: string): Promise<void> {
if (this.statics.has(id)) return
const row = this.graphRows.get(id)
if (row === undefined) throw new Error(`client-modules: prefetch("${id}") — not a graph entry`)
await this.arrive(row)
}
invalidate(id: string): void {
this.factories.delete(id)
this.loadCache.delete(id)
}
}

View File

@@ -0,0 +1,306 @@
// @vitest-environment jsdom
/**
* ClientModuleLoaderImpl behavior: lazy CJS arrival (bundle execution only
* registers the factory), materialization on first import/require with
* memoization and recursive self-sequencing, the resolution branch order,
* shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
* default transport seams, and the loud failure modes (duplicate
* registration, cycles, table misses, double boot).
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ClientModuleLoaderImpl, createClientModuleLoader,
type ClientModuleLoader, type ClientPluginHandoff, type DshWindow, type WebBootEntry,
} from '../src/index.ts'
const win = globalThis as DshWindow
type Factory = ClientPluginHandoff['factory']
afterEach(() => {
vi.unstubAllGlobals()
delete win.__ModuleLoader__
delete (document as unknown as Record<string, unknown>).__realmBridge
for (const el of document.querySelectorAll('style, script')) el.remove()
})
const row = (id: string): WebBootEntry => ({ id, url: `/plugins/${id}/client.js?rev=0` })
interface Bench {
loader: ClientModuleLoader
fetched: string[]
gates: Map<string, () => void>
}
/**
* Loader over scripted bundles: fetch resolves to the row url (optionally
* gated on a release callback); execute registers the scripted factory
* through the window sink (`null` scripts a bundle that never calls load).
*/
function bench(
entries: WebBootEntry[],
bundles: Record<string, Factory | null> = {},
opts: { seed?: Record<string, unknown>; gated?: string[] } = {},
): Bench {
const fetched: string[] = []
const gates = new Map<string, () => void>()
const loader = createClientModuleLoader({
graph: { rev: 'test', entries },
staticModules: opts.seed ?? {},
fetchBundle: (url) => {
fetched.push(url)
if (opts.gated?.includes(url) === true) {
return new Promise((resolve) => { gates.set(url, () => { resolve(url) }) })
}
return Promise.resolve(url)
},
executeBundle: (code) => {
const id = /\/plugins\/(.+)\/client\.js/.exec(code)?.[1]
const factory = id === undefined ? undefined : bundles[id]
if (factory == null || id === undefined) return
win.__ModuleLoader__?.load({ id, factory })
},
})
return { loader, fetched, gates }
}
describe('lazy CJS arrival', () => {
it('prefetch fetches and executes but does not run the factory', async () => {
const ran: string[] = []
const b = bench([row('a')], { a: () => { ran.push('a'); return {} } })
await b.loader.prefetch('a')
expect(b.fetched).toEqual(['/plugins/a/client.js?rev=0'])
expect(ran).toEqual([])
expect(b.loader.loadCache.size).toBe(0)
})
it('import materializes once and memoizes the export surface', async () => {
const ran: string[] = []
const b = bench([row('a')], { a: () => { ran.push('a'); return { marker: 'a' } } })
const first = await b.loader.import('a', '', {})
const second = await b.loader.import('a', '', {})
expect(first).toBe(second)
expect((first as { marker: string }).marker).toBe('a')
expect(ran).toEqual(['a'])
expect(b.loader.loadCache.get('a')?.id).toBe('a')
})
it('import without prefetch fetches, executes, and materializes in one call', async () => {
const b = bench([row('a')], { a: () => ({ marker: 'direct' }) })
const surface = await b.loader.import('a', '', {})
expect((surface as { marker: string }).marker).toBe('direct')
expect(b.fetched).toHaveLength(1)
})
it('concurrent callers share one in-flight arrival and materialize once', async () => {
const ran: string[] = []
const url = '/plugins/a/client.js?rev=0'
const b = bench([row('a')], { a: () => { ran.push('a'); return { marker: 'a' } } }, { gated: [url] })
const first = b.loader.import('a', '', {})
const second = b.loader.import('a', '', {})
const third = b.loader.prefetch('a')
b.gates.get(url)?.()
const [s1, s2] = await Promise.all([first, second, third])
expect(s1).toBe(s2)
expect(b.fetched).toEqual([url])
expect(ran).toEqual(['a'])
})
it('prefetch after registration is a no-op without invalidate', async () => {
const b = bench([row('a')], { a: () => ({}) })
await b.loader.prefetch('a')
await b.loader.prefetch('a')
expect(b.fetched).toHaveLength(1)
})
})
describe('require resolution', () => {
it('a factory requiring a registered-but-unmaterialized module materializes it recursively', async () => {
const order: string[] = []
const b = bench([row('a'), row('b')], {
a: (req) => {
order.push('a')
const dep = req('b/client') as { helper: string }
return { got: dep.helper }
},
b: () => { order.push('b'); return { helper: 'from-b' } },
})
await b.loader.prefetch('a')
await b.loader.prefetch('b')
const surface = await b.loader.import('a', '', {})
expect((surface as { got: string }).got).toBe('from-b')
expect(order).toEqual(['a', 'b'])
expect(b.loader.loadCache.get('a')?.edges.has('b/client')).toBe(true)
expect(b.loader.loadCache.has('b')).toBe(true)
})
it('require prefers the platform seed word over the module table', async () => {
const react = { marker: 'react' }
const b = bench([row('a')], {
a: req => ({ dep: req('react') }),
}, { seed: { react } })
const surface = await b.loader.import('a', '', {})
expect((surface as { dep: unknown }).dep).toBe(react)
expect(await b.loader.import('react', '', {})).toBe(react)
expect(b.loader.loadCache.has('react')).toBe(false)
})
it('require answers an already-materialized module from the cache', async () => {
let built = 0
const b = bench([row('a'), row('c')], {
a: req => ({ dep: req('c') }),
c: () => { built += 1; return { marker: 'c' } },
})
const c = await b.loader.import('c', '', {})
const a = await b.loader.import('a', '', {})
expect((a as { dep: unknown }).dep).toBe(c)
expect(built).toBe(1)
})
it('a require that misses the module table is loud', async () => {
const b = bench([row('a')], { a: req => ({ dep: req('ghost') }) })
await expect(b.loader.import('a', '', {})).rejects.toThrow('require("ghost") missed the module table')
})
it('a require cycle is fatal', async () => {
const b = bench([row('a'), row('b')], {
a: req => ({ dep: req('b') }),
b: req => ({ dep: req('a') }),
})
await b.loader.prefetch('b')
await expect(b.loader.import('a', '', {})).rejects.toThrow('require cycle through "a"')
})
})
describe('static registry', () => {
it('serves shell-own modules to import and require without any fetch', async () => {
const shell = { marker: 'app-shell' }
const b = bench([row('a'), { id: 'app-shell' }], {
a: req => ({ dep: req('app-shell') }),
})
b.loader.registerStatic('app-shell', shell)
await b.loader.prefetch('app-shell')
expect(await b.loader.import('app-shell', '', {})).toBe(shell)
expect(b.loader.loadCache.get('app-shell')?.styles).toEqual([])
expect((await b.loader.import('a', '', {}) as { dep: unknown }).dep).toBe(shell)
expect(b.fetched).toEqual(['/plugins/a/client.js?rev=0'])
})
it('duplicate static registration is loud', () => {
const b = bench([])
b.loader.registerStatic('app-shell', {})
expect(() => { b.loader.registerStatic('app-shell', {}) }).toThrow('registered twice')
})
})
describe('failure modes', () => {
it('duplicate factory registration is loud', () => {
bench([])
win.__ModuleLoader__?.load({ id: 'x', factory: () => ({}) })
expect(() => win.__ModuleLoader__?.load({ id: 'x', factory: () => ({}) }))
.toThrow('duplicate factory registration for "x"')
})
it('a bundle that never registers its id is loud', async () => {
const b = bench([row('a')], { a: null })
await expect(b.loader.import('a', '', {})).rejects.toThrow('without registering "a"')
})
it('an unknown import specifier is loud', async () => {
const b = bench([])
await expect(b.loader.import('nope', '', {})).rejects.toThrow('cannot resolve "nope"')
})
it('an unknown prefetch id is loud', async () => {
const b = bench([])
await expect(b.loader.prefetch('nope')).rejects.toThrow('prefetch("nope") — not a graph entry')
})
it('a graph row with no url and no static registration is loud', async () => {
const b = bench([{ id: 'ghost' }])
await expect(b.loader.import('ghost', '', {})).rejects.toThrow('no bundle url and no static registration')
})
it('a duplicate graph entry is loud at construction', () => {
expect(() => bench([row('a'), row('a')])).toThrow('duplicate graph entry "a"')
})
it('double boot is loud', () => {
bench([])
expect(() => new ClientModuleLoaderImpl({ graph: { rev: 't', entries: [] }, staticModules: {} }))
.toThrow('already installed (double boot?)')
})
})
describe('HMR reset', () => {
it('invalidate drops the factory and record so the module refetches and re-registers', async () => {
let generation = 0
const b = bench([row('a')], { a: () => ({ generation: ++generation }) })
const first = await b.loader.import('a', '', {})
b.loader.invalidate('a')
expect(b.loader.loadCache.has('a')).toBe(false)
await b.loader.prefetch('a')
const second = await b.loader.import('a', '', {})
expect(b.fetched).toHaveLength(2)
expect((first as { generation: number }).generation).toBe(1)
expect((second as { generation: number }).generation).toBe(2)
})
})
describe('style claiming', () => {
it('claims untagged style tags for the materializing plugin and inventories owned css ids', async () => {
const foreign = document.createElement('style')
foreign.setAttribute('data-plugin', 'other')
document.head.appendChild(foreign)
const b = bench([row('a')], {
a: () => {
document.head.appendChild(document.createElement('style'))
const tagged = document.createElement('style')
tagged.setAttribute('data-plugin', 'a')
tagged.setAttribute('data-plugin-css', 'sheet-1')
document.head.appendChild(tagged)
return {}
},
})
await b.loader.import('a', '', {})
expect(b.loader.loadCache.get('a')?.styles).toEqual(['a', 'sheet-1'])
expect(document.querySelectorAll('style[data-plugin="a"]')).toHaveLength(2)
expect(foreign.getAttribute('data-plugin')).toBe('other')
})
it('materialization without a document skips the style inventory', async () => {
const b = bench([row('a')], { a: () => ({}) })
vi.stubGlobal('document', undefined)
try {
await b.loader.import('a', '', {})
} finally {
vi.unstubAllGlobals()
}
expect(b.loader.loadCache.get('a')?.styles).toEqual([])
})
})
describe('default transport seams', () => {
it('fetches same-origin and executes through an inline script tag', async () => {
// In a browser the loader's globalThis IS the page window; vitest's jsdom
// evaluates <script> in a separate realm that shares only the document,
// so the fixture bundle restores the sink from a document bridge before
// using the normal calling convention.
const code = 'window.__ModuleLoader__ = document.__realmBridge;\n'
+ 'window.__ModuleLoader__.load({ id: "dee", factory: function () { return { marker: "via-script" } } })'
vi.stubGlobal('fetch', async () => ({ ok: true, text: async () => code }))
const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
;(document as unknown as Record<string, unknown>).__realmBridge = win.__ModuleLoader__
const surface = await loader.import('dee', '', {})
expect((surface as { marker: string }).marker).toBe('via-script')
// The script node is removed right after its synchronous execution —
// repeated HMR rebuilds must not accumulate dead script nodes.
expect([...document.querySelectorAll('script')]).toEqual([])
})
it('a non-ok bundle response is loud with the status', async () => {
vi.stubGlobal('fetch', async () => ({ ok: false, status: 404 }))
const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
await expect(loader.prefetch('dee')).rejects.toThrow('answered 404')
})
})

View File

@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -1,6 +1,10 @@
# @deepseek-ai/dsh-client-runtime
Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), Session object layer, ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
## Session title projection
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title.
## Model Experience
@@ -13,6 +17,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
- **Scope teardown is watch-approximated** — the most recently resolved binding stands in for "who is watching"; a removed-while-watched session's scope survives until the watch moves away, not until true observer count reaches zero.
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`cell()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land.
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
- **`SessionSummary.title` is a display projection** — the wire summary carries no title yet; the cwd basename stands in, then the raw id.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-runtime",
"description": "Client cordis boot and core services: SlotsService, SessionsService (scope tree + object layer), ClientLoader",
"description": "Client core services: SlotsService, SessionsService (scope tree + object layer)",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -15,10 +15,6 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./loader": {
"types": "./lib/types/client/loader/index.d.ts",
"default": "./lib/loader.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
@@ -37,10 +33,12 @@
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "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",
@@ -55,7 +53,6 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/loader.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"

View File

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

View File

@@ -1,32 +1,43 @@
/**
* 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
* 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).
* SlotsService (declaration ledger + renderer seam + store axis, built-in
* 'root'), SessionsService (list store + current selection + scope tree +
* object layer), and the cordis Context/Events merges. apply mounts
* ctx.slots + ctx.sessions and wires the connection stream loop into the
* object layer. A static-arrival entry: the web shell bundles this module
* and mounts it through the host graph (module loading lives in
* @deepseek-ai/dsh-client-modules, entry governance in the vendored Loader).
*/
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 { SlotsService } from './slots.ts'
import { SessionsService } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
export { SlotsService } from './slots.ts'
// RootOwnerProps rides the 'root' SlotMap row (both migrated here from
// ui-layout: the framework slot is declared by the framework package).
export type { RootOwnerProps } from './slots.ts'
export { SessionsService, scopeOf } from './sessions/service.ts'
export type { Session } from './sessions/session.ts'
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
export { SessionManager } from './sessions/manager.ts'
export type { SessionListSnapshot } from './sessions/manager.ts'
export { Session, PAGE_MESSAGES } from './sessions/session.ts'
export type { SessionListEntry } from './sessions/lineage.ts'
// The snapshot-store engine lives here since the store migration (the data
// layer owns its substrate; web-react is React glue only). The './client'
// main export is the single serving door — no store subpath.
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
OpenState, PartialAssistant, PendingInteraction, PromptError, RunningToolCall, SteeringMessageNode,
RunningToolCall, SteeringMessageNode,
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
// PendingWait is a value export: tests construct fixture waits directly.
export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
// ---- Narrowed aliases (the single narrowing point of the slot type chain:
@@ -41,11 +52,8 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
*/
export type ClientContext = Context
/** SessionBinding narrowed to the client context (inject factories dot services directly). */
export type ClientSessionBinding = GenericSessionBinding<ClientContext>
/** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */
export type UseConversationSession = UseSession<ConversationSnapshot>
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
/**
* One tool call as the chat flow renders it: still-running (spinner card) or
@@ -54,6 +62,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 {
/**
@@ -66,48 +93,9 @@ declare module 'cordis' {
interface Context {
slots: import('./slots.ts').SlotsService
sessions: import('./sessions/service.ts').SessionsService
loader: ClientLoader
}
}
/** One __DSH_BOOT__ manifest row. */
export interface BootPluginEntry { id: string; url: string; inject: string[]; immediately?: boolean }
/** Per-plugin load status store shape. */
export type LoaderStatus = Record<string, 'loading' | 'active' | 'failed'>
/**
* Client bundle loader. The immediately group loads first (parallel fetch,
* apply in inject topology order); remaining plugins follow in inject
* topology. Loaded bundle export surfaces are registered back into the
* require module table. Implementation lives in the `./loader` subpath
* (shell-held machinery).
*/
export interface ClientLoader {
/** Start loading from window.__DSH_BOOT__ (non-blocking). */
start(): void
/**
* Load one plugin bundle (script inject, factory handoff, ctx.plugin, style registration).
* @param id - plugin id (package name).
*/
load(id: string): Promise<void>
/**
* Unload a plugin. P-I: not implemented (full chain lands with HMR).
* @param id - plugin id.
*/
unload(id: string): Promise<void>
/** Resolves when every manifest plugin reached active (AppRoot gates the real UI on this). */
settled(): Promise<void>
/**
* Read a loaded module's export surface from the module table (same
* implementation the bundle-facing require uses; unknown spec throws).
* @param spec - module specifier (package name or seeded library id).
*/
requireModule(spec: string): unknown
/** Per-plugin status store. */
readonly status: SnapshotStore<LoaderStatus>
}
/** Required services: the wire handle mounted by the connection plugin. */
export const inject = ['connection']

View File

@@ -1,247 +0,0 @@
/**
* ClientLoader implementation (shell-held machinery — the loader cannot load
* itself, so the web shell imports this subpath statically and mounts the
* instance as ctx.loader; the runtime package's own client bundle never
* includes it).
*
* Load chain per plugin: fetch bundle text → execute (script injection) → the
* bundle calls window.DSHClientProxy.loadPlugin({id, factory}) (single-slot
* handoff, id reconciled) → factory(require) with require bound to the module
* table → ctx.plugin(exports.apply) → the export surface is registered into
* the module table under the plugin id (inject topology guarantees later
* loaders can require earlier ones) → <style data-plugin> ownership recorded.
*
* start(): the `immediately` group is fetched in parallel and executed in
* group-internal inject topology (execution is serial — the handoff slot is
* single); a full-group barrier precedes the remaining plugins, which then
* load one by one in inject topology.
*/
import type { Context } from 'cordis'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
/** The shape a client bundle hands to window.DSHClientProxy.loadPlugin. */
export interface ClientPluginHandoff {
/** Plugin id (package name) — must match the manifest row being loaded. */
id: string
/**
* Closure factory: receives the DI require and returns the module's export
* surface; an `apply` export is applied as a cordis plugin.
*/
factory: (require: (spec: string) => unknown) => Record<string, unknown>
}
/** Window surface the loader owns (bundle side of the handoff protocol). */
interface DshWindow {
__DSH_BOOT__?: { plugins: BootPluginEntry[] }
DSHClientProxy?: { loadPlugin(handoff: ClientPluginHandoff): void }
}
/** Options for createClientLoader (assembled by the web shell at boot). */
export interface ClientLoaderOptions {
/** Client root context: plugin applies mount under it. */
ctx: Context
/**
* Seeded module table: pure-library entities (react, react-dom, cordis,
* ui-slots, web-react, ui-primitives). The loader takes ownership and
* registers loaded bundle export surfaces alongside them.
*/
modules: Record<string, unknown>
/**
* Boot manifest; defaults to window.__DSH_BOOT__. Fixture pages inject the
* same protocol shape.
*/
boot?: { plugins: BootPluginEntry[] }
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
fetchBundle?: (url: string) => Promise<string>
/**
* Bundle execution seam (serial half; execution synchronously performs the
* loadPlugin handoff). Defaults to a <script> element carrying the code.
*/
executeBundle?: (code: string, url: string) => void
}
/** Per-plugin bookkeeping across the load chain. */
interface PluginRecord {
entry: BootPluginEntry
state: 'idle' | 'loading' | 'active' | 'failed'
fetch?: Promise<string>
load?: Promise<void>
}
const NOT_LOADED = Symbol('dsh.loader.not-loaded')
/**
* Build the client bundle loader.
* @param options - ctx, seeded module table, boot manifest, fetch/execute seams.
* @returns the ClientLoader the shell mounts as ctx.loader.
*/
export function createClientLoader(options: ClientLoaderOptions): ClientLoader {
const { ctx } = options
const win = globalThis as DshWindow
const boot = options.boot ?? win.__DSH_BOOT__
if (boot === undefined) throw new Error('client-loader: no boot manifest (window.__DSH_BOOT__ missing)')
const modules = new Map<string, unknown>(Object.entries(options.modules))
const records = new Map<string, PluginRecord>()
for (const entry of boot.plugins) {
if (records.has(entry.id)) throw new Error(`client-loader: duplicate manifest id "${entry.id}"`)
records.set(entry.id, { entry, state: 'idle' })
}
const status = createSnapshotStore<LoaderStatus>({})
const publish = (id: string, state: 'loading' | 'active' | 'failed'): void => {
status.update((draft) => { draft[id] = state })
}
// Single-slot handoff: bundle execution synchronously calls loadPlugin;
// doLoad arms the slot before executing and reconciles the id after.
let slot: ClientPluginHandoff | typeof NOT_LOADED = NOT_LOADED
if (win.DSHClientProxy !== undefined) throw new Error('client-loader: window.DSHClientProxy already installed (double boot?)')
win.DSHClientProxy = {
loadPlugin: (handoff: ClientPluginHandoff): void => {
if (slot !== NOT_LOADED) {
throw new Error(`client-loader: overlapping loadPlugin handoff (got "${handoff.id}" while a previous handoff is unclaimed)`)
}
slot = handoff
},
}
const fetchBundle = options.fetchBundle ?? (async (url: string): Promise<string> => {
const res = await fetch(url)
if (!res.ok) throw new Error(`client-loader: bundle fetch ${url} answered ${String(res.status)}`)
return res.text()
})
const executeBundle = options.executeBundle ?? ((code: string, url: string): void => {
const el = document.createElement('script')
// Inline execution (not src) so the fetch half stays parallelizable; the
// sourceURL comment keeps devtools stack frames attributed to the bundle.
el.textContent = `${code}\n//# sourceURL=${url}`
document.head.appendChild(el)
})
const requireModule = (spec: string): unknown => {
if (!modules.has(spec)) {
throw new Error(`client-loader: module "${spec}" is not available — not a seeded library and no loaded plugin registered it (check dshClient.inject ordering)`)
}
return modules.get(spec)
}
/** Tag styles the bundle injected during execution (unload bookkeeping; plugin CSS lands untagged). */
const claimStyles = (id: string): void => {
if (typeof document === 'undefined') return
for (const el of document.querySelectorAll('style:not([data-plugin])')) {
el.setAttribute('data-plugin', id)
}
}
/** Start (or reuse) the parallelizable fetch half. */
const prefetch = (record: PluginRecord): Promise<string> =>
(record.fetch ??= fetchBundle(record.entry.url))
async function doLoad(record: PluginRecord): Promise<void> {
const { id } = record.entry
record.state = 'loading'
publish(id, 'loading')
try {
// Dependencies must already be active (start() sequences this; direct
// load() callers get the same fail-loud check).
for (const dep of record.entry.inject) {
const depRecord = records.get(dep)
if (depRecord === undefined) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
if (depRecord.state !== 'active') throw new Error(`client-loader: "${id}" loaded before its dependency "${dep}" is active`)
}
const code = await prefetch(record)
executeBundle(code, record.entry.url)
if (slot === NOT_LOADED) throw new Error(`client-loader: bundle ${record.entry.url} executed without calling DSHClientProxy.loadPlugin`)
const handoff = slot
slot = NOT_LOADED
if (handoff.id !== id) throw new Error(`client-loader: bundle id mismatch — manifest "${id}" vs handoff "${handoff.id}"`)
const exports = handoff.factory(requireModule)
if (typeof exports.apply !== 'function') throw new Error(`client-loader: plugin "${id}" exports no apply function`)
// The whole export surface is the plugin: cordis object-plugin form
// keeps the bundle's exported `inject`/`name` (an apply-only pass would
// silently drop the dependency declaration — postmortem 0001).
const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void })
await fiber.await()
// Register under both specifier forms bundles emit: the bare package
// name (deep-import rewrites) and the /client subpath (CLIENT_EXTERNALS
// form) — the loaded surface IS the client half either way.
modules.set(id, exports)
modules.set(`${id}/client`, exports)
claimStyles(id)
record.state = 'active'
publish(id, 'active')
} catch (error) {
record.state = 'failed'
publish(id, 'failed')
throw error
}
}
const load = (id: string): Promise<void> => {
const record = records.get(id)
if (record === undefined) return Promise.reject(new Error(`client-loader: unknown plugin "${id}"`))
record.load ??= doLoad(record)
return record.load
}
/** Topologically order `ids` by inject (edges inside the set only — an early-group member never waits on a later-group one). */
const topo = (ids: string[]): string[] => {
const pool = new Set(ids)
const ordered: string[] = []
const done = new Set<string>()
const visiting = new Set<string>()
const visit = (id: string): void => {
if (done.has(id)) return
if (visiting.has(id)) throw new Error(`client-loader: inject cycle through "${id}"`)
visiting.add(id)
const record = records.get(id)
/* v8 ignore next -- ids come from records; unknown ids are caught per-dep below. */
if (record === undefined) throw new Error(`client-loader: manifest references unknown plugin "${id}"`)
for (const dep of record.entry.inject) {
if (!records.has(dep)) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
if (pool.has(dep)) visit(dep)
}
visiting.delete(id)
done.add(id)
ordered.push(id)
}
for (const id of ids) visit(id)
return ordered
}
let settledPromise: Promise<void> | undefined
async function run(): Promise<void> {
const all = [...records.values()]
const early = all.filter(r => r.entry.immediately === true)
const rest = all.filter(r => r.entry.immediately !== true)
// Early group: parallel fetch (all requests in flight at once), serial
// inject-topology execution, full-group barrier before anything else.
const earlyOrder = topo(early.map(r => r.entry.id))
for (const record of early) void prefetch(record).catch(() => {}) // surfaced by the awaited load below
for (const id of earlyOrder) await load(id)
// Remaining plugins: one by one in inject topology.
for (const id of topo(rest.map(r => r.entry.id))) await load(id)
}
return {
start: () => {
settledPromise ??= run()
// Failures surface through settled()/status — start() itself is fire-and-forget.
settledPromise.catch(() => {})
},
load,
unload: (id: string) => Promise.reject(new Error(`client-loader: unload("${id}") is not implemented (lands with HMR)`)),
settled: () => {
if (settledPromise === undefined) throw new Error('client-loader: settled() before start()')
return settledPromise
},
requireModule,
status,
}
}

View File

@@ -4,7 +4,8 @@
// string here (narrow to real brands when convenient).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { RpcError, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
/** Assistant content blocks sorted by what the UI cares about
* (text body / collapsible reasoning / tool-call card head / other fallback). */
@@ -41,6 +42,8 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock {
export interface UserMessageNode {
kind: 'user'
seq: number
/** Unix epoch ms from the source session event. */
time: number
content: readonly ContentBlock[]
source: unknown
}
@@ -49,6 +52,8 @@ export interface UserMessageNode {
export interface AssistantMessageNode {
kind: 'assistant'
seq: number
/** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */
time: number
turn: number
step: number
blocks: readonly AssistantBlock[]
@@ -62,6 +67,8 @@ export interface AssistantMessageNode {
export interface SteeringMessageNode {
kind: 'steering'
seq: number
/** Unix epoch ms from the source session event. */
time: number
turn: number
content: readonly ContentBlock[]
source: unknown
@@ -71,6 +78,8 @@ export interface SteeringMessageNode {
export interface ContextMessageNode {
kind: 'context'
seq: number
/** Unix epoch ms from the source session event. */
time: number
content: readonly ContentBlock[]
source: unknown
meta?: unknown
@@ -80,9 +89,13 @@ export interface ContextMessageNode {
export interface ToolResultNode {
kind: 'tool-result'
seq: number
/** Unix epoch ms from the tool/result session event. */
time: number
callId: string
/** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */
call: { name: string; argsRaw: string } | null
/** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */
callTime: number | null
content: readonly ContentBlock[]
isError: boolean
error?: { name: string; code: string }
@@ -97,6 +110,8 @@ export interface ToolResultNode {
export interface UnknownSurfaceNode {
kind: 'unknown'
seq: number
/** Unix epoch ms from the source session event when known. */
time: number
type: string
data: unknown
}
@@ -117,15 +132,12 @@ export interface RunningToolCall {
argsRaw: string
turn: number
step: number
/** Unix epoch ms when the tool/call event was logged. */
time: number
/** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
callView: ToolCallView | null
}
/** Approval/question placeholder cards (visible, not answerable;
* rpcId = the requested frame's envelope id, the future respond backfill key). */
export type PendingInteraction =
| { kind: 'approval'; rpcId: RpcId; approvalId: string; toolName: string; callId?: string; reason?: string }
| { kind: 'question'; rpcId: RpcId; questions: readonly unknown[] }
/** In-progress assistant output (chunk accumulator product). */
export interface PartialAssistant {

View File

@@ -18,6 +18,8 @@ export interface CallIndexEntry {
argsRaw: string
turn: number
step: number
/** Unix epoch ms of the tool/call event. */
time: number
/** Wire view riding the tool/call (envelope-level; never inside the event). */
callView: ToolCallView | null
}
@@ -38,24 +40,37 @@ function materializeNode(
): ConversationNode {
switch (event.type) {
case 'user/message':
return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source }
// Injected context (plugin/goal source) folds to a context node, not a
// user message; only a direct human prompt is a user node.
if (event.data.source.kind !== 'user') {
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
meta: event.data.meta,
}
}
return {
kind: 'user', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
case 'assistant/message':
return {
kind: 'assistant', seq: event.seq, turn: event.data.turn, step: event.data.step,
kind: 'assistant', seq: event.seq, time: event.time,
turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
}
case 'steering/message':
return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source }
case 'context/message':
return {
kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source,
meta: event.data.meta,
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.content, source: event.data.source,
}
case 'tool/result': {
const call = callIndex.get(String(event.data.callId))
return {
kind: 'tool-result', seq: event.seq, callId: String(event.data.callId),
kind: 'tool-result', seq: event.seq, time: event.time,
callId: String(event.data.callId),
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
callTime: call?.time ?? null,
content: event.data.content, isError: event.data.isError,
...(event.data.error !== undefined ? { error: event.data.error } : {}),
meta: event.data.meta,
@@ -63,11 +78,14 @@ function materializeNode(
resultView,
}
}
/* v8 ignore next 2 -- defensive arm: fold output only carries the five
/* v8 ignore next 2 -- defensive arm: fold output only carries the four
surface-eligible types, and each has a case above; reachable only if core
adds an eligible type. */
default:
return { kind: 'unknown', seq: event.seq, type: event.type, data: (event as { data?: unknown }).data }
return {
kind: 'unknown', seq: event.seq, time: event.time,
type: event.type, data: (event as { data?: unknown }).data,
}
}
}
@@ -186,6 +204,7 @@ export class FoldAdapter {
if (event.type !== 'tool/call') return
this.callIdx.set(String(event.data.callId), {
name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step,
time: event.time,
callView: view?.for === 'call' ? view.view : null,
})
// No backfill into already-materialized tool-result nodes for this callId

View File

@@ -4,9 +4,15 @@
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
/** Host list summary enriched with the latest mux-projected durable title. */
export interface TitledSessionSummary extends SessionSummary {
title?: string
}
/** One flattened session-list row (summary + lineage indent depth). */
export interface SessionListEntry {
sessionId: SessionId
title?: string
updatedAt: number
running: boolean
parentSessionId?: SessionId
@@ -21,12 +27,12 @@ export interface SessionListEntry {
* @param summaries - the host's session.list items.
* @returns display rows in render order.
*/
export function flattenLineage(summaries: readonly SessionSummary[]): SessionListEntry[] {
const byId = new Map<SessionId, SessionSummary>()
export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] {
const byId = new Map<SessionId, TitledSessionSummary>()
for (const s of summaries) byId.set(s.sessionId, s)
const children = new Map<SessionId, SessionSummary[]>()
const roots: SessionSummary[] = []
const children = new Map<SessionId, TitledSessionSummary[]>()
const roots: TitledSessionSummary[] = []
for (const s of summaries) {
if (s.parentSessionId !== undefined && byId.has(s.parentSessionId)) {
const list = children.get(s.parentSessionId) ?? []
@@ -37,12 +43,12 @@ export function flattenLineage(summaries: readonly SessionSummary[]): SessionLis
}
}
const byUpdatedDesc = (a: SessionSummary, b: SessionSummary): number => b.updatedAt - a.updatedAt
const byUpdatedDesc = (a: TitledSessionSummary, b: TitledSessionSummary): number => b.updatedAt - a.updatedAt
roots.sort(byUpdatedDesc)
const out: SessionListEntry[] = []
const visited = new Set<SessionId>()
const walk = (s: SessionSummary, depth: number): void => {
const walk = (s: TitledSessionSummary, depth: number): void => {
if (visited.has(s.sessionId)) {
console.warn(`[web-runtime] lineage cycle at ${s.sessionId}; emitting as root`)
return

View File

@@ -3,8 +3,10 @@
// List data never enters zustand; React connects via subscribe/getListSnapshot.
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionListEntry } from './lineage.ts'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
import { flattenLineage } from './lineage.ts'
import { Notifier } from './notifier.ts'
import { Session } from './session.ts'
@@ -19,6 +21,13 @@ export interface SessionListSnapshot {
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
const PENDING_BUFFER_CAP = 32
/** Latest title control snapshot retained independently of list/instance arrival. */
interface SessionTitleSnapshot {
title: string
eventSeq: number
updatedAt: number
}
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
export class SessionManager {
private readonly sessions = new Map<SessionId, Session>()
@@ -27,6 +36,7 @@ export class SessionManager {
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
private readonly titleSnapshots = new Map<SessionId, SessionTitleSnapshot>()
private summaries: SessionSummary[] = []
private listState: 'idle' | 'loading' | 'error' = 'idle'
private listError: RpcError | null = null
@@ -158,6 +168,24 @@ export class SessionManager {
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
const frame = envelope.payload
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
if (frame.type === 'session/title') {
const current = this.titleSnapshots.get(frame.sessionId)
if (current !== undefined && current.eventSeq >= frame.eventSeq) return
this.titleSnapshots.set(frame.sessionId, {
title: frame.title,
eventSeq: frame.eventSeq,
updatedAt: frame.updatedAt,
})
this.notifier.markDirty()
return
}
if (frame.type === 'session/subscribed') {
const current = this.titleSnapshots.get(frame.sessionId)
if (current !== undefined && current.eventSeq > frame.lastSeq) {
this.titleSnapshots.delete(frame.sessionId)
this.notifier.markDirty()
}
}
const session = this.sessions.get(frame.sessionId)
if (session === undefined) {
// Approval/question frames never hit history: buffer for replay on instantiation;
@@ -204,6 +232,7 @@ export class SessionManager {
this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId)
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.titleSnapshots.delete(frame.sessionId)
this.notifier.markDirty()
return
}
@@ -230,12 +259,19 @@ export class SessionManager {
}
private buildListSnapshot(): SessionListSnapshot {
const fresh = flattenLineage(this.summaries)
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
const title = this.titleSnapshots.get(summary.sessionId)
return title === undefined
? summary
: { ...summary, title: title.title, updatedAt: Math.max(summary.updatedAt, title.updatedAt) }
})
const fresh = flattenLineage(merged)
const items = fresh.map((entry) => {
const prev = this.entryCache.get(entry.sessionId)
if (
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.depth === entry.depth
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.title === entry.title && prev.depth === entry.depth
) return prev
this.entryCache.set(entry.sessionId, entry)
return entry

View File

@@ -0,0 +1,79 @@
// PendingWait: the carrier-protocol half of a pending host interaction. The runtime owns only
// envelope knowledge (rpcId backfill into a client-response); domain result encoding belongs to
// the interaction's consumer package.
import type {
ClientResponse, MuxFrame, RpcId, RpcReceipt, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
/** Kind-keyed payload map: the requested frame's domain fields (envelope fields stripped). */
export interface PendingPayloads {
approval: Omit<Extract<MuxFrame, { type: 'approval/requested' }>, 'type' | 'sessionId'>
question: Omit<Extract<MuxFrame, { type: 'question/requested' }>, 'type' | 'sessionId'>
}
/** Pending-interaction discriminant (the keys of PendingPayloads). */
export type PendingKind = keyof PendingPayloads
/** Kind-discriminated union of concrete waits: narrowing on `kind` types `payload`. */
export type PendingInteraction = { [K in PendingKind]: PendingWait<K> }[PendingKind]
/** Key prefixes, one per kind (the key doubles as the Session pending-map key). */
const KEY_PREFIX: Record<PendingKind, string> = { approval: 'a', question: 'q' }
/**
* One pending host-owned interaction wait: an immutable render face
* (kind/key/sessionId/payload) plus the response carrier. respond() backfills
* the requested frame's rpcId into a client-response envelope — no consumer
* ever sees the raw rpcId. Settlement is expressed only by pending-list
* membership (the settled flag is a fail-loud guard, not a render input).
*/
export class PendingWait<K extends PendingKind = PendingKind> {
/** Interaction kind (union discriminant). */
readonly kind: K
/** Opaque render identity, `<prefix>:<rpcId>` — stable across baseline replay, usable as a React key. */
readonly key: string
/** Owning session. */
readonly sessionId: SessionId
/** The requested frame's domain fields, verbatim. */
readonly payload: PendingPayloads[K]
#settled = false
readonly #rpcId: RpcId
readonly #respond: (message: ClientResponse) => Promise<RpcReceipt>
/**
* Minted by Session on a requested frame (public construction is the test-fixture path).
* @param kind - interaction kind.
* @param rpcId - the requested frame's stable envelope id (kept private; respond echoes it).
* @param sessionId - owning session.
* @param payload - the requested frame's domain fields.
* @param respond - the client-response carrier (api.respond).
*/
constructor(
kind: K, rpcId: RpcId, sessionId: SessionId, payload: PendingPayloads[K],
respond: (message: ClientResponse) => Promise<RpcReceipt>,
) {
this.kind = kind
this.key = `${KEY_PREFIX[kind]}:${rpcId}`
this.sessionId = sessionId
this.payload = payload
this.#rpcId = rpcId
this.#respond = respond
}
/**
* Send a result for this wait: wraps it into the client-response envelope
* with the rpcId backfilled. Throws synchronously once settled.
* @param result - the result shell (ok value / error envelope), domain-encoded by the caller.
* @returns the carrier receipt.
*/
respond(result: ClientResponse['result']): Promise<RpcReceipt> {
if (this.#settled) throw new Error(`pending wait ${this.key} is already settled`)
return this.#respond({ type: 'client-response', rpcId: this.#rpcId, result })
}
/** Session-only settlement mark (the authoritative resolved frame arrived); respond() throws afterwards. */
markSettled(): void {
this.#settled = true
}
}

View File

@@ -1,35 +1,46 @@
/**
* SessionsService: root sessions service — list snapshot store (manager
* projection), session scope tree (mintScope pattern: no-op plugin Fiber +
* ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
* projection; carries `current`, the persisted selection every
* session-scoped surface keys off — migrated here from ui-layout per the
* slot-parity design), session scope tree (mintScope pattern: no-op plugin
* Fiber + ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
*
* Scope lifecycle is watch-driven: a scope is minted lazily on first
* resolution; a session leaving the list tears its scope down only when
* nobody is watching it. "Watched" is approximated as the most recently
* resolved binding id — SessionProvider re-resolves on every selection
* change (keyed remount), so a switch away always re-evaluates the deferred
* teardown; a host-side death without list removal keeps the scope (frozen
* read-only view).
* Scope lifecycle is stage-driven: a scope is minted lazily on first
* resolution (pure — resolution has no side effects and is render-safe);
* the event window and deferred teardown key off the STAGED session, which
* follows `list.current` exactly. Staging is the open signal: the window
* opens ⟺ the session is on stage (today the stage is `current`; the staged
* state can widen to a multi-pane list later). A session leaving the list
* tears its scope down immediately unless it is the staged one, whose scope
* survives frozen (read-only view) until the stage moves on.
*/
import type { Context, Fiber } from 'cordis'
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import { SessionManager } from './manager.ts'
import type { Session } from './session.ts'
/** Session list row projected from the host list RPC plus live stream increments. */
export interface SessionSummary {
id: SessionId
title: string
/** Latest durable log-backed title, absent until the host projects one. */
title?: string
/** Human-facing label: durable title, project basename, then session id. */
displayTitle: string
cwd?: string
parentId?: SessionId
running: boolean
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 {
@@ -54,10 +65,11 @@ export function scopeOf(ctx: Context): SessionId | undefined {
function sessionScope(): void {}
/**
* Display title projection. The wire summary carries no title yet (P-I
* ledger): the project directory's basename stands in, then the raw id.
* Display title projection: durable title, project directory basename, then
* the raw id.
*/
function titleOf(cwd: string | undefined, id: SessionId): string {
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
if (title !== undefined) return title
if (cwd !== undefined && cwd !== '') {
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
if (base !== undefined && base !== '') return base
@@ -69,34 +81,85 @@ 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} / {@link SessionsService.clear}. Projection
* validates it against the live list instead of destructively pruning, so a
* selection survives transient list states (reconnect re-pull) and
* resurfaces when its session returns.
*/
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
private readonly scopes = new Map<SessionId, ScopeRecord>()
/** Most recently resolved binding id — the watch approximation for deferred teardown. */
/**
* The staged session id — follows `list.current` exactly, holding its last
* defined value across masked gaps (a transiently absent selection blanks
* `current` without moving the stage, so reconnect re-pulls and removals
* keep the staged scope's frozen view alive until the stage moves on).
*/
private watched: SessionId | undefined
/** Removed-while-watched sessions whose teardown waits for the watch to move away. */
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
private readonly deferredRemovals = new Set<SessionId>()
/**
* @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session.
*/
constructor(private readonly rootCtx: Context, api: IApiClient) {
constructor(private readonly rootCtx: Context, private readonly api: IApiClient) {
this.manager = new SessionManager(api)
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
{},
{ persist: { name: 'dsh.sessions.current' } })
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined })
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
this.manager.subscribe(() => { this.projectList() })
// Stage follower: every current write (open() and projection alike)
// re-evaluates staging, so startup restore (persisted selection validated
// by the projection) and reconnect resurfacing open their window with no
// dedicated code path. Safe to run synchronously inside the store notify:
// the follower writes no list state — session.open()'s synchronous prefix
// touches only session-side state and its own microtask-batched notifier.
this.list.subscribe(() => { this.followCurrent() })
rootCtx.reflect.provide('sessions', this, undefined)
}
/**
* Select a session as current. Unknown ids fail loud instead of navigating
* nowhere.
* @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 })
}
/**
* Clear the current selection so the layout shows the no-session empty
* state. Wipes the persisted selection too — a reload stays on empty until
* the user opens or starts a session. Staging holds the previous occupant
* across the blank (same masked-gap rule as a transient list miss).
*/
clear(): void {
this.selection.set({})
this.list.update((draft) => { draft.current = undefined })
}
/**
* Create a session on the host.
* @param opts - creation options (project directory).
@@ -108,6 +171,27 @@ export class SessionsService {
return result.value.sessionId
}
/**
* Create a workspace folder under the host process cwd and a session in it.
* Name is a single path segment (no separators); the host mkdir runs inside
* session.create. Caller opens the returned id when it wants the session staged.
* @param name - workspace folder basename.
* @returns the new session id.
*/
async createWorkspace(name: string): Promise<SessionId> {
const trimmed = name.trim()
if (trimmed === '') throw new Error('sessions.createWorkspace: name is required')
if (/[/\\]/.test(trimmed)) {
throw new Error('sessions.createWorkspace: name must not contain path separators')
}
const { result } = await this.api.host.describe({})
if (!result.ok) {
throw new Error(`host.describe failed: ${result.error.code}: ${result.error.message}`)
}
const hostCwd = result.value.cwd.replace(/[/\\]+$/, '')
return this.create({ cwd: `${hostCwd}/${trimmed}` })
}
/**
* Resolve a session-scoped context view (use-and-discard).
* @param id - session id.
@@ -118,18 +202,62 @@ export class SessionsService {
}
/**
* Resolve the stable session binding (SessionProvider's resolveBinding feed).
* Read the session scope tag off a context. Service-method seam: fetch
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
* value import of the standalone helper would inline a second module
* instance whose private tag Symbol never matches.
* @param ctx - any client context.
* @returns the session id, or undefined on root contexts.
*/
scopeOf(ctx: Context): SessionId | undefined {
return scopeOf(ctx)
}
/**
* Resolve the stable session binding (scope-addressed assembly feed). Pure
* resolution — no staging, no window side effects.
* @param id - session id.
* @returns binding, or undefined for a session neither listed nor already scoped.
*/
binding(id: SessionId): SessionBinding | undefined {
const record = this.resolve(id)
if (record === undefined) return undefined
if (this.watched !== id) {
this.watched = id
this.sweepDeferred()
return this.resolve(id)?.binding
}
/**
* Resolve the render-layer session cell (SessionProvider's feed through
* the renderer host; ctx never enters the render layer). Pure resolution —
* render-safe: SessionProvider calls this during render, so no staging, no
* window side effects (StrictMode double-invokes and concurrent discarded
* passes must stay free).
* @param id - session id.
* @returns cell, or undefined for a session neither listed nor already scoped.
*/
cell(id: string): SessionCell | undefined {
return this.resolve(id as SessionId)?.cell
}
/**
* Move the stage to the list's current session: sweep teardowns deferred
* behind the previous occupant and pull the new occupant's history window.
* Staging IS the open signal — the window opens ⟺ the session is on stage
* — and open() is idempotent (an in-flight or completed open no-ops; a
* failed one retries the next time current is touched).
*/
private followCurrent(): void {
const current = this.list.getSnapshot().current
// A masked gap (current blanked while the selection's session is
// transiently absent) holds the stage: tearing down on the gap would
// destroy exactly the frozen scope the mask exists to preserve.
if (current === undefined || current === this.watched) return
this.watched = current
this.sweepDeferred()
const record = this.resolve(current)
/* v8 ignore next 3 -- defensive: current is always a listed id (open()
* validates and the projection masks absent selections), so resolve
* cannot miss; kept so a future current writer cannot crash the notify. */
if (record !== undefined) {
void record.binding.session.open()
}
return record.binding
}
/**
@@ -158,10 +286,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
@@ -176,18 +308,23 @@ export class SessionsService {
ids.push(entry.sessionId)
byId[entry.sessionId] = {
id: entry.sessionId,
title: titleOf(entry.cwd, entry.sessionId),
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
running: entry.running,
updatedAt: entry.updatedAt,
...(entry.title !== undefined ? { title: entry.title } : {}),
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
}
}
this.list.set({ ids, byId })
// current = the persisted selection, masked while its session is absent
// (falls to the empty state; resurfaces if the session returns).
const selected = this.selection.getSnapshot().sessionId
const current = selected !== undefined && byId[selected] !== undefined ? selected : undefined
this.list.set({ ids, byId, current })
this.pruneScopes(byId)
}
/** Tear down scopes for removed sessions nobody watches; the watched one defers until the watch moves. */
/** Tear down scopes for removed sessions off stage; the staged one defers until the stage moves. */
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
for (const [id, record] of this.scopes) {
if (byId[id] !== undefined) continue
@@ -197,15 +334,23 @@ export class SessionsService {
}
this.scopes.delete(id)
this.deferredRemovals.delete(id)
void record.fiber.dispose()
this.dropScope(id, record)
}
}
/** Run deferred teardowns whose session is no longer watched (called when the watch moves). */
/** Dispose a scope fiber and its session-keyed slot-store instances together (single lifecycle axis). */
private dropScope(id: SessionId, record: ScopeRecord): void {
void record.fiber.dispose()
// Optional lookup: slots and sessions are sibling services with no
// declared dependency; a slots-less boot (object-layer tests) skips.
this.rootCtx.get('slots')?.pruneStoreScope(id)
}
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
private sweepDeferred(): void {
for (const id of [...this.deferredRemovals]) {
/* v8 ignore next -- defensive: only the watched id ever defers, and every
* watch move sweeps first, so the set cannot contain the id the watch just
/* v8 ignore next -- defensive: only the staged id ever defers, and every
* stage move sweeps first, so the set cannot contain the id the stage just
* moved to; kept as a guard against future extra sweep call sites. */
if (id === this.watched) continue
// Still absent from the list? (A re-added id cancels the deferred teardown.)
@@ -220,7 +365,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

@@ -5,13 +5,19 @@
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 {
ConversationNode, ConversationSnapshot, OpenState, PendingInteraction, PromptError, RunningToolCall,
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
SessionId, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts'
import type {
ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
import { FoldAdapter } from './fold-adapter.ts'
import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts'
@@ -19,11 +25,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).
@@ -182,7 +190,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.events = []
this.views = []
this.baseSeq = 0
this.pending.clear() // the subscribed baseline replay re-sends still-pending requested frames verbatim
// Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim
// (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host.
this.pending.clear()
this.pendingRev++
this.subscribedLastSeq = null
this.liveBuffer = []
@@ -228,33 +238,27 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return // pure baseline bookkeeping, no visible change
}
case 'approval/requested': {
this.pending.set(`a:${rpcId}`, {
kind: 'approval', rpcId, approvalId: frame.approvalId, toolName: frame.toolName,
...(frame.callId !== undefined ? { callId: frame.callId } : {}),
...(frame.reason !== undefined ? { reason: frame.reason } : {}),
})
this.pendingRev++
const { type: _type, sessionId: _sid, ...payload } = frame
this.mint(new PendingWait('approval', rpcId, this.sessionId, payload, m => this.api.respond(m)))
this.notifier.markDirty()
return
}
case 'approval/resolved': {
for (const [key, item] of this.pending) {
if (item.kind === 'approval' && item.approvalId === frame.approvalId) {
this.pending.delete(key)
this.pendingRev++
}
for (const item of this.pending.values()) {
if (item.kind === 'approval' && item.payload.approvalId === frame.approvalId) this.settle(item)
}
this.notifier.markDirty()
return
}
case 'question/requested': {
this.pending.set(`q:${rpcId}`, { kind: 'question', rpcId, questions: frame.questions })
this.pendingRev++
const { type: _type, sessionId: _sid, ...payload } = frame
this.mint(new PendingWait('question', rpcId, this.sessionId, payload, m => this.api.respond(m)))
this.notifier.markDirty()
return
}
case 'question/resolved': {
if (this.pending.delete(`q:${frame.questionRpcId}`)) this.pendingRev++
const item = this.pending.get(`q:${frame.questionRpcId}`)
if (item !== undefined) this.settle(item)
this.notifier.markDirty()
return
}
@@ -294,6 +298,19 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
// ---- 私有 ----
/** Requested-frame arrival: the wait enters the pending map under its own key. */
private mint(wait: PendingInteraction): void {
this.pending.set(wait.key, wait)
this.pendingRev++
}
/** Authoritative resolved-frame settlement: mark, then drop from the pending map. */
private settle(wait: PendingInteraction): void {
wait.markSettled()
this.pending.delete(wait.key)
this.pendingRev++
}
/** @param generation - openGeneration at launch; every await re-checks it and a stale pass
* drops all writes (resync superseded this open — its outcome belongs to a dead connection). */
private async doOpen(generation: number): Promise<void> {
@@ -417,7 +434,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
case 'tool/call': {
this.openCalls.set(String(event.data.callId), {
callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments,
turn: event.data.turn, step: event.data.step,
turn: event.data.turn, step: event.data.step, time: event.time,
callView: view?.for === 'call' ? view.view : null,
})
this.callsRev++
@@ -438,7 +455,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (visible) {
// Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
this.frozenNodes.push({
kind: 'assistant', seq: event.seq - 0.9, turn: this.partial.turn, step: this.partial.step,
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
turn: this.partial.turn, step: this.partial.step,
blocks, interrupted: true,
})
this.frozenRev++
@@ -452,8 +470,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.callsRev++
// The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
this.frozenNodes.push({
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, callId,
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time,
callId,
call: { name: call.name, argsRaw: call.argsRaw },
callTime: call.time,
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView, resultView: null,
})

View File

@@ -1,22 +1,88 @@
/**
* 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
/** Chain-slot routing selector (pure; the core validates presence for chain targets). */
select?: (owner: never) => unknown
/** Chain-slot explicit ordering override (ascending; registration order otherwise). */
priority?: number
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 +93,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 +186,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 +205,114 @@ export class SlotsService extends Service {
return this._core.getVersion(key)
}
/** The wrapped pure core (web-react's scopedSlots outlet reads through this). */
get core(): SlotCore {
return this._core
/** Delegating registration path: factory minting + registrant stamp + core write + instance-axis bookkeeping. */
private _register(options: ErasedRegisterOptions, component: unknown): () => void {
// Exclusive stores pass the factory itself: minted here into a per-entry
// handle so the stored entry always carries a resolvable handle (the
// core's shared-handle scope pinning applies to it harmlessly).
const store = typeof options.store === 'function' ? options.store() : options.store
const registrant = options.registrant ?? (this.ctx.fiber as { name?: string } | undefined)?.name
const erased: ErasedRegisterOptions = {
...options,
...(store !== undefined ? { store } : {}),
...(registrant !== undefined ? { registrant } : {}),
}
// Core write first: all load-time validation (undeclared target,
// duplicate declaration, kind conflicts, cross-scope handle) throws
// there before this layer commits anything.
const dispose = (this._core as unknown as ErasedCore).register(erased, component)
if (store !== undefined) {
// Register succeeded, so the target's spec is on the ledger.
const scope = (this._core.specDynamic(options.name) as SlotSpec<never>).scope
this._acquire(store, scope)
}
let disposed = false
return () => {
if (disposed) return
disposed = true
dispose()
if (store !== undefined) this._release(store)
}
}
/** Build (once) the host face the installed renderer reads; sessions resolve lazily at first render. */
private hostFace(): SlotRendererHost {
if (this._host !== undefined) return this._host
const sessions = this.ctx.get('sessions')
if (sessions === undefined) {
throw new Error("renderSlot('root') before the sessions service mounted — boot order puts runtime apply first")
}
// Identity-stable view: current rides the list snapshot (arbitrated), but
// the provider consumes it as its own observable; one cached object keeps
// the renderer's per-source hook cache stable.
const current = {
getSnapshot: () => sessions.list.getSnapshot().current as string | undefined,
subscribe: (fn: () => void) => sessions.list.subscribe(fn),
}
this._host = {
subscribe: (key, fn) => this._core.subscribe(key, fn),
getVersion: key => this._core.getVersion(key),
entriesOf: key => this._core.entries(key),
specOf: key => this._core.specDynamic(key),
isLive: entry => this._core.isLive(entry),
storeOf: (entry, scopeKey) =>
entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
sessions: {
list: sessions.list,
current,
cell: id => sessions.cell(id),
},
}
return this._host
}
/** Resolve (create or reuse) the store instance for a registered handle under a scope key. */
private resolveStore(handle: EngineStoreHandle, sessionId: string | undefined): StoreInstanceLike {
const record = this._stores.get(handle)
if (record === undefined) throw new Error('store handle is not registered (entry unloaded, or the handle never went through register)')
const key = record.scope === 'session' ? sessionId : ROOT_INSTANCE_KEY
if (key === undefined) throw new Error('session-scoped store resolution requires a session id')
let instance = record.instances.get(key)
if (instance === undefined) {
// Session instances get the scope key (the engine suffixes the persist
// key per session); root instances stay keyless.
instance = record.scope === 'session' ? handle.create(key) : handle.create()
record.instances.set(key, instance)
}
return instance
}
/** Bind (or re-reference) a handle on the axis; cross-scope conflicts already threw in the core. */
private _acquire(handle: EngineStoreHandle, scope: SlotScope): void {
const record = this._stores.get(handle)
if (record === undefined) {
this._stores.set(handle, { scope, refs: 1, instances: new Map() })
return
}
record.refs += 1
}
/** Drop one reference; the last holder's unload drops the record (instances go with it — engine stores need no explicit dispose). */
private _release(handle: EngineStoreHandle): void {
const record = this._stores.get(handle)
/* v8 ignore next -- defensive: release only runs from a disposer whose
* register acquired the same handle, so the record must exist; kept so a
* future call site cannot underflow the axis. */
if (record === undefined) return
record.refs -= 1
if (record.refs === 0) this._stores.delete(handle)
}
}
// register's implementation (prototype assignment pairs with the `declare`
// inside the class — see its JSDoc for why it must live on the prototype).
// Element access reaches the private _register legally and keeps it a
// TS-visible read.
;(SlotsService.prototype as { register: (options: object, component: unknown) => () => void }).register
= function register(this: SlotsService, rawOptions: object, component: unknown): () => void {
// The core's overloads proved the shares; the implementation works on
// the erased view (same pattern as the core's own implementation arm).
const options = rawOptions as ErasedRegisterOptions
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(() => this['_register'](options, component), 'slots.register()')
}

View File

@@ -37,6 +37,9 @@ describe('runtime client apply', () => {
it('mounts ctx.slots + ctx.sessions and wires the stream sinks into the manager', async () => {
const bench = await mount()
expect(bench.ctx.get('slots') !== undefined).toBe(true)
// The built-in 'root' declaration ships with this package's SlotsService
// (the SlotMap 'root' merge lives here since the slot-parity rework).
expect(bench.ctx.slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
const sessions = bench.ctx.get('sessions')
expect(sessions !== undefined).toBe(true)
expect(bench.sinks).toBeDefined()

View File

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

View File

@@ -1,289 +0,0 @@
/**
* ClientLoader: handoff protocol (single slot, id reconciliation), DI require
* with export-surface re-registration, immediately-group barrier (parallel
* fetch / topology execution / full-group barrier), status store, settled,
* failure modes (missing handoff, unknown dep, cycle, unload stub).
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { createClientLoader } from '../src/client/loader/index.ts'
import type { BootPluginEntry, ClientPluginHandoff } from '../src/client/loader/index.ts'
type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; __DSH_BOOT__?: { plugins: BootPluginEntry[] } }
const win = globalThis as Win
afterEach(() => {
delete win.DSHClientProxy
delete win.__DSH_BOOT__
})
interface FakeBundle {
handoff: ClientPluginHandoff | null | ((require: (spec: string) => unknown) => Record<string, unknown>)
}
interface Bench {
loader: ReturnType<typeof createClientLoader>
fetched: string[]
executed: string[]
fetchGate: Map<string, () => void>
}
/** Build a loader over scripted fake bundles keyed by url; fetches resolve when released (or immediately). */
function bench(
plugins: BootPluginEntry[],
bundles: Record<string, FakeBundle>,
opts: { modules?: Record<string, unknown>; gated?: string[] } = {},
): Bench {
const ctx = new Context()
const fetched: string[] = []
const executed: string[] = []
const fetchGate = new Map<string, () => void>()
const loader = createClientLoader({
ctx,
modules: opts.modules ?? { react: { marker: 'react' } },
boot: { plugins },
fetchBundle: (url) => {
fetched.push(url)
if (opts.gated?.includes(url) === true) {
return new Promise<string>((resolve) => { fetchGate.set(url, () => { resolve(url) }) })
}
return Promise.resolve(url)
},
executeBundle: (code) => {
executed.push(code)
const bundle = bundles[code]
if (bundle === undefined) throw new Error(`no fake bundle for ${code}`)
if (bundle.handoff === null) return // simulates a bundle that never calls loadPlugin
if (typeof bundle.handoff === 'function') {
win.DSHClientProxy?.loadPlugin({ id: code.replace('/client.js', '').replace('/plugins/', ''), factory: bundle.handoff })
return
}
win.DSHClientProxy?.loadPlugin(bundle.handoff)
},
})
return { loader, fetched, executed, fetchGate }
}
const entry = (id: string, inject: string[] = [], immediately?: boolean): BootPluginEntry =>
({ id, url: `/plugins/${id}/client.js`, inject, ...(immediately === true ? { immediately: true } : {}) })
const okBundle = (applied?: string[], exports: Record<string, unknown> = {}): FakeBundle => ({
handoff: require => ({
apply: (pluginCtx: Context) => { void pluginCtx; applied?.push('applied') },
require,
...exports,
}),
})
describe('load chain', () => {
it('runs fetch→execute→handoff→factory(require)→apply→export re-registration→status active', async () => {
const applied: string[] = []
const b = bench(
[entry('fake-base', [], true), entry('feature', ['fake-base'])],
{
'/plugins/fake-base/client.js': { handoff: () => ({ apply: () => { applied.push('fake-base') }, helper: 'base-helper' }) },
'/plugins/feature/client.js': {
handoff: (require) => {
// Later loader requires the earlier one's export surface (inject topology guarantee).
const fakeBase = ['fake','base'].join('-') // assembled so knip's static require() scan skips the fake id
const base = require(fakeBase) as { helper: string }
expect(base.helper).toBe('base-helper')
expect((require('react') as { marker: string }).marker).toBe('react')
return { apply: () => { applied.push('feature') } }
},
},
},
)
b.loader.start()
await b.loader.settled()
expect(applied).toEqual(['fake-base', 'feature'])
expect(b.loader.status.getSnapshot()).toEqual({ 'fake-base': 'active', feature: 'active' })
expect((b.loader.requireModule('fake-base') as { helper: string }).helper).toBe('base-helper')
expect(() => b.loader.requireModule('ghost')).toThrow(/not available/)
})
it('fetches the immediately group in parallel and holds the barrier before the rest', async () => {
const b = bench(
[entry('a', [], true), entry('b', ['a'], true), entry('later')],
{
'/plugins/a/client.js': okBundle(),
'/plugins/b/client.js': okBundle(),
'/plugins/later/client.js': okBundle(),
},
{ gated: ['/plugins/a/client.js'] },
)
b.loader.start()
await Promise.resolve()
// Both early fetches are in flight before any execution; the late plugin is not fetched yet.
expect(b.fetched).toEqual(['/plugins/a/client.js', '/plugins/b/client.js'])
expect(b.executed).toEqual([])
b.fetchGate.get('/plugins/a/client.js')?.()
await b.loader.settled()
expect(b.executed).toEqual(['/plugins/a/client.js', '/plugins/b/client.js', '/plugins/later/client.js'])
})
it('orders execution by inject topology within each group', async () => {
const b = bench(
[entry('z-ui', ['a-base']), entry('a-base')],
{ '/plugins/a-base/client.js': okBundle(), '/plugins/z-ui/client.js': okBundle() },
)
b.loader.start()
await b.loader.settled()
expect(b.executed).toEqual(['/plugins/a-base/client.js', '/plugins/z-ui/client.js'])
})
})
describe('failure modes (fail loud)', () => {
it('rejects settled and marks failed when a bundle never calls loadPlugin', async () => {
const b = bench([entry('silent')], { '/plugins/silent/client.js': { handoff: null } })
b.loader.start()
await expect(b.loader.settled()).rejects.toThrow(/without calling DSHClientProxy.loadPlugin/)
expect(b.loader.status.getSnapshot().silent).toBe('failed')
})
it('rejects on manifest/handoff id mismatch', async () => {
const b = bench([entry('expected')], {
'/plugins/expected/client.js': { handoff: { id: 'imposter', factory: () => ({ apply: () => {} }) } },
})
b.loader.start()
await expect(b.loader.settled()).rejects.toThrow(/id mismatch/)
})
it('rejects unknown inject targets, cycles, missing apply, unknown load ids, duplicate manifest ids', async () => {
// Sequential benches: each loader owns the window proxy, so release it between them.
const fresh = <T>(build: () => T): T => {
delete win.DSHClientProxy
return build()
}
const missing = fresh(() => bench([entry('x', ['nope'])], { '/plugins/x/client.js': okBundle() }))
missing.loader.start()
await expect(missing.loader.settled()).rejects.toThrow(/injects unknown plugin "nope"/)
const cyclic = fresh(() => bench(
[entry('p', ['q']), entry('q', ['p'])],
{ '/plugins/p/client.js': okBundle(), '/plugins/q/client.js': okBundle() },
))
cyclic.loader.start()
await expect(cyclic.loader.settled()).rejects.toThrow(/inject cycle/)
const applyless = fresh(() => bench([entry('noap')], { '/plugins/noap/client.js': { handoff: { id: 'noap', factory: () => ({}) } } }))
applyless.loader.start()
await expect(applyless.loader.settled()).rejects.toThrow(/exports no apply/)
const b = fresh(() => bench([entry('a')], { '/plugins/a/client.js': okBundle() }))
await expect(b.loader.load('ghost')).rejects.toThrow(/unknown plugin "ghost"/)
expect(() => fresh(() => bench([entry('dup'), entry('dup')], {}))).toThrow(/duplicate manifest id/)
})
it('throws on missing boot manifest, double proxy install, and pre-start settled', () => {
expect(() => createClientLoader({ ctx: new Context(), modules: {} })).toThrow(/no boot manifest/)
const b = bench([], {})
expect(() => b.loader.settled()).toThrow(/settled\(\) before start\(\)/)
// First bench installed the proxy; a second loader must refuse.
expect(() => createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })).toThrow(/already installed/)
})
it('direct load() before a dependency is active fails loud (same check start() sequences)', async () => {
const b = bench(
[entry('dep', [], true), entry('needy', ['dep'])],
{ '/plugins/dep/client.js': okBundle(), '/plugins/needy/client.js': okBundle() },
)
await expect(b.loader.load('needy')).rejects.toThrow(/loaded before its dependency "dep" is active/)
})
it('direct load() naming an unknown inject target fails loud', async () => {
const b = bench([entry('solo', ['phantom'])], { '/plugins/solo/client.js': okBundle() })
await expect(b.loader.load('solo')).rejects.toThrow(/injects unknown plugin "phantom"/)
})
it('an immediately-group fetch failure surfaces through settled, not as an unhandled prefetch rejection', async () => {
// The fire-and-forget prefetch swallow arm must absorb the early
// rejection; the awaited load surfaces the same failure via settled().
const ctx = new Context()
delete win.DSHClientProxy
const loader = createClientLoader({
ctx,
modules: {},
boot: { plugins: [{ id: 'kaboom', url: '/plugins/kaboom/client.js', inject: [], immediately: true }] },
fetchBundle: () => Promise.reject(new Error('bundle fetch exploded')),
executeBundle: () => {},
})
loader.start()
await expect(loader.settled()).rejects.toThrow(/bundle fetch exploded/)
})
it('unload is the P-I stub', async () => {
const b = bench([], {})
await expect(b.loader.unload('x')).rejects.toThrow(/not implemented/)
})
})
describe('DOM default seams (stubbed globals)', () => {
it('default fetchBundle uses fetch, rejects non-OK; default executeBundle injects an inline script; claimStyles tags orphans', async () => {
const origFetch = globalThis.fetch
const appended: { textContent?: string | null }[] = []
const styleTag = {
attrs: {} as Record<string, string>,
setAttribute(k: string, v: string) { this.attrs[k] = v },
}
const fakeDoc = {
createElement: () => {
const el = { textContent: null as string | null }
return el
},
head: { appendChild: (el: { textContent?: string | null }) => { appended.push(el) } },
querySelectorAll: () => [styleTag],
}
const g = globalThis as { document?: unknown; fetch: typeof fetch }
g.document = fakeDoc
g.fetch = (url: URL | RequestInfo) => Promise.resolve(
(typeof url === 'string' ? url : url instanceof URL ? url.href : url.url).includes('bad')
? new Response('x', { status: 500 })
: new Response('window.DSHClientProxy.loadPlugin(globalThis.__seamHandoff)', { status: 200 }),
)
try {
delete win.DSHClientProxy
const ctx = new Context()
const loader = createClientLoader({
ctx,
modules: {},
boot: { plugins: [
{ id: 'seam-ok', url: '/plugins/seam-ok/client.js', inject: [] },
{ id: 'seam-bad', url: '/plugins/bad/client.js', inject: [] },
] },
// NO seams injected (keys omitted, not undefined — exactOptional):
// the DOM defaults are under test.
})
const seamHandoff: ClientPluginHandoff = {
id: 'seam-ok',
factory: () => ({ apply: () => {} }),
}
// Default executeBundle only APPENDS the script element (no execution in
// our fake DOM), so drive the handoff manually before load resolves it.
const loadOk = loader.load('seam-ok')
await Promise.resolve()
;(globalThis as Win).DSHClientProxy?.loadPlugin(seamHandoff)
await loadOk
expect(appended).toHaveLength(1)
expect(appended[0]?.textContent).toContain('sourceURL=/plugins/seam-ok/client.js')
expect(styleTag.attrs['data-plugin']).toBe('seam-ok')
await expect(loader.load('seam-bad')).rejects.toThrow(/answered 500/)
} finally {
g.fetch = origFetch
delete (globalThis as { document?: unknown }).document
}
})
})
describe('handoff slot protocol', () => {
it('rejects an overlapping loadPlugin before the loader claims the pending handoff', () => {
delete win.DSHClientProxy
createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })
const proxy = (globalThis as Win).DSHClientProxy
proxy?.loadPlugin({ id: 'first', factory: () => ({ apply: () => {} }) })
expect(() => proxy?.loadPlugin({ id: 'second', factory: () => ({ apply: () => {} }) }))
.toThrow(/overlapping loadPlugin handoff/)
})
})

View File

@@ -2,7 +2,7 @@
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
HostFrame, IApiClient, MuxFrame, RpcError, RpcRequest, RpcResponse, SessionId,
ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
@@ -93,8 +93,10 @@ export class FakeApiClient implements IApiClient {
host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen),
}
respond(): Promise<{ accepted: false; reason: 'not-pending' }> {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
onRespond: (message: ClientResponse) => Promise<RpcReceipt> = () => Promise.resolve({ accepted: true })
respond(message: ClientResponse): Promise<RpcReceipt> {
return this.record('respond', message, this.onRespond(message))
}
/** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */

View File

@@ -40,7 +40,7 @@ describe('FoldAdapter', () => {
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }),
at(3, { type: 'context/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
at(3, { type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(5, 0, 'c1', '结果'),
]

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

@@ -34,7 +34,7 @@ describe('instances', () => {
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } })
const session = manager.get(S1)
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', approvalId: 'ap1' }])
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }])
// Buffer cleared: a second instantiation of another id gets nothing.
expect(manager.get(S2).getSnapshot().pending).toEqual([])
})
@@ -48,7 +48,7 @@ describe('instances', () => {
}
const pending = manager.get(S1).getSnapshot().pending
expect(pending).toHaveLength(32)
expect(pending.map(p => p.rpcId)).toEqual(Array.from({ length: 32 }, (_, i) => `q${i + 8}`)) // oldest 8 dropped
expect(pending.map(p => p.key)).toEqual(Array.from({ length: 32 }, (_, i) => `q:q${i + 8}`)) // oldest 8 dropped
// Removed session: buffered frames must not replay on a future instantiation.
manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
@@ -89,6 +89,66 @@ describe('list lifecycle', () => {
expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
})
it('retains monotonic title snapshots before list arrival, merges recency, and clears them on removal', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleMuxEnvelope({
rpcId: 'title-new' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Newest', eventSeq: 4, updatedAt: 300 },
})
manager.handleMuxEnvelope({
rpcId: 'title-stale' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Stale', eventSeq: 3, updatedAt: 900 },
})
manager.handleMuxEnvelope({
rpcId: 'title-equal' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Equal', eventSeq: 4, updatedAt: 901 },
})
api.onList = () => Promise.resolve(ok({
items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[],
}))
await manager.refreshList()
const titled = manager.getListSnapshot()
expect(titled.items.map(item => item.sessionId)).toEqual([S1, S2])
expect(titled.items[0]).toMatchObject({ title: 'Newest', updatedAt: 300 })
expect(titled.items[1]?.title).toBeUndefined()
manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', sessionId: S1 } })
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
})
it('drops a retained title beyond the subscription baseline before accepting its durable replay', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
const manager = new SessionManager(api)
await manager.refreshList()
manager.handleMuxEnvelope({
rpcId: 'title-unflushed' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Unflushed', eventSeq: 4, updatedAt: 400 },
})
manager.handleMuxEnvelope({
rpcId: 'subscribed-recovered' as never,
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
})
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(100)
manager.handleMuxEnvelope({
rpcId: 'title-durable' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Durable', eventSeq: 2, updatedAt: 200 },
})
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
manager.handleMuxEnvelope({
rpcId: 'subscribed-current' as never,
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
})
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
})
})
describe('host frame routing', () => {

View File

@@ -251,6 +251,36 @@ describe('pending interactions', () => {
session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq' as never, outcome: 'answered' })
expect(session.getSnapshot().pending).toEqual([])
})
it('mints waits whose respond() backfills the requested rpcId into the client-response envelope', async () => {
const { api, session } = makeSession()
session.handleMuxEnvelope('rq-answer' as never, { type: 'question/requested', sessionId: SID, questions: [] })
const wait = session.getSnapshot().pending[0]!
expect(wait).toMatchObject({ kind: 'question', key: 'q:rq-answer', sessionId: SID, payload: { questions: [] } })
const receipt = await wait.respond({
ok: true,
value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } },
})
expect(receipt).toEqual({ accepted: true })
expect(api.callsOf('respond')).toEqual([{
type: 'client-response', rpcId: 'rq-answer',
result: {
ok: true,
value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } },
},
}])
})
it('settles the wait on the authoritative resolved frame: respond() then throws synchronously', async () => {
const { api, session } = makeSession()
session.handleMuxEnvelope('rq1' as never, { type: 'question/requested', sessionId: SID, questions: [] })
const wait = session.getSnapshot().pending[0]!
session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq1' as never, outcome: 'answered' })
expect(session.getSnapshot().pending).toEqual([])
expect(() => wait.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } }))
.toThrow('already settled')
expect(api.callsOf('respond')).toEqual([])
})
})
describe('remaining branches', () => {
@@ -355,7 +385,7 @@ describe('remaining branches', () => {
session.handleMuxEnvelope('ra' as never, {
type: 'approval/requested', sessionId: SID, approvalId: 'ap2' as never, toolName: 'rm', callId: 'c1' as never, reason: '危险',
})
expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', callId: 'c1', reason: '危险' })
expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', payload: { callId: 'c1', reason: '危险' } })
session.handleMuxEnvelope('rx' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
session.handleMuxEnvelope('rx2' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
session.handleMuxEnvelope('ry2' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'never-was' as never, outcome: 'cancelled' })
@@ -568,6 +598,22 @@ describe('resync', () => {
expect(cold.api.calls).toEqual([]) // never opened: no traffic
})
it('re-mints a replayed requested frame as a fresh wait with the same key (old reference superseded)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] })
const before = session.getSnapshot().pending[0]!
await session.resync()
session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] })
const after = session.getSnapshot().pending[0]!
expect(after).not.toBe(before)
expect(after.key).toBe(before.key)
// Superseded ≠ settled: an in-flight respond on the stale reference still reaches the host.
await before.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
expect(api.callsOf('respond')).toMatchObject([{ rpcId: 'rq-replay' }])
})
it('drops a stale in-flight open superseded by resync (generation guard)', async () => {
const { api, session } = makeSession()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()

View File

@@ -1,10 +1,13 @@
/**
* SessionsService: list store projection (manager → {ids, byId} with derived
* titles), scope-tree lifecycle (lazy mint / frozen survival / removed
* teardown with watch deferral), binding identity, ancestry walk, create.
* SessionsService: list store projection (manager → {ids, byId, current}
* with derived titles), the migrated current-selection account (open
* validation, persisted mask semantics, cell resolution), scope-tree
* lifecycle (lazy mint / frozen survival / removed teardown with staged
* deferral — the stage follows list.current), binding identity, ancestry
* walk, create.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
@@ -38,16 +41,21 @@ async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: s
}
describe('list store projection', () => {
it('projects ids/byId with cwd-basename titles (id fallback) and parent links', async () => {
it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => {
const b = bench()
b.svc.manager.handleMuxEnvelope({
rpcId: 'title' as never,
payload: { type: 'session/title', sessionId: sid('s1'), title: 'Durable title', eventSeq: 2, updatedAt: 3 },
})
await feedList(b, [
{ id: 's1', cwd: '/home/u/proj-a/' },
{ id: 's2', parentId: 's1', running: true },
])
const state = b.svc.list.getSnapshot()
expect(state.ids).toEqual(['s1', 's2'])
expect(state.byId[sid('s1')]).toMatchObject({ title: 'proj-a', cwd: '/home/u/proj-a/' })
expect(state.byId[sid('s2')]).toMatchObject({ title: 's2', parentId: 's1', running: true })
expect(state.byId[sid('s1')]).toMatchObject({ title: 'Durable title', displayTitle: 'Durable title', cwd: '/home/u/proj-a/' })
expect(state.byId[sid('s2')]).toMatchObject({ displayTitle: 's2', parentId: 's1', running: true })
expect(state.byId[sid('s2')]?.title).toBeUndefined()
})
it('reflects live increments (host stream via manager) into the store', async () => {
@@ -74,21 +82,21 @@ describe('scope tree', () => {
expect(binding?.ctx).toBe(scoped)
})
it('tears down an unwatched removed session but defers the watched one until the watch moves', async () => {
it('tears down an off-stage removed session but defers the staged one until the stage moves', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
const ctx1 = b.svc.scope(sid('s1'))
b.svc.binding(sid('s1')) // s1 is watched
b.svc.scope(sid('s2')) // s2 scoped but not watched
b.svc.open(sid('s1')) // s1 staged (current)
b.svc.scope(sid('s2')) // s2 scoped but off stage
await feedList(b, [{ id: 's1' }]) // s2 removed, unwatched: torn down
await feedList(b, [{ id: 's1' }]) // s2 removed, off stage: torn down
expect(b.svc.scope(sid('s2'))).toBeUndefined()
await feedList(b, []) // s1 removed while watched: deferred, scope survives
await feedList(b, []) // s1 removed while staged (current masks): deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBe(ctx1)
await feedList(b, [{ id: 's3' }])
b.svc.binding(sid('s3')) // watch moves: deferred teardown sweeps s1
b.svc.open(sid('s3')) // stage moves: deferred teardown sweeps s1
expect(b.svc.scope(sid('s1'))).toBeUndefined()
})
@@ -104,14 +112,163 @@ describe('scope tree', () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const scoped = b.svc.scope(sid('s1'))
b.svc.binding(sid('s1'))
await feedList(b, []) // removed while watched → deferred
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears
b.svc.binding(sid('s2')) // watch moves; sweep must NOT tear down the re-listed s1
b.svc.open(sid('s1'))
await feedList(b, []) // removed while staged → deferred
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears (current resurfaces, stage unchanged)
b.svc.open(sid('s2')) // stage moves; sweep must NOT tear down the re-listed s1
expect(b.svc.scope(sid('s1'))).toBe(scoped)
})
})
describe('current selection (migrated from ui-layout, arbitrated into the list snapshot)', () => {
afterEach(() => { vi.unstubAllGlobals() })
it('open() writes list.current; unknown ids fail loud', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
expect(b.svc.list.getSnapshot().current).toBeUndefined()
b.svc.open(sid('s1'))
expect(b.svc.list.getSnapshot().current).toBe('s1')
expect(() => { b.svc.open(sid('ghost')) }).toThrow(/unknown session ghost/)
expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone
})
it('clear() blanks list.current and the persisted selection', 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) },
removeItem: (k: string) => { storage.delete(k) },
clear: () => { storage.clear() },
})
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
expect(storage.get('dsh.sessions.current')).toContain('s1')
b.svc.clear()
expect(b.svc.list.getSnapshot().current).toBeUndefined()
// Persisted wipe: a fresh service with the same storage stays on empty.
const again = bench()
await feedList(again, [{ id: 's1' }])
expect(again.svc.list.getSnapshot().current).toBeUndefined()
})
it('masks (not destroys) the selection while its session is off the list', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.open(sid('s1'))
await feedList(b, [{ id: 's2' }]) // s1 removed → current falls to the empty state
expect(b.svc.list.getSnapshot().current).toBeUndefined()
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // s1 returns → selection resurfaces
expect(b.svc.list.getSnapshot().current).toBe('s1')
})
it('persists the selection under dsh.sessions.current and rehydrates it into a fresh service', async () => {
const storage = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (k: string) => storage.get(k) ?? null,
setItem: (k: string, v: string) => { storage.set(k, v) },
})
const first = bench()
await feedList(first, [{ id: 's1' }])
first.svc.open(sid('s1'))
expect(storage.get('dsh.sessions.current')).toContain('s1')
// A fresh boot (same storage) recovers the selection once the list holds the session.
const second = bench()
await feedList(second, [{ id: 's1' }])
expect(second.svc.list.getSnapshot().current).toBe('s1')
})
})
describe('cell (render-layer session kit)', () => {
it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const cell = b.svc.cell('s1')
expect(cell).toBeDefined()
expect(cell?.sessionId).toBe('s1')
// Bare-source form (store migration): the cell carries the Session
// observable itself; hook binding happens in the React machinery.
expect(cell?.session).toBe(b.svc.manager.get(sid('s1')))
expect(b.svc.cell('s1')).toBe(cell)
expect(b.svc.cell('ghost')).toBeUndefined()
})
it('cell()/binding() are pure resolution: no staging, no deferred sweep', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.open(sid('s1')) // staged
b.svc.cell('s2') // resolution only — must NOT move the stage
b.svc.binding(sid('s2'))
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBeDefined()
})
it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
// Resolution is addressing, not staging: no window pull.
b.svc.scope(sid('s1'))
b.svc.cell('s1')
b.svc.binding(sid('s1'))
expect(historyCalls()).toHaveLength(0)
b.svc.open(sid('s1'))
expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
// Same current again: no second pull.
b.svc.open(sid('s1'))
expect(historyCalls()).toHaveLength(1)
// Stage moves: the new occupant opens.
b.svc.open(sid('s2'))
expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1', 's2'])
})
it('startup restore: a persisted selection validated by the first projection opens its window unprompted', async () => {
const storage = new Map<string, string>([
['dsh.sessions.current', JSON.stringify({ sessionId: 's1' })],
])
vi.stubGlobal('localStorage', {
getItem: (k: string) => storage.get(k) ?? null,
setItem: (k: string, v: string) => { storage.set(k, v) },
})
try {
const b = bench()
expect(b.api.calls.filter(c => c.method === 'session.history')).toHaveLength(0)
await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows
const historyCalls = b.api.calls.filter(c => c.method === 'session.history')
expect(historyCalls.map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
} finally {
vi.unstubAllGlobals()
}
})
})
describe('slot-store scope prune hook', () => {
it('notifies ctx.slots.pruneStoreScope when a scope dies (both teardown paths)', async () => {
const b = bench()
const pruneStoreScope = vi.fn()
b.ctx.reflect.provide('slots', { pruneStoreScope })
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.scope(sid('s1'))
b.svc.scope(sid('s2'))
b.svc.open(sid('s2')) // s2 staged
await feedList(b, []) // s1 off stage → immediate drop; s2 staged → deferred
expect(pruneStoreScope).toHaveBeenCalledWith('s1')
expect(pruneStoreScope).not.toHaveBeenCalledWith('s2')
await feedList(b, [{ id: 's3' }])
b.svc.open(sid('s3')) // stage moves → deferred sweep drops s2
expect(pruneStoreScope).toHaveBeenCalledWith('s2')
})
it('tolerates a slots-less boot (object-layer benches carry no slot service)', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.scope(sid('s1'))
await feedList(b, []) // teardown without ctx.slots must not throw
expect(b.svc.scope(sid('s1'))).toBeUndefined()
})
})
describe('ancestry', () => {
it('walks parentId links root-first including self; broken links stop the walk', async () => {
const b = bench()
@@ -140,53 +297,77 @@ describe('create', () => {
})
})
describe('createWorkspace', () => {
it('joins host.describe cwd with the name and creates there', async () => {
const b = bench()
b.api.onDescribe = () => Promise.resolve(ok({ version: '0', cwd: '/host/root', attachedSessions: 0 }))
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('ws') }))
await expect(b.svc.createWorkspace('My Proj')).resolves.toBe('ws')
expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/host/root/My Proj' }])
})
it('rejects empty names and path separators; surfaces describe failures', async () => {
const b = bench()
await expect(b.svc.createWorkspace(' ')).rejects.toThrow(/name is required/)
await expect(b.svc.createWorkspace('a/b')).rejects.toThrow(/path separators/)
b.api.onDescribe = () => Promise.resolve({
rpcId: 'e' as never,
result: { ok: false as const, error: { code: 'internal' as const, message: 'down', details: {} } },
} as never)
await expect(b.svc.createWorkspace('ok')).rejects.toThrow(/host.describe failed/)
})
})
describe('coverage tails (branch duals)', () => {
it('titleOf falls back to the id for empty and separator-only cwd', async () => {
it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => {
const b = bench()
await feedList(b, [{ id: 'no-base', cwd: '///' }, { id: 'empty-cwd', cwd: '' }])
const { byId } = b.svc.list.getSnapshot()
expect(byId[sid('no-base')]?.title).toBe('no-base')
expect(byId[sid('empty-cwd')]?.title).toBe('empty-cwd')
expect(byId[sid('no-base')]?.displayTitle).toBe('no-base')
expect(byId[sid('empty-cwd')]?.displayTitle).toBe('empty-cwd')
expect(byId[sid('no-base')]?.title).toBeUndefined()
})
it('binding for an unknown session returns undefined without moving the watch', async () => {
it('binding for an unknown session returns undefined and leaves the staged scope intact', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.binding(sid('s1'))
b.svc.open(sid('s1'))
expect(b.svc.binding(sid('ghost'))).toBeUndefined()
// Watch unchanged: removing s1 defers (still watched), proving the ghost lookup did not steal the watch.
// Stage unchanged: removing s1 defers (still staged), proving the ghost lookup touched nothing.
await feedList(b, [])
expect(b.svc.scope(sid('s1'))).toBeDefined()
})
it('sweep skips the id that is itself still watched and tolerates a scope record already gone', async () => {
it('a masked current gap holds the stage (no teardown, no re-open) until the stage moves', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.binding(sid('s1'))
await feedList(b, []) // deferred removal of the watched id
// Re-resolving the SAME watched id: sweep runs but must skip it (watched-continue branch).
expect(b.svc.binding(sid('s1'))).toBeDefined()
b.svc.open(sid('s1'))
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
expect(historyCalls()).toHaveLength(1)
await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred
expect(b.svc.scope(sid('s1'))).toBeDefined()
// Resurfacing re-projects current = s1: same stage occupant, no second pull.
await feedList(b, [{ id: 's1' }])
expect(historyCalls()).toHaveLength(1)
expect(b.svc.list.getSnapshot().current).toBe('s1')
})
it('sweep hits both deferral edges: watched-id skip and an already-vacated scope record', async () => {
it('sweep hits both deferral edges: staged-id skip and an already-vacated scope record', async () => {
const b = bench()
await feedList(b, [{ id: 'a' }, { id: 'b' }])
b.svc.binding(sid('a'))
b.svc.binding(sid('b')) // watch: b; both scoped
await feedList(b, []) // a removed unwatched → torn immediately; b removed watched → deferred
// Move the watch to a THIRD id while b stays deferred: sweep now walks a
// set containing b (torn) — and the watched-continue branch fires when the
// deferral set still holds the current watch target.
b.svc.scope(sid('a'))
b.svc.open(sid('b')) // stage: b; both scoped
await feedList(b, []) // a removed off stage → torn immediately; b removed staged → deferred
// Move the stage to a THIRD id while b stays deferred: sweep walks a set
// containing b (torn).
await feedList(b, [{ id: 'c' }])
b.svc.binding(sid('c'))
b.svc.open(sid('c'))
expect(b.svc.scope(sid('b'))).toBeUndefined()
// Deferral for an id whose record was never minted: force-add via removed
// list state (scope teardown raced) — sweep must tolerate the missing record.
await feedList(b, [])
b.svc.binding(sid('c')) // c now watched+removed → deferred
// Deferral for an id whose record was never minted: force the deferral
// via removed list state — sweep must tolerate the missing record.
await feedList(b, []) // c removed while staged → deferred (scope exists)
await feedList(b, [{ id: 'd' }])
b.svc.binding(sid('d')) // sweep tears c
b.svc.open(sid('d')) // sweep tears c
expect(b.svc.scope(sid('c'))).toBeUndefined()
})

View File

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

View File

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

View File

@@ -1,14 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
"outDir": "lib/types"
},
"include": [
"src"
@@ -26,6 +20,9 @@
{
"path": "../connection"
},
{
"path": "../../host/apiproxy"
},
{
"path": "../../llm/llm"
},

View File

@@ -1,23 +1,3 @@
import type { UserConfig } from 'tsdown'
import { clientBundle } from '../tsdown.client.ts'
/**
* Standard dual-entry shape plus the loader lib half: exports["./loader"]
* promises lib/loader.js (the web shell statically imports the machinery —
* a loader cannot load itself), and the shared preset only emits
* lib/{index,invariant}.js, so the extra config supplies it.
*/
const configs = clientBundle('@deepseek-ai/dsh-client-runtime', ['lib/types/index.js', 'lib/types/invariant.js'])
const loaderLib: UserConfig = {
entry: { loader: 'lib/types/client/loader/index.js' },
outDir: 'lib',
format: ['esm'],
platform: 'neutral',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
}
export default [...configs, loaderLib]
export default clientBundle('@deepseek-ai/dsh-client-runtime', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -1,6 +1,6 @@
/**
* Shared tsdown preset for UI plugin client bundles. Emits a closure-factory
* artifact: the bundle calls window.DSHClientProxy.loadPlugin({id, factory})
* artifact: the bundle calls window.__ModuleLoader__.load({id, factory})
* and resolves externals through the injected require (loader module table —
* cordis DI entities, no globals, no import map). CSS Modules are compiled by
* lightningcss inside the bundle: importing `x.module.css` yields the
@@ -11,6 +11,7 @@ import { readFile } from 'node:fs/promises'
import { basename, dirname, resolve as resolvePath } from 'node:path'
import type { UserConfig } from 'tsdown'
import { transform } from 'lightningcss'
import { PLATFORM_MODULES } from './web/src/platform.ts'
/**
* Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline
@@ -28,23 +29,20 @@ const CSS_VIRTUAL_SUFFIX = '.mjs'
*/
export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
/** Externals resolved from the loader module table (keep in sync with the shell's seeding list). */
export const CLIENT_EXTERNALS = [
'react',
'react-dom',
'react/jsx-runtime',
'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',
'@deepseek-ai/dsh-client-ui-layout/client',
'@deepseek-ai/dsh-client-ui-conversation/client',
'@deepseek-ai/dsh-client-ui-theme/client',
'@deepseek-ai/dsh-client-i18n/client',
]
/**
* Documented TEMPORARY exemption, not a platform module (hence not in
* platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
* shallowEqual) lives in runtime pending its promotion-time rehoming, and
* five importers (i18n, ui-layout, ui-conversation ×3) ride this single
* exemption. At runtime the lazy CJS table answers the require natively:
* runtime is an immediately-tier row, its factory is registered before any
* dependent bundle materializes. TODO(webload/store-rehome): remove with the
* store-engine relocation follow-up.
*/
const RUNTIME_STORE_EXEMPTION = '@deepseek-ai/dsh-client-runtime/client'
/** Externals resolved from the loader module table: the platform seed entries plus the documented runtime exemption. */
export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME_STORE_EXEMPTION]
/**
* Build the tsdown config for one UI plugin package: the node-half lib build
@@ -52,8 +50,8 @@ export const CLIENT_EXTERNALS = [
* the root workspace shape, so the lib half must be restated here — dropping
* it leaves the package without lib/index.js and the host Loader cannot
* import its node half.
* @param id - plugin id (package name), stamped into the loadPlugin handoff
* and onto the injected style tags.
* @param id - plugin id (package name), stamped into the __ModuleLoader__.load
* handoff and onto the injected style tags.
* @param libEntry - node-half entries, spelled at the call site so the
* package-invariants gate can see `lib/types/invariant.js` in each package's
* own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
@@ -80,7 +78,22 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
// Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing.
dts: false,
clean: false,
external: CLIENT_EXTERNALS,
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
@@ -88,24 +101,20 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
// opinion for table entries (external above wins), bundle everything else.
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
plugins: [{
// Bundle purity gate: a bare-name import of a module-table package would
// slip past CLIENT_EXTERNALS (which lists the /client form) and INLINE a
// second copy of that package — duplicate runtime identity (a second
// scope Symbol was tonight's white-screen root cause). Resolve-time is
// the earliest, most precise interception: rewrite bare table names to
// their /client form (the loader registers both specifiers), and reject
// any other @deepseek-ai/* leak that is not an inline-safe wire layer.
// Bundle purity gate (build-time mirror of the module-edge rules):
// platform seed entries stay external, inline-safe wire layers inline,
// and every other @deepseek-ai value import is a build error — a
// cross-plugin value import either inlines a duplicate runtime instance
// or requires a specifier the frozen module table cannot answer.
// Cross-plugin collaboration goes through cordis services instead.
name: 'dsh-client-bundle-purity',
resolveId(source: string) {
if (!source.startsWith('@deepseek-ai/')) return null
if (CLIENT_EXTERNALS.includes(source)) return null // external wins
if (CLIENT_EXTERNALS.includes(`${source}/client`)) {
return { id: `${source}/client`, external: true }
}
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
throw new Error(
`client bundle purity: "${source}" is not in CLIENT_EXTERNALS and not an inline-safe wire layer — `
+ 'import the /client form, add it to the module table, or it inlines a duplicate runtime instance',
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
)
},
}, {
@@ -144,7 +153,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
}],
outputOptions: {
entryFileNames: 'client.js',
banner: `window.DSHClientProxy.loadPlugin({ id: ${JSON.stringify(id)}, factory: (require) => {`,
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
footer: `return module.exports; } });`,
intro: 'var module = { exports: {} }; var exports = module.exports;',
},

View File

@@ -1,8 +1,16 @@
# @deepseek-ai/dsh-client-ui-conversation
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7.
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains.
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction.
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, tab read face, details/paging callbacks, startSession chain).
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
## Model Experience
@@ -18,5 +26,4 @@ None; this package neither assembles nor sends a provider request.
- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **Approval/question cards are display-only placeholders** — web-side answering (composer takeover panel) is the P-II approvals project.
- **Module-level toolview caches are single-bundle state** — the inject cache and registry maps must reach cross-bundle consumers through the package export surface and loader module table, never by a second bundle copy.
- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.

View File

@@ -24,6 +24,8 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-i18n",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-layout"
],
"platform": "web"
@@ -34,23 +36,25 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-i18n": "workspace:^",
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"files": [
"lib/index.js",

View File

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

View File

@@ -1,12 +1,13 @@
// AssistantMarkdown: renders assistant blocks in order — markdown text body,
// reasoning as the figma Think summary row (expand = indented gray text),
// other-block JSON fallback. Tool-call heads are NOT rendered here: the chat
// view groups them into tool rows via the toolview outlet (figma step-summary
// flow). Shared by finalized nodes and the streaming partial (pulse marker).
// view groups them into tool rows through its keyed toolview slot (figma
// step-summary flow). Shared by finalized nodes and the streaming partial
// (pulse marker).
import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import { IconThinkOutline14, JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { ToolRow } from './ToolRow.tsx'
import css from './AssistantMarkdown.module.css'
@@ -32,6 +33,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
summary={firstLine(text)}
body={text}
state={running ? 'running' : 'ok'}
expandOnRowClick
/>
)
}
@@ -42,7 +44,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
<div className={css.root} data-streaming={streaming || undefined}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MessageText key={i} text={block.text} />
case 'text': return <MarkdownText key={i} text={block.text} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
// Tool-call heads render as tool rows in the chat view's grouping pass.
case 'tool-call': return null

View File

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

View File

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

View File

@@ -16,13 +16,13 @@ export const PendingCard = memo(function PendingCard({ item }: PendingCardProps)
<div className={css.card}>
{item.kind === 'approval' ? (
<>
<div className={css.title}>等待审批:<span className={css.mono}>{item.toolName}</span></div>
{item.reason !== undefined && <div className={css.reason}>{item.reason}</div>}
<div className={css.title}>等待审批:<span className={css.mono}>{item.payload.toolName}</span></div>
{item.payload.reason !== undefined && <div className={css.reason}>{item.payload.reason}</div>}
</>
) : (
<>
<div className={css.title}>等待回答({item.questions.length} 题)</div>
<JsonBlock label="问题内容" payload={item.questions} />
<div className={css.title}>等待回答({item.payload.questions.length} 题)</div>
<JsonBlock label="问题内容" payload={item.payload.questions} />
</>
)}
<div className={css.hint}>请在原客户端处理(web 端作答后续里程碑提供)</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,43 +1,79 @@
// ConversationRoot: the conversation slot's skeleton (figma Header 39:27730 +
// Tab_Group + view area + composer). Zero framework imports — everything
// arrives via props from the inject factory: breadcrumb feed, view registry
// read face, per-view render, and the composer's draft/send choreography.
// The active view id lives in layout.viewFor (shell viewing state), read and
// written through injected accessors.
// Tab_Group + view area + composer). Pure component — everything arrives via
// props: the framework standard kit (useSession/sessionId/useSessions), the
// declared chat store's useStore/actions, the injected business face, and the
// renderSlot share for the declared 'conversation.view' child slot (views are
// slot entries; the active one renders via the list `only` filter) plus the
// renderSlotChain share for the 'conversation.composer' takeover chain.
// Breadcrumbs derive from useSessions with a pure parentId walk; the active
// view id lives in the chat store's `view` field (per-session by store scope).
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps } from '../contract/slots.ts'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import css from './ConversationRoot.module.css'
/**
* Full props = owner share (sessionId) & standard share (useSession) &
* injected share — composed by reference from the contract, never re-typed
* here (share-ownership rule).
*/
/** Full props = the automatic shares & injected share — composed by reference
* from the contract, never re-typed here (share-ownership rule). */
export type ConversationRootProps = ConversationSlotProps
/** Breadcrumb chain: walk parentId links (root ancestor first, self last;
* empty when unknown; a broken link stops the walk). Pure twin of the
* sessions service's ancestry — components derive, they don't subscribe. */
function deriveAncestry(list: SessionListState, id: SessionId): readonly SessionSummary[] {
const chain: SessionSummary[] = []
let cursor: SessionId | undefined = id
while (cursor !== undefined) {
const summary: SessionSummary | undefined = list.byId[cursor]
if (summary === undefined || chain.includes(summary)) break
chain.unshift(summary)
cursor = summary.parentId
}
return chain
}
export function ConversationRoot({
sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView,
sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain,
views, send, stop, open,
}: ConversationRootProps) {
useSyncExternalStore(views.subscribe, views.version)
const list = views.list()
const activeId = useActiveView() ?? 'chat'
const active = list.find(v => v.id === activeId) ?? list[0]
const tabs = views.list()
// The store's persisted view id may be stale (view plugin unloaded); the
// slot ledger is the runtime validator — unknown ids fall to the first view.
const activeId = useStore(s => s.view) ?? 'chat'
const active = tabs.find(v => v.id === activeId) ?? tabs[0]
const ancestry = useAncestry()
const draft = composer.useDraft()
const running = useSession(s => (s as { running: boolean }).running)
const removed = useSession(s => (s as { removed: boolean }).removed)
const promptError = useSession(s => (s as { promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null }).promptError)
const turns = useSession(s => countTurns(s as { nodes: readonly { kind: string }[] }))
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
const draft = useStore(s => s.draft)
const running = useSession(s => s.running)
const removed = useSession(s => s.removed)
const promptError = useSession(s => s.promptError)
const turns = useSession(s => countTurns(s))
const pending = useSession(s => s.pending)
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` }
// The default composer doubles as the chain's all-decline fallback: a
// pending wait with no registered takeover must still leave the input usable.
const composerBar = (
<InputBar
draft={draft}
running={running}
disabled={removed}
error={error}
variant="composer"
onDraftChange={actions.setDraft}
onSend={(mode) => { send(draft, mode) }}
onStop={stop}
/>
)
return (
<div className={css.root}>
<header className={css.header}>
@@ -52,9 +88,9 @@ 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}
{s.displayTitle}
</button>
</span>
)
@@ -65,16 +101,16 @@ export function ConversationRoot({
{/* Header button row (Fork / Session log / I/O Details): a P-I visual
placeholder registry slot is deferred — buttons land with their features. */}
</div>
{list.length > 1 && (
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">
{list.map(v => (
{tabs.map(v => (
<button
key={v.id}
type="button"
role="tab"
aria-selected={v.id === active?.id}
className={clsx(css.tab, v.id === active?.id && css.tabActive)}
onClick={() => { actions.openView(v.id) }}
onClick={() => { actions.setView(v.id) }}
>
{v.label}
</button>
@@ -84,19 +120,10 @@ export function ConversationRoot({
</header>
<div className={css.viewArea}>
{active !== undefined && renderView(active)}
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>
<InputBar
draft={draft}
running={running}
disabled={removed}
error={error}
variant="composer"
onDraftChange={composer.setDraft}
onSend={composer.send}
onStop={composer.stop}
/>
{renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })}
</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,6 +1,6 @@
/* NEW SESSION hero: headline over the shared InputBar card, centered in the
conversation column. The card is the same component as the composer —
only positioning lives here. */
/* NEW SESSION hero (figma Input_Bottom 75:8208): fish + title, workspace chip
above the shared InputBar card. The input itself is InputBar — only stack
geometry and the chip live here. */
.root {
display: flex;
@@ -11,58 +11,154 @@
padding: 24px;
}
/* figma hero group 34:10409: headline block sits 36px above the input card. */
.card {
/* Cap matches InputBar card width (800). Glow may paint past the sides. */
.stack {
display: flex;
flex-direction: column;
gap: 36px;
align-items: stretch;
/* figma 75:8208: 12 between title block / workspace / card. */
gap: 12px;
width: 100%;
max-width: 776px;
max-width: 800px;
overflow: visible;
}
/* figma 34:10411: fish + title row, gap 10, centered; title 26/32 wt600 (34:10414). */
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600; title block
keeps 36px below the headline before the flex gap. */
.headline {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding-bottom: 36px;
font-size: 26px;
line-height: 32px;
font-weight: 600;
color: var(--dsw-alias-label-primary);
}
/* figma 34:10412/10413: brand-blue vector. */
/* figma fish fill rides business blue. */
.fish {
flex: none;
color: var(--dsw-alias-state-business-primary);
}
.picker {
/* Workspace row sits 12px above the input card (figma y80 → y112). Glow is
centered on this block so it stays under the picker + InputBar together. */
.body {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
overflow: visible;
}
/* Design input 776 → glow SVG 1051×468 (ellipse 851×268 + blur pad). */
.glow {
position: absolute;
left: 50%;
top: 50%;
z-index: 0;
width: calc(100% * 1051 / 776);
aspect-ratio: 1051 / 468;
transform: translate(-50%, -50%);
pointer-events: none;
}
.body > :not(.glow) {
position: relative;
z-index: 1;
}
/* Must beat `.body > :not(.glow)` specificity so the open Menu (and its
right-hand submenu) paints above the InputBar card. */
.body > .workspaceRow {
z-index: 10;
display: flex;
align-items: center;
min-width: 0;
/* figma 75:8208 workspace row: px 8 above the card. */
padding-left: 8px;
}
.select,
.customInput {
max-width: 320px;
padding: 4px 10px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
/* Folder + label + chevron — transparent at rest; fill only on hover / open. */
.workspace {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
min-height: 28px;
padding: 0 8px;
border: none;
border-radius: 12px;
background: var(--dsw-alias-bg-base);
background: transparent;
color: var(--dsw-alias-label-primary);
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
font-weight: 500;
cursor: pointer;
}
.customInput {
width: 320px;
outline: none;
.workspace:hover,
.workspace[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
}
.customInput:focus {
/* Business blue, not brand-primary: that token resolves to ink in this sheet. */
border-color: var(--dsw-alias-state-business-primary);
.folder {
flex: none;
color: var(--dsw-alias-label-primary);
}
.workspaceLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
flex: none;
color: var(--dsw-alias-label-caption);
}
/* Workspace menu width tracks the longest basename in the Figma frame. */
.workspaceMenu :global([role='menu']) {
min-width: 240px;
}
/* Dialog field (figma 451:18655 Input): h44, r22, px 14, caption placeholder. */
.modalInput {
width: 100%;
height: 44px;
padding: 0 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 22px;
outline: none;
background: transparent;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-primary);
}
.modalInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.modalInput:focus {
border-color: var(--dsw-alias-state-business-primary);
}
.modalInput:disabled {
color: var(--dsw-alias-label-dimmed);
}
.modalAction {
min-width: 72px;
}
.modalError {
margin-top: 8px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-error-primary);
}

View File

@@ -1,32 +1,75 @@
// 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.
// EmptyState (figma NEW SESSION screen): centered hero — fish + title,
// workspace picker row (MenuDropdown 122:9481 + New Workspace submenu
// 419:16920 + Dialog 451:18655), then the SAME InputBar the resident
// composer uses (empty→content is a position move, never a swap). Project
// options derive in-component from useSessions; Create new runs
// createWorkspaceSession (host mkdir + session.create + open).
import { useState } from 'react'
import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
import { useId, useMemo, useState } from 'react'
import {
Button,
FishLogo,
IconChevronDownOutline14,
IconFolderClose16,
IconFolderOpen16,
IconPlusOutline16,
Menu,
Modal,
type MenuEntry,
} 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'
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'
/** Menu id for "New Workspace" (opens submenu; not a cwd). */
const NEW_WORKSPACE = '::new-workspace'
/** Submenu: path modal (figma 451:18655 copy). */
const USE_EXISTING = '::use-existing'
/** Submenu: create-workspace modal → mkdir + default session. */
const CREATE_NEW = '::create-new'
/** Full props composed by reference from the contract (owner & injected shares; root slot has no standard share). */
/** Which full-page dialog is open (null = none). */
type ModalKind = 'path' | 'create' | null
/** 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]
}
/** Basename for the workspace chip / menu row; empty → the design's "New Workspace" label. */
function workspaceLabel(cwd: string): string {
if (cwd === '') return 'New Workspace'
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
return base !== undefined && base !== '' ? base : cwd
}
export function EmptyState({ useSessions, startSession, createWorkspaceSession }: 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('')
const [cwd, setCwd] = useState<string>('')
const [custom, setCustom] = useState(false)
const [cwd, setCwd] = useState('')
const [menuOpen, setMenuOpen] = useState(false)
const [modalKind, setModalKind] = useState<ModalKind>(null)
const [pathDraft, setPathDraft] = useState('')
const [workspaceName, setWorkspaceName] = useState('New WorkSpace')
const [creating, setCreating] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [sending, setSending] = useState(false)
const [error, setError] = useState<InputBarError | null>(null)
// Stable filter id so multiple EmptyState mounts do not collide in the DOM.
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
const submit = (mode: 'queue' | 'steer'): void => {
const text = draft.trim()
@@ -35,72 +78,228 @@ 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 = (
<div className={css.picker}>
{custom
? (
<input
className={css.customInput}
value={cwd}
autoFocus
placeholder="目录路径,如 /home/me/proj"
onChange={(e) => { setCwd(e.target.value) }}
/>
)
: (
<select
className={css.select}
value={cwd}
aria-label="项目目录"
onChange={(e) => {
if (e.target.value === NEW_DIR) {
setCustom(true)
setCwd('')
} else {
setCwd(e.target.value)
}
}}
>
<option value="">默认目录</option>
{cwds.map(c => <option key={c} value={c}>{c}</option>)}
<option value={NEW_DIR}>新目录…</option>
</select>
)}
</div>
)
const items: MenuEntry[] = [
...cwds.map(c => ({
id: c,
label: workspaceLabel(c),
icon: <IconFolderClose16 size={16} />,
})),
...(cwds.length > 0 ? [{ type: 'separator' as const, id: 'sep-new' }] : []),
{
id: NEW_WORKSPACE,
label: 'New Workspace',
icon: <IconPlusOutline16 size={16} />,
submenu: [
{ id: USE_EXISTING, label: 'Use a existing folder' },
{ id: CREATE_NEW, label: 'Create new' },
],
},
]
const closeModal = (): void => {
if (creating) return
setModalKind(null)
setModalError(null)
}
const openPathModal = (): void => {
setPathDraft(cwd)
setModalError(null)
setModalKind('path')
}
const openCreateModal = (): void => {
setWorkspaceName('New WorkSpace')
setModalError(null)
setModalKind('create')
}
const confirmPath = (): void => {
const next = pathDraft.trim()
if (next === '') return
setCwd(next)
setModalKind(null)
}
const confirmCreate = (): void => {
if (creating) return
setCreating(true)
setModalError(null)
createWorkspaceSession(workspaceName)
.catch((reason: unknown) => {
setModalError(reason instanceof Error ? reason.message : String(reason))
setCreating(false)
})
// Success swaps this slot out for the new session body — no local cleanup.
}
const modalBusy = creating
const isPath = modalKind === 'path'
const isCreate = modalKind === 'create'
return (
<div className={css.root}>
<div className={css.card}>
<div className={css.stack}>
<div className={css.headline}>
{/* figma 34:10412: fish 34x25 leading the headline, gap 10. */}
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
<FishLogo size={34} className={css.fish} />
Let&apos;s start building
</div>
<InputBar
draft={draft}
running={false}
disabled={sending}
error={error}
variant="hero"
placeholder="Message to run task, plan and build"
accessory={picker}
onDraftChange={setDraft}
onSend={submit}
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
onStop={() => {}}
/>
<div className={css.body}>
{/* figma 313:14109: soft ellipse behind workspace + InputBar; width
tracks the card (glow asset 1051 vs design card 776) so blur
scales in userSpace with it. */}
<svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
<defs>
<filter
id={glowFilterId}
x="0"
y="0"
width="1051"
height="468"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
</filter>
</defs>
<g filter={`url(#${glowFilterId})`}>
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" />
</g>
</svg>
<div className={css.workspaceRow}>
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
{...(cwd !== '' ? { selectedId: cwd } : {})}
items={items}
side="top"
className={css.workspaceMenu!}
onSelect={(id) => {
if (id === USE_EXISTING) {
setMenuOpen(false)
openPathModal()
return
}
if (id === CREATE_NEW) {
setMenuOpen(false)
openCreateModal()
return
}
setCwd(id)
setMenuOpen(false)
}}
anchor={(
<button
type="button"
className={css.workspace}
aria-label="项目目录"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={() => { setMenuOpen(!menuOpen) }}
>
<IconFolderOpen16 className={css.folder} size={16} />
<span className={css.workspaceLabel}>{workspaceLabel(cwd)}</span>
<IconChevronDownOutline14 className={css.chevron} size={12} />
</button>
)}
/>
</div>
<InputBar
draft={draft}
running={false}
disabled={sending}
error={error}
variant="hero"
placeholder="Message to run task, plan and build, enter for / commands"
onDraftChange={setDraft}
onSend={submit}
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
onStop={() => {}}
/>
</div>
</div>
<Modal
open={isPath}
onClose={closeModal}
title="Enter an existing folder path"
footer={(
<>
<Button variant="outline" className={css.modalAction!} onClick={closeModal}>Cancel</Button>
<Button
variant="primary"
className={css.modalAction!}
disabled={pathDraft.trim() === ''}
onClick={confirmPath}
>
Open Folder
</Button>
</>
)}
>
<input
className={css.modalInput}
value={pathDraft}
aria-label="Folder path"
autoFocus
placeholder="ex. User/Documents/Harness/Space"
onChange={(e) => { setPathDraft(e.target.value) }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
confirmPath()
}
}}
/>
</Modal>
<Modal
open={isCreate}
onClose={closeModal}
title="Create new workspace"
footer={(
<>
<Button variant="outline" className={css.modalAction!} disabled={modalBusy} onClick={closeModal}>
Cancel
</Button>
<Button
variant="primary"
className={css.modalAction!}
disabled={modalBusy || workspaceName.trim() === ''}
onClick={confirmCreate}
>
Create
</Button>
</>
)}
>
<input
className={css.modalInput}
value={workspaceName}
aria-label="Workspace name"
autoFocus
disabled={modalBusy}
onChange={(e) => { setWorkspaceName(e.target.value) }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
confirmCreate()
}
}}
/>
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
</Modal>
</div>
)
}

View File

@@ -1,7 +1,7 @@
/* Floating capsule input (figma Input_Bottom 34:11445): card floats above the
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
viewport bottom inside the centered message column; textarea on top, action
row below, one primary circle button bottom-right. Input width rides the
column (776 is a cap, not a fixed size — layout rule: the box shrinks with
column (800 is a cap, not a fixed size — layout rule: the box shrinks with
the center column keeping its padding). Hero variant = the same card
centered in the empty state; the transition between the two is a position
move of one component. */
@@ -10,8 +10,8 @@
display: flex;
flex-direction: column;
align-items: center;
/* figma Input_Bottom 34:11445: pad L32/R32/B12; the bottom gradient mask is
owned by the chat scroller. Top 8 hosts the error strip's breathing room. */
/* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by
the chat scroller. Top 8 hosts the error strip's breathing room. */
padding: 8px 32px 12px;
}
@@ -21,7 +21,7 @@
.error {
width: 100%;
max-width: 776px;
max-width: 800px;
margin-bottom: 6px;
padding: 4px 8px;
border-radius: 8px;
@@ -34,10 +34,12 @@
.card {
display: flex;
flex-direction: column;
/* figma Input 34:11458: 12px between the text area and the button row. */
/* figma Input 75:8208: 12px between the text area and the button row; 10px
top pad on the card before .InputText. */
gap: 12px;
width: 100%;
max-width: 776px;
max-width: 800px;
padding-top: 10px;
/* Input stroke: black/0.10 light, white/0.06 dark (figma darkmode note says
the input border is one notch weaker than buttons) — exactly the
l2-darkmode-thin pair. Fill: the input surface token (elevated in dark). */
@@ -49,11 +51,6 @@
line-height: 24px;
}
/* New-session state rounds up (figma: r24 and a taller box). */
.hero .card {
border-radius: 24px;
}
.accessory {
display: flex;
align-items: center;
@@ -85,7 +82,8 @@
.input,
.mirror {
padding: 12px 16px 0;
/* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. */
padding: 4px 12px 0 16px;
font-size: inherit;
line-height: inherit;
white-space: pre-wrap;
@@ -108,23 +106,98 @@
.mirror {
visibility: hidden;
pointer-events: none;
/* 2-line floor: 2 × 24px line + 12px top padding; 14-line cap (336px). */
min-height: 60px;
/* figma min-h 52 (= ~2 × 24 line + 4pt); 14-line cap (336px). */
min-height: 52px;
max-height: 336px;
overflow: hidden;
}
.hero .mirror {
/* New-session box is taller at rest (figma 118px input area). */
min-height: 84px;
}
/* figma Frame 1123 (34:11463): pad 12/0/10/10, buttons vertically centered. */
/* Toolbar: attach + Plan + Read-only on the left; model + send on the right
(figma Input_Bottom chrome). */
.row {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 10px 10px 12px;
justify-content: space-between;
gap: 12px;
padding: 0 10px 10px 10px;
min-width: 0;
}
.tools,
.modes,
.trailing {
display: flex;
align-items: center;
min-width: 0;
}
/* figma 75:8208: 16 between + and the mode chips; 4 between Plan / Read-only. */
.tools {
gap: 16px;
}
.modes {
gap: 4px;
}
.trailing {
flex: none;
gap: 12px;
}
/* Attach circle (figma + control): 28px, selector fill, primary glyph. */
.add {
display: grid;
place-items: center;
flex: none;
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
background: var(--dsw-specific-selector);
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
.add:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-solid);
}
.add:disabled {
opacity: 0.5;
cursor: default;
}
/* Plan / Read-only / model — native <select>, chip-like closed chrome
(figma ToggleButton: 13/20 medium secondary, 12px chevron). */
.select {
max-width: 220px;
height: 28px;
padding: 0 20px 0 8px;
border: none;
border-radius: 8px;
outline: none;
background-color: transparent;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 4px center;
background-size: 12px 12px;
color: var(--dsw-alias-label-secondary);
font-size: 13px;
line-height: 20px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
appearance: none;
}
.select:hover:not(:disabled) {
background-color: var(--dsw-alias-interactive-bg-hover);
}
.select:disabled {
opacity: 0.5;
cursor: default;
}
/* Primary send (figma IconButton 34:10465): 34px circle, #3964FE light /
@@ -133,6 +206,7 @@
.primary {
display: grid;
place-items: center;
flex: none;
width: 34px;
height: 34px;
border: none;

View File

@@ -4,10 +4,14 @@
// position move of this component, never a swap (layout ruling). Running
// LOCKS the input: textarea disabled with the draft visible, stop is the only
// action; the turn ending re-enables and refocuses.
//
// Bottom chrome (attach / Plan / Read-only / model) is visual-only for now —
// local native <select> state, no host wiring.
import { useEffect, useRef } from 'react'
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './InputBar.module.css'
/** Prompt failure surface (mirrors the session snapshot's promptError shape). */
@@ -24,13 +28,33 @@ export interface InputBarProps {
/** Hero = empty-state centered card; composer = resident bottom bar. */
variant: 'hero' | 'composer'
placeholder?: string
/** Optional leading accessory row content (the empty state mounts its cwd picker here). */
/** Optional leading accessory row above the textarea (kept for callers; empty state no longer uses it). */
accessory?: ReactNode
onDraftChange: (text: string) => void
onSend: (mode: 'queue' | 'steer') => void
onStop: () => void
}
interface SelectOption {
id: string
label: string
}
const PLAN_OPTIONS: readonly SelectOption[] = [
{ id: 'plan', label: 'Plan' },
{ id: 'agent', label: 'Agent' },
]
const READONLY_OPTIONS: readonly SelectOption[] = [
{ id: 'readonly', label: 'Read-only' },
{ id: 'readwrite', label: 'Read-write' },
]
const MODEL_OPTIONS: readonly SelectOption[] = [
{ id: 'v4-pro-high', label: 'DeepSeek-V4-Pro High' },
{ id: 'v4-pro', label: 'DeepSeek-V4-Pro' },
]
export function InputBar({
draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop,
}: InputBarProps) {
@@ -48,6 +72,11 @@ export function InputBar({
}, 10)
}
// Placeholder chrome: selection is local until plan/mode/model seams land.
const [planId, setPlanId] = useState('plan')
const [readonlyId, setReadonlyId] = useState('readonly')
const [modelId, setModelId] = useState('v4-pro-high')
// Locked while running: the browser drops keystrokes AND focus on a disabled
// textarea — no sending mid-turn, stop or wait.
const locked = disabled || running
@@ -88,6 +117,25 @@ export function InputBar({
if (!empty && !disabled) onSend('queue')
}
const renderSelect = (
aria: string,
value: string,
options: readonly SelectOption[],
onPick: (id: string) => void,
): ReactNode => (
<select
className={css.select}
aria-label={aria}
value={value}
disabled={locked}
onChange={(e: ChangeEvent<HTMLSelectElement>) => { onPick(e.target.value) }}
>
{options.map(opt => (
<option key={opt.id} value={opt.id}>{opt.label}</option>
))}
</select>
)
return (
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
{error !== null && (
@@ -116,25 +164,44 @@ export function InputBar({
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
</div>
<div className={css.row}>
<button
type="button"
className={clsx(css.primary, running && css.stopping)}
aria-label={primaryLabel}
title={running ? '停止本轮' : '发送(Enter)'}
disabled={!running && (empty || disabled)}
onMouseDown={keepFocus}
onClick={onPrimary}
>
{running ? (
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
<path d="M8 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
)}
</button>
<div className={css.tools}>
<button
type="button"
className={css.add}
aria-label="添加"
title="添加"
disabled={locked}
onMouseDown={keepFocus}
>
<IconPlusOutline16 size={14} />
</button>
<div className={css.modes}>
{renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)}
{renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)}
</div>
</div>
<div className={css.trailing}>
{renderSelect('Model', modelId, MODEL_OPTIONS, setModelId)}
<button
type="button"
className={clsx(css.primary, running && css.stopping)}
aria-label={primaryLabel}
title={running ? '停止本轮' : '发送(Enter)'}
disabled={!running && (empty || disabled)}
onMouseDown={keepFocus}
onClick={onPrimary}
>
{running ? (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<path d="M8 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
)}
</button>
</div>
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,20 +3,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,
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConvViewProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatView } from '../src/client/chat/ChatView.tsx'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '../src/client/stores.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
import { deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
// Keyless create() persists under the bare declared key; clear between cases
// so one harness's selection cannot rehydrate into the next.
beforeEach(() => {
localStorage.clear()
})
const SID = 's1' as SessionId
@@ -48,53 +54,56 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
}
const user = (seq: number, text: string): UserMessageNode => ({
kind: 'user', seq, content: [{ type: 'text', text }] as never, source: null,
kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text }] as never, source: null,
})
const assistant = (seq: number, text: string): AssistantMessageNode => ({
kind: 'assistant', seq, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
})
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
kind: 'tool-result', seq, callId,
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null,
})
const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, callView: null,
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
})
/** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
return bindSnapshotSelector(store)
}
function makeHarness(init?: Partial<ConversationSnapshot>) {
const { set, source } = makeSource(init)
const registry = new ToolViewRegistry()
const ChatView = createChatView({ toolviews: registry, t: (k) => k })
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const loadOlder = vi.fn()
const selection = makeSelection()
const props: ConvViewProps = {
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the PropsStore useStore share).
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
// every tool lands on GenericToolCard); keyed dispatch to registered rows
// is the slot machinery's behavior, covered by its own specs.
const chat = createChatStore().create()
const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot']
// SessionProvider seat arrives with the session-scope child declaration;
// ChatView never invokes it (render-prop pass-through stub).
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
const props: ChatViewSlotProps = {
sessionId: SID,
useSession: bindSnapshotSelector(source) as unknown as UseSession,
useSelection: bindSnapshotSelector(selection.source),
actions: { openDetails, loadOlder },
slots: { renderSlot: () => null } as never,
}
return { set, registry, ChatView, props, openDetails, loadOlder, setSelection: selection.set }
}
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)
},
},
useSession: bindSnapshotSelector(source),
useSessions: emptySessions(),
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
renderSlot,
SessionProvider: SessionProviderStub,
openDetails,
loadOlder,
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return { set, ChatView, props, openDetails, loadOlder, setSelection }
}
describe('chat-flow derivation', () => {
@@ -150,6 +159,44 @@ describe('ChatView', () => {
expect(view.getByText('run a')).toBeTruthy()
})
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
const markdown = '# Rendered\n\n- **one**\n- `two`'
const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })
const view = render(<h.ChatView {...h.props} />)
expect(view.container.querySelectorAll('h1')).toHaveLength(1)
const literal = view.getByText((_content, element) => (
element?.tagName === 'DIV' && element.childElementCount === 0 && element.textContent === markdown
))
expect(literal.querySelector('h1')).toBeNull()
act(() => {
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: markdown }] } })
})
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
expect(view.container.querySelector('[data-streaming="true"] h1')?.textContent).toBe('Rendered')
act(() => {
h.set({
nodes: [user(1, markdown), assistant(2, markdown), assistant(3, markdown)],
partial: null,
})
})
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
expect(view.container.querySelector('[data-streaming="true"]')).toBeNull()
act(() => {
h.set({
nodes: [
user(1, markdown),
assistant(2, markdown),
{ ...assistant(3, markdown), interrupted: true },
],
})
})
expect(view.getByText('已停止')).toBeTruthy()
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
})
it('streaming partial frames re-render only the tail (Profiler count)', () => {
const h = makeHarness({
nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')],
@@ -179,11 +226,13 @@ describe('ChatView', () => {
const h = makeHarness({
nodes: [user(1, 'q'), assistant(2, 'old'), toolResult(3, 'a')],
})
// Count renderSlot invocations: the memo boundary holds when CallRow does
// not re-render, so the row's renderSlot call count freezes during chunks.
let rowRenders = 0
h.registry.register('bash', () => {
h.props.renderSlot = (((_key: string, _owner: object) => {
rowRenders += 1
return <div data-testid="counting-row" />
})
}) as unknown as ChatViewSlotProps['renderSlot'])
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('counting-row')).toBeTruthy()
const afterMount = rowRenders
@@ -221,21 +270,19 @@ describe('ChatView', () => {
expect(view.getByText('cmd-r1')).toBeTruthy()
})
it('a scoped toolview registration takes over rendering for its session only', () => {
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
h.registry.register('bash', () => <div data-testid="custom-bash" />, { scope: (id) => id === SID })
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('custom-bash')).toBeTruthy()
})
it('unregistering a toolview falls back to the generic row live', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const off = h.registry.register('bash', () => <div data-testid="custom-bash" />)
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('custom-bash')).toBeTruthy()
act(() => off())
expect(view.queryByTestId('custom-bash')).toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
const calls: { key: string; entryKey?: string }[] = []
h.props.renderSlot = (((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
return opts?.fallback ?? null
}) as unknown as ChatViewSlotProps['renderSlot'])
render(<h.ChatView {...h.props} />)
// Keyed dispatch: slot name is the declared hole, entryKey the wire tool
// name, and the fallback (GenericToolCard) renders on an empty ledger.
// (Registered-row takeover and live unload are slot machinery behavior,
// owned by the slot system's own specs.)
expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }])
})
it('prepend compensates scrollTop by the height delta; a trailing user node force-scrolls', () => {
@@ -297,7 +344,8 @@ describe('ChatView', () => {
it('pending interactions render placeholder cards', () => {
const h = makeHarness({
pending: [{ kind: 'approval', rpcId: 'r1' as never, approvalId: 'ap1', toolName: 'bash' }],
pending: [new PendingWait('approval', RpcId('r1'), SID,
{ approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())],
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText(/等待审批/)).toBeTruthy()

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,10 @@ function setup(over?: Partial<InputBarProps>) {
}
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector('textarea')!
const button = view.container.querySelector('button')!
// aria-label (not role name): title also contains 发送/停止 and would double-match.
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${over?.running === true ? '停止' : '发送'}"]`,
)!
return { view, textarea, button, props }
}
@@ -97,7 +100,7 @@ describe('running lock and primary button', () => {
const textarea = view.container.querySelector('textarea')!
expect(document.activeElement).toBe(textarea)
textarea.blur()
fireEvent.mouseDown(view.container.querySelector('button')!)
fireEvent.mouseDown(view.container.querySelector('button[aria-label="发送"]')!)
expect(document.activeElement).toBe(textarea)
})
@@ -129,3 +132,38 @@ describe('error strip and variants', () => {
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
})
})
describe('placeholder chrome', () => {
it('renders attach / Plan / Read-only / model controls', () => {
const { view } = setup()
expect(view.getByLabelText('添加')).toBeTruthy()
expect((view.getByLabelText('Plan mode') as HTMLSelectElement).value).toBe('plan')
expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly')
expect((view.getByLabelText('Model') as HTMLSelectElement).value).toBe('v4-pro-high')
})
it('native select change updates the selected option', () => {
const { view } = setup()
const plan = view.getByLabelText('Plan mode') as HTMLSelectElement
fireEvent.change(plan, { target: { value: 'agent' } })
expect(plan.value).toBe('agent')
const access = view.getByLabelText('Access mode') as HTMLSelectElement
fireEvent.change(access, { target: { value: 'readwrite' } })
expect(access.value).toBe('readwrite')
})
it('model select can drop the High option', () => {
const { view } = setup()
const model = view.getByLabelText('Model') as HTMLSelectElement
fireEvent.change(model, { target: { value: 'v4-pro' } })
expect(model.value).toBe('v4-pro')
expect(model.selectedOptions[0]?.textContent).toBe('DeepSeek-V4-Pro')
})
it('running locks the chrome selects and attach control', () => {
const { view } = setup({ running: true })
expect((view.getByLabelText('添加') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('Plan mode') as HTMLSelectElement).disabled).toBe(true)
expect((view.getByLabelText('Model') as HTMLSelectElement).disabled).toBe(true)
})
})

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,74 +67,116 @@ 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 display-title-upgrading list refresh keeps instance identity and the selection value', async () => {
const b = bench()
// First-send shape: client-side create inserts the row without cwd (title = bare id).
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s1') }))
const id = await b.sessions.create({})
await flush()
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('s1')
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 's1' })
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
const binding = b.sessions.binding(id)
expect(binding).toBeDefined()
const scoped = b.sessions.scope(id)!
const store = (scoped.get('conversation') as ConversationService).selection
store.set({ turnSeq: 3, callId: 'c1' })
const store = storeFor(b, 'conversation', id)
store.actions.select({ turnSeq: 3, callId: 'c1' })
store.actions.setDraft('half-typed')
// The late list refresh lands (host knows the cwd → formal title).
// The late list refresh lands (host knows the cwd → better fallback label).
feed(b, [{ id: 's1', cwd: '/w/proj-a' }])
await b.sessions.manager.refreshList()
await flush()
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('proj-a')
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 'proj-a' })
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
// Scope, binding and the selection account must all be identity-stable.
expect(b.sessions.scope(id)).toBe(scoped)
expect(b.sessions.binding(id)).toBe(binding)
const after = (b.sessions.scope(id)!.get('conversation') as ConversationService).selection
const after = storeFor(b, 'conversation', id)
expect(after).toBe(store)
expect(after.getSnapshot()).toEqual({ turnSeq: 3, callId: 'c1' })
expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
expect(after.store.getSnapshot().draft).toBe('half-typed')
})
it('reconnect (handleConnected: refreshList + resync) keeps the selection account', async () => {
it('session death buries the instance and its persisted draft', async () => {
const b = bench()
feed(b, [{ id: 's1' }])
feed(b, [{ id: 's1' }, { id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
const scoped = b.sessions.scope(sid('s1'))!
const store = (scoped.get('conversation') as ConversationService).selection
store.set({ turnSeq: 1, callId: 'c9' })
// Mint the scope (store prune rides the scope-teardown axis: no scope,
// no teardown — the real page always resolves the binding to render).
b.sessions.binding(sid('s1'))
const doomed = storeFor(b, 'conversation', sid('s1'))
doomed.actions.setDraft('to be buried')
doomed.actions.select({ turnSeq: 1 })
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
// Reconnect generation: 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,11 @@
// @vitest-environment jsdom
/**
* ConversationService orchestration half: scope-addressed send/cancel (result
* folding, root throw), openDetails choreography, the startSession chain, and
* the service-unavailable loud failures. Store semantics live in
* service-stores.spec.ts.
* ConversationService orchestration half after the store-seat slimming:
* scope-addressed send/cancel (result folding, root throw), the startSession
* chain (create → sessions.open → scoped send), and the service-unavailable
* loud failures. Selection/draft state left this service for the declared
* chat store (chat-store.spec.ts / selection-survival.spec.ts); the view
* registry left for the 'conversation.view' slot (views-type-chain.spec.tsx).
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
@@ -13,7 +15,7 @@ import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/cli
const sid = (s: string): SessionId => s as SessionId
/** Recover the module-private scope tag through the public seam (same probe as service-stores.spec). */
/** Recover the module-private scope tag through the public seam (same probe as apply-inject.spec). */
const SCOPE_TAG: symbol = (() => {
const recorded: (string | symbol)[] = []
const spy = new Proxy(new Context(), {
@@ -33,7 +35,7 @@ interface SessionDouble {
cancel: ReturnType<typeof vi.fn>
}
async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
async function bench(opts?: { sessions?: boolean }) {
const ctx = new Context()
const sessionDoubles = new Map<SessionId, SessionDouble>()
const scopes = new Map<SessionId, Context>()
@@ -47,6 +49,7 @@ async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
return scoped
}
const createMock = vi.fn(() => Promise.resolve(sid('new-1')))
const openMock = vi.fn()
const sessionsFake = {
manager: {
get: (id: SessionId) => {
@@ -62,16 +65,17 @@ async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
},
},
create: createMock,
open: openMock,
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
scopeOf,
} as unknown as SessionsService
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
const layoutFake = { open: vi.fn(), openDetails: vi.fn() }
if (opts?.layout !== false) ctx.provide('layout', layoutFake)
const fiber = ctx.plugin((pluginCtx) => { void new ConversationService(pluginCtx) })
// Class-plugin mount — the same form apply.ts uses in production.
const fiber = ctx.plugin(ConversationService)
await fiber.await()
const svc = ctx.get('conversation') as ConversationService
const scopedSvc = (id: SessionId) => mint(id).get('conversation') as ConversationService
return { ctx, svc, scopedSvc, mint, sessionDoubles, sessionsFake, createMock, layoutFake }
return { ctx, svc, scopedSvc, mint, sessionDoubles, sessionsFake, createMock, openMock }
}
describe('send / cancel', () => {
@@ -109,22 +113,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 +142,6 @@ describe('service-unavailable loud failures', () => {
await expect(b.svc.startSession({ text: 't', mode: 'queue' })).rejects.toThrow(/sessions service unavailable/)
})
it('throws when layout is missing', async () => {
const b = await bench({ layout: false })
const s = b.scopedSvc(sid('s1'))
expect(() => { s.openDetails({ turnSeq: 1 }) }).toThrow(/layout service unavailable/)
})
it('startSession fails loud when the new scope cannot resolve conversation', async () => {
const b = await bench()
// A scope minted outside the service tree: scoped.get('conversation') finds nothing.
@@ -164,28 +152,3 @@ describe('service-unavailable loud failures', () => {
.rejects.toThrow(/conversation service unavailable through the new scope/)
})
})
describe('views ordering and draft persistence branches', () => {
it('orders by explicit order with undefined treated as zero (both comparator arms)', async () => {
const b = await bench()
const entry = (id: string, order?: number) => ({
id, label: id, component: () => null,
...(order !== undefined ? { order } : {}),
})
b.svc.registerView(entry('z-late', 5) as never)
b.svc.registerView(entry('default-zero') as never)
b.svc.registerView(entry('first', -1) as never)
expect(b.svc.views().map(v => v.id)).toEqual(['first', 'default-zero', 'z-late'])
})
it('draft store round-trips through localStorage and removes the key when emptied', async () => {
const b = await bench()
localStorage.setItem('dsh.conversation.draft.s9', 'restored')
const s = b.scopedSvc(sid('s9'))
expect(s.drafts.getSnapshot()).toBe('restored')
s.drafts.set('typed')
expect(localStorage.getItem('dsh.conversation.draft.s9')).toBe('typed')
s.drafts.set('')
expect(localStorage.getItem('dsh.conversation.draft.s9')).toBeNull()
})
})

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