Merge remote-tracking branch 'origin/master' into fix/tui-cwd-first-frame-race
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -9,7 +9,7 @@ Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-<nam
|
||||
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.
|
||||
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.
|
||||
@@ -20,9 +20,9 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
|
||||
|
||||
The `/client` surface of a UI plugin package is a contract face, not a convenience barrel. Three rules, enforced package-wide (do not restate them as per-file comments):
|
||||
|
||||
1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType<typeof createXXXStore>`). Types are the extra allowance: contract types (owner shares, injected shapes, view/toolview entry types) export freely. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer.
|
||||
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, the view and toolview registries) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself.
|
||||
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)
|
||||
|
||||
@@ -45,7 +45,7 @@ Non-negotiables across the layers:
|
||||
|
||||
## Directory regime (plugin packages)
|
||||
|
||||
One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through the slot/view/toolview registries in `apply` — never module-level side effects.
|
||||
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
|
||||
|
||||
|
||||
@@ -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: {} },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +62,19 @@ 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' } } })
|
||||
}
|
||||
@@ -49,7 +83,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
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)
|
||||
@@ -159,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
|
||||
@@ -253,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>>()
|
||||
@@ -298,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. */
|
||||
@@ -326,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))
|
||||
@@ -343,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)
|
||||
@@ -410,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) => {
|
||||
@@ -433,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,
|
||||
@@ -446,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 {
|
||||
@@ -475,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 })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
19
packages/client/hmr/README.md
Normal file
19
packages/client/hmr/README.md
Normal 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.
|
||||
51
packages/client/hmr/package.json
Normal file
51
packages/client/hmr/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
191
packages/client/hmr/src/client/index.ts
Normal file
191
packages/client/hmr/src/client/index.ts
Normal 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')
|
||||
}
|
||||
9
packages/client/hmr/src/index.ts
Normal file
9
packages/client/hmr/src/index.ts
Normal 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 {}
|
||||
33
packages/client/hmr/src/invariant.ts
Normal file
33
packages/client/hmr/src/invariant.ts
Normal 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 */
|
||||
14
packages/client/hmr/tests/node-half.spec.ts
Normal file
14
packages/client/hmr/tests/node-half.spec.ts
Normal 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
|
||||
})
|
||||
})
|
||||
30
packages/client/hmr/tsconfig.json
Normal file
30
packages/client/hmr/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/hmr/tsdown.config.ts
Normal file
3
packages/client/hmr/tsdown.config.ts
Normal 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'])
|
||||
@@ -27,10 +27,6 @@
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^"
|
||||
|
||||
20
packages/client/modules/README.md
Normal file
20
packages/client/modules/README.md
Normal 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.
|
||||
37
packages/client/modules/package.json
Normal file
37
packages/client/modules/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
175
packages/client/modules/src/index.ts
Normal file
175
packages/client/modules/src/index.ts
Normal 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)
|
||||
}
|
||||
34
packages/client/modules/src/invariant.ts
Normal file
34
packages/client/modules/src/invariant.ts
Normal 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 */
|
||||
239
packages/client/modules/src/loader.ts
Normal file
239
packages/client/modules/src/loader.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
306
packages/client/modules/tests/loader.spec.ts
Normal file
306
packages/client/modules/tests/loader.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
24
packages/client/modules/tsconfig.json
Normal file
24
packages/client/modules/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
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
|
||||
|
||||
None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request.
|
||||
@@ -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.
|
||||
|
||||
@@ -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,6 +33,7 @@
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"immer": "^10.1.1",
|
||||
@@ -56,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"
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
|
||||
* SlotsService (declaration ledger + renderer seam + store axis, built-in
|
||||
* 'root'), SessionsService (list store + current selection + scope tree +
|
||||
* object layer), the ClientLoader interface, and the cordis Context/Events
|
||||
* merges. apply
|
||||
* mounts ctx.slots + ctx.sessions and wires the connection stream loop into
|
||||
* the object layer. The loader machinery implementation is NOT in the plugin
|
||||
* bundle — it ships via the package's `./loader` subpath, statically held by
|
||||
* the web shell (a loader cannot load itself).
|
||||
* 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, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from './contract/store.ts'
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
@@ -34,9 +32,12 @@ export type {
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
|
||||
PendingInteraction, 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:
|
||||
@@ -51,7 +52,7 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
*/
|
||||
export type ClientContext = Context
|
||||
|
||||
/** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */
|
||||
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
|
||||
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
|
||||
|
||||
/**
|
||||
@@ -92,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']
|
||||
|
||||
|
||||
@@ -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 '../contract/store.ts'
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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). */
|
||||
@@ -121,11 +122,6 @@ export interface RunningToolCall {
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
79
packages/client/runtime/src/client/sessions/pending.ts
Normal file
79
packages/client/runtime/src/client/sessions/pending.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@
|
||||
* 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'
|
||||
@@ -24,7 +25,10 @@ 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
|
||||
@@ -61,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
|
||||
@@ -97,9 +102,14 @@ export class SessionsService {
|
||||
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>()
|
||||
|
||||
/**
|
||||
@@ -115,6 +125,13 @@ export class SessionsService {
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -152,35 +169,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 record.binding
|
||||
return this.resolve(id)?.binding
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the render-layer session cell (SessionProvider's feed through
|
||||
* the renderer host; ctx never enters the render layer). Marks the session
|
||||
* watched, same as {@link SessionsService.binding}.
|
||||
* 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 {
|
||||
const record = this.resolve(id as SessionId)
|
||||
if (record === undefined) return undefined
|
||||
if (this.watched !== id) {
|
||||
this.watched = id as SessionId
|
||||
this.sweepDeferred()
|
||||
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.cell
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,9 +275,10 @@ 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 } : {}),
|
||||
}
|
||||
@@ -246,7 +291,7 @@ export class SessionsService {
|
||||
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
|
||||
@@ -268,11 +313,11 @@ export class SessionsService {
|
||||
this.rootCtx.get('slots')?.pruneStoreScope(id)
|
||||
}
|
||||
|
||||
/** Run deferred teardowns whose session is no longer watched (called when the watch moves). */
|
||||
/** 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.)
|
||||
|
||||
@@ -5,12 +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 {
|
||||
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, PendingInteraction, PromptError, RunningToolCall,
|
||||
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'
|
||||
@@ -183,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 = []
|
||||
@@ -229,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
|
||||
}
|
||||
@@ -295,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> {
|
||||
|
||||
@@ -66,6 +66,10 @@ interface ErasedRegisterOptions {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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). */
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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']>>>()
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* SessionsService: list store projection (manager → {ids, byId, current}
|
||||
* with derived titles), the migrated current-selection account (open
|
||||
* validation, persisted mask semantics, cell resolution), scope-tree
|
||||
* lifecycle (lazy mint / frozen survival / removed teardown with watch
|
||||
* deferral), binding identity, ancestry walk, create.
|
||||
* 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 { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -40,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 () => {
|
||||
@@ -76,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()
|
||||
})
|
||||
|
||||
@@ -106,10 +112,10 @@ 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)
|
||||
})
|
||||
})
|
||||
@@ -168,15 +174,52 @@ describe('cell (render-layer session kit)', () => {
|
||||
expect(b.svc.cell('ghost')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('moves the watch like binding(): switching cells sweeps a deferred removal', async () => {
|
||||
it('cell()/binding() are pure resolution: no staging, no deferred sweep', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.cell('s1') // watched
|
||||
await feedList(b, []) // removed while watched → deferred, scope survives
|
||||
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()
|
||||
await feedList(b, [{ id: 's2' }])
|
||||
b.svc.cell('s2') // watch moves → sweep tears s1 down
|
||||
expect(b.svc.scope(sid('s1'))).toBeUndefined()
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -187,12 +230,13 @@ describe('slot-store scope prune hook', () => {
|
||||
b.ctx.reflect.provide('slots', { pruneStoreScope })
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
b.svc.scope(sid('s1'))
|
||||
b.svc.binding(sid('s2')) // s2 watched
|
||||
await feedList(b, []) // s1 unwatched → immediate drop; s2 watched → deferred
|
||||
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.binding(sid('s3')) // watch moves → deferred sweep drops s2
|
||||
b.svc.open(sid('s3')) // stage moves → deferred sweep drops s2
|
||||
expect(pruneStoreScope).toHaveBeenCalledWith('s2')
|
||||
})
|
||||
|
||||
@@ -234,52 +278,55 @@ describe('create', () => {
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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,22 +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-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
|
||||
@@ -51,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).
|
||||
@@ -79,7 +78,7 @@ 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
|
||||
@@ -102,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)',
|
||||
)
|
||||
},
|
||||
}, {
|
||||
@@ -158,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;',
|
||||
},
|
||||
|
||||
@@ -1,12 +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 plus the slot terminal design (store seat / props shares).
|
||||
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
|
||||
|
||||
The 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.
|
||||
|
||||
Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to both the conversation and details registrations, so the two session slots share one instance per session (selection written by conversation, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, view registry read face, startSession chain).
|
||||
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).
|
||||
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
|
||||
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
|
||||
|
||||
@@ -22,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.
|
||||
|
||||
@@ -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,22 +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:^",
|
||||
"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",
|
||||
|
||||
@@ -1,39 +1,32 @@
|
||||
/**
|
||||
* 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 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).
|
||||
* 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 type { Context } from 'cordis'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId, SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
|
||||
import type { SelectionTarget } from './contract/views.ts'
|
||||
import type { ConversationInjected, DetailsInjected, EmptyStateInjected } from './contract/slots.ts'
|
||||
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 {
|
||||
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']
|
||||
|
||||
/** 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
|
||||
}
|
||||
export const inject = ['slots', 'layout', 'sessions']
|
||||
|
||||
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
|
||||
@@ -49,48 +42,51 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat
|
||||
* @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 conversation = new ConversationService(ctx)
|
||||
const toolviews = new ToolViewRegistry()
|
||||
ctx.provide('toolviews', toolviews)
|
||||
|
||||
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')
|
||||
const sessions = ctx.sessions
|
||||
const layout = ctx.layout
|
||||
const slots = ctx.slots
|
||||
|
||||
// Shared store handle, constructed here so its identity lives and dies with
|
||||
// this fiber (a module-level handle would be a de-facto singleton). Both
|
||||
// session-slot registrations declare it; same scope key = same instance, so
|
||||
// conversation writes and details reads meet in one store.
|
||||
const chat = createChatStore()
|
||||
// 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()
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Conversation occupant. Declaring the view ring here is claiming it:
|
||||
// ConversationRoot is the only component authorized to render the ring.
|
||||
slots.register({
|
||||
name: 'conversation',
|
||||
store: chat,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chat>): ConversationInjected => {
|
||||
const session = sessions.manager.get(sessionId)
|
||||
// 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)
|
||||
// Watch-driven history pull: assembling the surface IS the watch signal
|
||||
// (once per entry x session; open() is idempotent and self-recovers).
|
||||
void session.open()
|
||||
return {
|
||||
views: {
|
||||
list: () => conversation.views(),
|
||||
subscribe: fn => conversation.subscribeViews(fn),
|
||||
version: () => conversation.viewsVersion(),
|
||||
list: viewTabs,
|
||||
subscribe: fn => slots.subscribe('conversation.view', fn),
|
||||
version: () => slots.getVersion('conversation.view'),
|
||||
},
|
||||
send: (text, mode) => {
|
||||
const trimmed = text.trim()
|
||||
@@ -107,19 +103,46 @@ export function apply(ctx: Context): void {
|
||||
// Stop failure surfaces via snapshot.promptError; nothing to restore.
|
||||
})
|
||||
},
|
||||
openDetails: (target: SelectionTarget) => {
|
||||
actions.select(target)
|
||||
layout.openDetails()
|
||||
},
|
||||
loadOlder: () => { void session.loadOlder() },
|
||||
open: (target: SessionId) => { sessions.open(target) },
|
||||
}
|
||||
},
|
||||
}, ConversationRoot)
|
||||
|
||||
// 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)
|
||||
|
||||
// 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)
|
||||
|
||||
// The bash sample rides that exact seam, in third-party posture.
|
||||
ctx.plugin(bashToolviewSample)
|
||||
|
||||
slots.register({
|
||||
name: 'details',
|
||||
store: chat,
|
||||
store: chatStore,
|
||||
inject: (): DetailsInjected => ({
|
||||
closeDetails: () => { layout.closeDetails() },
|
||||
}),
|
||||
@@ -128,7 +151,15 @@ export function apply(ctx: Context): void {
|
||||
slots.register({
|
||||
name: 'conversation.empty',
|
||||
inject: (): EmptyStateInjected => ({
|
||||
startSession: opts => conversation.startSession(opts),
|
||||
// ctx.get, not ctx.conversation: the service mounts on this plugin's
|
||||
// own child fiber, so it is not in the inject topology the property
|
||||
// proxy enforces; get reads the global store and stays loud on a torn
|
||||
// boot through the optional-chain throw below.
|
||||
startSession: (opts) => {
|
||||
const conversation = ctx.get('conversation')
|
||||
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
|
||||
return conversation.startSession(opts)
|
||||
},
|
||||
}),
|
||||
}, EmptyState)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -43,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
|
||||
|
||||
@@ -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-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
|
||||
|
||||
/** 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, useStore, 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 = useStore((s) => s.selection?.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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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, 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'
|
||||
|
||||
@@ -22,8 +24,8 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
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}
|
||||
@@ -32,7 +34,7 @@ export function GenericToolCard({ toolName, block, actions }: ToolViewProps) {
|
||||
summary={model.summary}
|
||||
body={model.body}
|
||||
state={model.state}
|
||||
onOpenDetails={actions.openDetails}
|
||||
onOpenDetails={openDetails}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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-ui-slots'
|
||||
import type { ChromeProps } from '../contract/views.ts'
|
||||
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[] = []
|
||||
|
||||
@@ -1,80 +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. Pure props
|
||||
// machinery, zero React context: a registrant inject factory receives the
|
||||
// sessionId this outlet already holds, is called once per (registration x
|
||||
// session) and cached, mirroring the slot injection discipline.
|
||||
|
||||
import { Component, useSyncExternalStore, type ReactNode } from 'react'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolViewInject, ToolViewProps, ToolViewResolver } from '../contract/toolview.ts'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
|
||||
export interface ToolViewOutletProps {
|
||||
registry: ToolViewResolver
|
||||
sessionId: SessionId
|
||||
toolName: string
|
||||
viewProps: ToolViewProps
|
||||
}
|
||||
|
||||
/** Inject cache: per inject-factory (stable per registration) x session id.
|
||||
* The inner Map lives and dies with its factory (WeakMap entry), so entries
|
||||
* are bounded by the session count over the registration's lifetime. */
|
||||
const injectCache = new WeakMap<ToolViewInject<object>, Map<SessionId, object>>()
|
||||
|
||||
function cachedInject(inject: ToolViewInject<object>, sessionId: SessionId): object {
|
||||
let perSession = injectCache.get(inject)
|
||||
if (!perSession) {
|
||||
perSession = new Map()
|
||||
injectCache.set(inject, perSession)
|
||||
}
|
||||
let props = perSession.get(sessionId)
|
||||
if (!props) {
|
||||
props = inject(sessionId)
|
||||
perSession.set(sessionId, 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
|
||||
}
|
||||
}
|
||||
|
||||
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} />
|
||||
: <Row {...{ ...cachedInject(resolved.inject, sessionId), ...viewProps }} />}
|
||||
</RowErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
@@ -1,31 +1,109 @@
|
||||
/**
|
||||
* Slot-ring contract for the conversation package: the composed props shapes
|
||||
* its registrants mount into the layout-owned slots (conversation / details /
|
||||
* conversation.empty). Terminal slot design (§3): full component props are the
|
||||
* automatic shares — PropsRuntime<K> (framework standard kit) & PropsStore<H>
|
||||
* 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. No renderSlot share: none of the three registrations declares
|
||||
* children, so the zero-renderSlot inference applies.
|
||||
* here.
|
||||
*/
|
||||
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
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 { SelectionTarget, ViewEntry } from './views.ts'
|
||||
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
|
||||
|
||||
/** The shared chat store handle type (apply constructs one; conversation and details both declare it). */
|
||||
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} now; ancestry derives from the
|
||||
* standard useSessions hook in-component; view rendering moved into the
|
||||
* component, which holds every share a view needs.
|
||||
* 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 {
|
||||
/** 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
|
||||
}
|
||||
@@ -33,17 +111,42 @@ export interface ConversationInjected {
|
||||
send(text: string, mode: 'queue' | 'steer'): void
|
||||
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
|
||||
stop(): void
|
||||
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
|
||||
openDetails(target: SelectionTarget): void
|
||||
/** Pull one older history page. */
|
||||
loadOlder(): void
|
||||
/** Navigate to another session (breadcrumb ancestors). */
|
||||
open(id: SessionId): void
|
||||
}
|
||||
|
||||
/** Full conversation-slot component props: runtime share & store share & injected share. */
|
||||
/**
|
||||
* 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[]
|
||||
}
|
||||
|
||||
/** Full conversation-slot component props: runtime & child-render (view ring + composer chain) & store & injected shares. */
|
||||
export type ConversationSlotProps =
|
||||
PropsRuntime<'conversation'> & PropsStore<ChatStore> & ConversationInjected
|
||||
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
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
* 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). */
|
||||
|
||||
@@ -1,78 +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 { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CallId, Translate } from './views.ts'
|
||||
|
||||
// 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) and cached by the render
|
||||
* outlet. Mirrors the slot inject shape (parameters derive from the
|
||||
* declaration): toolviews are session-domain by nature, so the factory
|
||||
* receives the session id only — service access goes through the
|
||||
* registrant's own apply-closure ctx (design §5; binding objects retired).
|
||||
*/
|
||||
export type ToolViewInject<I extends object> = (sessionId: SessionId) => I
|
||||
|
||||
/** Options accepted by the toolview registry's register; `I` is inferred from the inject factory. */
|
||||
export interface ToolViewOptions<I extends object = object> {
|
||||
/** 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
|
||||
}
|
||||
@@ -1,89 +1,39 @@
|
||||
/**
|
||||
* View-ring contract: the typed conversation view table, the chat store state
|
||||
* shared through it, and the props surfaces handed to registered views.
|
||||
* Shared face between the skeleton domain (ConversationRoot renders views)
|
||||
* and the chat domain (registers the chat view); domain implementation files
|
||||
* import this, never each other.
|
||||
* 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 { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
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 }
|
||||
|
||||
/**
|
||||
* 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 and details registrations. `createChatStore` implements
|
||||
* this shape; views read it through {@link ConvViewProps}'s pass-through hook.
|
||||
* `view` may carry a stale persisted id after a view plugin unloads — the
|
||||
* registry is the runtime validator (unknown ids fall back to the first view).
|
||||
* 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; null falls back to the first registered view. */
|
||||
view: ViewId | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Props handed to registered conversation views. `useSession` and `useStore`
|
||||
* are the framework hooks ConversationRoot received as a slot registrant,
|
||||
* passed through unchanged (hook transfer is plain props passing; no
|
||||
* business-made subscription exists on this path). No renderSlot share: the
|
||||
* view ring delegates no sub-slots.
|
||||
*/
|
||||
export interface ConvViewProps {
|
||||
sessionId: SessionId
|
||||
useSession: UseSession
|
||||
/** Chat store read face (selection is the only slice views consume today). */
|
||||
useStore: SnapshotSelectorHook<ChatStoreState>
|
||||
actions: { openDetails(t: SelectionTarget): void; loadOlder(): void }
|
||||
/** Active conversation view id ('conversation.view' entry id); null falls back to the first view. */
|
||||
view: string | null
|
||||
}
|
||||
|
||||
@@ -1,34 +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, ChatStoreState, 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 {
|
||||
ChatStore, 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 discipline: packages/client/AGENTS.md.
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
conversation: ConversationService
|
||||
toolviews: ToolViewRegistry
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* ConversationService implementation: scope-addressed send/cancel, view
|
||||
* registry with a uSES read face, and the empty-state startSession chain.
|
||||
* Contract: api-contracts v3 section 7. Selection/draft state moved to the
|
||||
* declared chat store (slot terminal design §4) — the per-scope store maps,
|
||||
* lazy construction, and prune bookkeeping this service used to carry are
|
||||
* retired; what remains is the send/stop orchestration face.
|
||||
* 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
|
||||
@@ -15,31 +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'
|
||||
import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ViewEntry, ViewId } from './index.ts'
|
||||
|
||||
/** Mutable view-registry cell (plain object: mutation never crosses the tracker proxy). */
|
||||
interface ViewsState {
|
||||
entries: Map<string, ViewEntry>
|
||||
/** Sorted projection cache; null = rebuild on next read. */
|
||||
cache: readonly ViewEntry[] | null
|
||||
tick: number
|
||||
listeners: Set<() => void>
|
||||
}
|
||||
// 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'
|
||||
|
||||
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
|
||||
export class ConversationService extends Service {
|
||||
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).
|
||||
@@ -68,60 +50,6 @@ export class ConversationService extends Service {
|
||||
if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -151,11 +79,17 @@ export class ConversationService extends Service {
|
||||
|
||||
/** Resolve the caller scope's Session or throw on root contexts. */
|
||||
private scopedSession(op: string): Session {
|
||||
const id = scopeOf(this.ctx)
|
||||
const id = this.scopeId(op)
|
||||
return this.requireSessions().manager.get(id)
|
||||
}
|
||||
|
||||
/** Read the caller's session scope tag via the sessions service; root contexts fail loud. */
|
||||
private scopeId(op: string): SessionId {
|
||||
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 this.requireSessions().manager.get(id)
|
||||
return id
|
||||
}
|
||||
|
||||
private requireSessions(): SessionsService {
|
||||
@@ -167,9 +101,3 @@ export class ConversationService extends Service {
|
||||
return sessions
|
||||
}
|
||||
}
|
||||
|
||||
function bumpViews(state: ViewsState): void {
|
||||
state.cache = null
|
||||
state.tick += 1
|
||||
for (const fn of [...state.listeners]) fn()
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
// ConversationRoot: the conversation slot's skeleton (figma Header 39:27730 +
|
||||
// Tab_Group + view area + composer). Pure component — everything arrives via
|
||||
// props: the framework standard kit (useSession/sessionId/useSessions), the
|
||||
// declared chat store's useStore/actions, and the injected business face.
|
||||
// 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 { useMemo, useSyncExternalStore, type ReactNode } from 'react'
|
||||
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 type { ConvViewProps, ViewEntry } from '../contract/views.ts'
|
||||
import { InputBar } from './InputBar.tsx'
|
||||
import type { InputBarError } from './InputBar.tsx'
|
||||
import css from './ConversationRoot.module.css'
|
||||
@@ -35,15 +37,15 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
|
||||
}
|
||||
|
||||
export function ConversationRoot({
|
||||
sessionId, useSession, useSessions, useStore, actions,
|
||||
views, send, stop, openDetails, loadOlder, open,
|
||||
sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain,
|
||||
views, send, stop, open,
|
||||
}: ConversationRootProps) {
|
||||
useSyncExternalStore(views.subscribe, views.version)
|
||||
const list = views.list()
|
||||
const tabs = views.list()
|
||||
// The store's persisted view id may be stale (view plugin unloaded); the
|
||||
// registry is the runtime validator — unknown ids fall to the first view.
|
||||
// slot ledger is the runtime validator — unknown ids fall to the first view.
|
||||
const activeId = useStore(s => s.view) ?? 'chat'
|
||||
const active = list.find(v => v.id === activeId) ?? list[0]
|
||||
const active = tabs.find(v => v.id === activeId) ?? tabs[0]
|
||||
|
||||
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
|
||||
const draft = useStore(s => s.draft)
|
||||
@@ -51,31 +53,26 @@ export function ConversationRoot({
|
||||
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})` }
|
||||
|
||||
// Views receive the shares this component already holds (hook transfer is
|
||||
// plain props passing); the callback slice is referentially stable per
|
||||
// injected identity so memoized view rows hold.
|
||||
const viewProps = useMemo<ConvViewProps>(() => ({
|
||||
sessionId, useSession, useStore,
|
||||
actions: { openDetails, loadOlder },
|
||||
}), [sessionId, useSession, useStore, openDetails, loadOlder])
|
||||
|
||||
const renderView = (entry: ViewEntry): ReactNode => {
|
||||
const Header = entry.chrome?.header
|
||||
const Footer = entry.chrome?.footer
|
||||
const View = entry.component
|
||||
return (
|
||||
<>
|
||||
{Header !== undefined && <Header sessionId={sessionId} useSession={useSession} />}
|
||||
<View {...viewProps} />
|
||||
{Footer !== undefined && <Footer sessionId={sessionId} useSession={useSession} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
// 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}>
|
||||
@@ -93,7 +90,7 @@ export function ConversationRoot({
|
||||
disabled={last}
|
||||
onClick={() => { open(s.id) }}
|
||||
>
|
||||
{s.title}
|
||||
{s.displayTitle}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
@@ -104,9 +101,9 @@ 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"
|
||||
@@ -123,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={actions.setDraft}
|
||||
onSend={(mode) => { send(draft, mode) }}
|
||||
onStop={stop}
|
||||
/>
|
||||
{renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* in the module cache (a de-facto singleton surviving plugin reloads).
|
||||
*/
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatStoreState, SelectionTarget, ViewId } from './contract/views.ts'
|
||||
import type { ChatStoreState, SelectionTarget } from './contract/views.ts'
|
||||
|
||||
/**
|
||||
* Annotation twin of the actions literal below (the export needs a declared
|
||||
@@ -21,22 +21,22 @@ type ChatActions = {
|
||||
setDraft: (draft: ChatStoreState, text: string) => void
|
||||
clearDraft: (draft: ChatStoreState) => void
|
||||
restoreDraft: (draft: ChatStoreState, text: string) => void
|
||||
setView: (draft: ChatStoreState, view: ViewId) => 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 (previously layout.viewFor — store seat is the
|
||||
* cross-remount survival channel, null falls back to the first registered view).
|
||||
* 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: views consume the store through
|
||||
// ConvViewProps' SnapshotSelectorHook<ChatStoreState>, so init and the
|
||||
// contract cannot drift.
|
||||
// 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: {
|
||||
@@ -46,7 +46,7 @@ export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions
|
||||
// Optimistic-send failure restore: only when the user typed nothing new
|
||||
// since the clear (send choreography lives in the inject factory).
|
||||
restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
|
||||
setView: (d, view: ViewId) => { d.view = view },
|
||||
setView: (d, view: string) => { d.view = view },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 = () => {}
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
// apply inject factories exercised end to end against the terminal thin
|
||||
// shape: the conversation surface (views triple, send choreography incl.
|
||||
// optimistic clear + failure restore THROUGH the declared store actions,
|
||||
// openDetails = select action + layout orchestration, watch-driven open,
|
||||
// sessions.open navigation), the injectless-but-closeDetails details surface,
|
||||
// and the one-callback empty surface. Complements chat-apply.spec.tsx
|
||||
// (registration) and selection-survival.spec.ts (store axis).
|
||||
// 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'
|
||||
@@ -13,10 +15,10 @@ 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 { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { ConversationService, apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
ConversationInjected, DetailsInjected, EmptyStateInjected,
|
||||
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
@@ -49,7 +51,7 @@ async function bench() {
|
||||
|
||||
const listStore = createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT],
|
||||
byId: { [ROOT]: { id: ROOT, title: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
|
||||
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
|
||||
current: ROOT,
|
||||
} as SessionListState)
|
||||
const sessionFake = {
|
||||
@@ -74,6 +76,7 @@ async function bench() {
|
||||
manager: { get: () => sessionFake },
|
||||
scope: (id: SessionId) => mint(id),
|
||||
cell: () => undefined,
|
||||
scopeOf,
|
||||
create: vi.fn(() => Promise.resolve(ROOT)),
|
||||
open: vi.fn(),
|
||||
}
|
||||
@@ -103,7 +106,7 @@ async function bench() {
|
||||
slots.install({ renderRoot: (h) => { host = h; return null } })
|
||||
slots.renderSlot('root', {})
|
||||
const hostFace = host!
|
||||
const entryOf = (key: 'conversation' | 'details' | 'conversation.empty') => hostFace.entriesOf(key)[0]!
|
||||
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')
|
||||
@@ -112,18 +115,30 @@ async function bench() {
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
return { ctx, slots, hostFace, entryOf, conversationSurface, sessionFake, sessionsFake, layoutFake, mint }
|
||||
/** 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, hostFace, entryOf, conversationSurface, chatViewSurface, sessionFake, sessionsFake, layoutFake, mint }
|
||||
}
|
||||
|
||||
describe('conversation slot inject surface', () => {
|
||||
it('assembles the thin surface, pulls history through the watch signal, navigates via sessions.open', async () => {
|
||||
it('assembles the thin surface side-effect-free, navigates via sessions.open', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
expect(b.sessionFake.open).toHaveBeenCalledTimes(1)
|
||||
// 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.open(ROOT)
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
injected.loadOlder()
|
||||
// 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)
|
||||
})
|
||||
|
||||
@@ -161,27 +176,51 @@ describe('conversation slot inject surface', () => {
|
||||
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('openDetails writes the selection through the store actions and opens the panel', async () => {
|
||||
it('inject fails loud when the session resolves no scope or the scope lacks the service', async () => {
|
||||
const b = await bench()
|
||||
const { instance, injected } = b.conversationSurface(ROOT)
|
||||
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)
|
||||
// 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('views read face forwards to the service registry (subscribe/version)', async () => {
|
||||
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
const before = injected.views.version()
|
||||
const listener = vi.fn()
|
||||
const unsub = injected.views.subscribe(listener)
|
||||
const conversation = b.ctx.get('conversation') as
|
||||
import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
|
||||
const off = conversation.registerView({ id: 'chat2', label: 'X', component: () => null } as never)
|
||||
// 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(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()
|
||||
})
|
||||
})
|
||||
@@ -211,4 +250,14 @@ describe('details and empty inject surfaces', () => {
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
|
||||
})
|
||||
|
||||
it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => {
|
||||
const b = await bench()
|
||||
const injected = (b.entryOf('conversation.empty').inject as unknown as () => EmptyStateInjected)()
|
||||
// Tear the service's own fiber (registry keyed by the class): the slot
|
||||
// entries survive, so the gesture-time read hits the loud branch.
|
||||
b.ctx.registry.delete(ConversationService)
|
||||
await vi.waitFor(() => { expect(b.ctx.get('conversation')).toBeUndefined() })
|
||||
expect(() => injected.startSession({ text: 'go', mode: 'queue' })).toThrow(/conversation service unavailable/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
// apply wiring: services provided, chat view + footer chrome registered, the
|
||||
// three slot registrations land against a root entry's children declarations
|
||||
// (the AppFrame role), the shared store handle rides both session slots, and
|
||||
// the bash samples resolve differentially (sub-session default scope).
|
||||
// Full-chain rendering belongs to the shell e2e; this spec stops at the
|
||||
// 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'
|
||||
@@ -11,8 +13,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject, ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const ROOT = 'root-1' as SessionId
|
||||
const CHILD = 'child-1' as SessionId
|
||||
@@ -25,8 +26,8 @@ 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)
|
||||
@@ -60,62 +61,69 @@ async function bench() {
|
||||
}
|
||||
|
||||
/** First stored entry for a key (inject/store live directly on StoredEntry). */
|
||||
function renderEntryOf(slots: SlotsService, key: 'conversation' | 'details' | 'conversation.empty') {
|
||||
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 the three slots; session pair shares one store handle, empty declares none', 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()
|
||||
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 BOTH session entries.
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 { hookOf } from './hook.ts'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ToolViewProps, Translate } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { 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,71 +66,8 @@ describe('small branch tails', () => {
|
||||
}
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine sessionId={SID} useSession={hookOf(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 session) and merges its props', () => {
|
||||
const registry = new ToolViewRegistry()
|
||||
const inject = vi.fn((sessionId: SessionId) => ({ extra: `injected:${sessionId}` }))
|
||||
registry.register('bash',
|
||||
(p: ToolViewProps & { extra: string }) => <div data-testid="row">{p.extra}</div>,
|
||||
{ inject })
|
||||
// Pure props machinery: the outlet feeds its own sessionId to the
|
||||
// factory — no provider/context needed (terminal channel form).
|
||||
const view = render(
|
||||
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
|
||||
)
|
||||
expect(view.getByTestId('row').textContent).toBe(`injected:${SID}`)
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
// Remount under the SAME session: cache hit, factory not re-run.
|
||||
view.unmount()
|
||||
const second = render(
|
||||
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
|
||||
)
|
||||
expect(second.getByTestId('row').textContent).toBe(`injected:${SID}`)
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
// A different session is a distinct cache key: factory runs once more.
|
||||
second.unmount()
|
||||
const other = render(
|
||||
<ToolViewOutlet registry={registry} sessionId={'s2' as SessionId} toolName="bash" viewProps={viewProps()} />,
|
||||
)
|
||||
expect(other.getByTestId('row').textContent).toBe('injected:s2')
|
||||
expect(inject).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('a crashing custom row falls back to GenericToolCard and retries on re-registration', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
// @vitest-environment jsdom
|
||||
// StatsLine (chrome.footer first consumer): totals derivation + the RFC hard
|
||||
// acceptance — zero renders during streaming. Bash sample: differential
|
||||
// registry hits per session, teardown reverts to the generic row.
|
||||
// StatsLine (rendered inside the chat view body): totals derivation + the RFC
|
||||
// hard acceptance — zero renders during streaming. Bash sample row: the
|
||||
// canonical sub-agent differential decided INSIDE the component off the
|
||||
// standard useSessions kit (no registry predicates — tool ring dissolved).
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode,
|
||||
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { hookOf } from './hook.ts'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ChromeProps, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { StatsLine, deriveStats } from '../src/client/chat/StatsLine.tsx'
|
||||
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 { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { StatsLine, deriveStats, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -77,8 +75,8 @@ describe('deriveStats', () => {
|
||||
})
|
||||
|
||||
describe('StatsLine', () => {
|
||||
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): ChromeProps {
|
||||
return { sessionId: SID, useSession: hookOf(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,71 +107,79 @@ describe('StatsLine', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('bash toolview samples', () => {
|
||||
describe('bash sample row', () => {
|
||||
const ROOT = 'root-1' as SessionId
|
||||
const CHILD = 'child-1' as SessionId
|
||||
|
||||
const result = (callId: string): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 3, callId,
|
||||
call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
const viewProps = (openDetails = vi.fn()): ToolViewProps => ({
|
||||
callId: 'c1', toolName: 'bash', block: result('c1'),
|
||||
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
|
||||
actions: { openDetails },
|
||||
t: (k) => k,
|
||||
})
|
||||
|
||||
function outlet(registry: ToolViewRegistry, sessionId: SessionId, p = viewProps()) {
|
||||
return render(
|
||||
<ToolViewOutlet registry={registry} sessionId={sessionId} toolName="bash" viewProps={p} />,
|
||||
)
|
||||
/** Real list-store engine: the family fixture the in-component parentId branch reads. */
|
||||
function listStore() {
|
||||
return createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT, CHILD],
|
||||
byId: {
|
||||
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 },
|
||||
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 },
|
||||
},
|
||||
current: undefined,
|
||||
} as SessionListState)
|
||||
}
|
||||
|
||||
it('differential rendering: scoped row for the matching session, global elsewhere', () => {
|
||||
const registry = new ToolViewRegistry()
|
||||
registerBashSamples(registry, (id) => id === ('swarm' as SessionId))
|
||||
const scoped = outlet(registry, 'swarm' as SessionId)
|
||||
const rowProps = (sessionId: SessionId, over?: {
|
||||
store?: ReturnType<typeof listStore>
|
||||
openDetails?: () => void
|
||||
}): ToolRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block: result('c1'),
|
||||
openDetails: over?.openDetails ?? vi.fn(),
|
||||
sessionId,
|
||||
useSessions: bindSnapshotSelector(over?.store ?? listStore()),
|
||||
} as unknown as ToolRowProps)
|
||||
|
||||
it('differential rendering: the scoped variant in sub-sessions, global at roots', () => {
|
||||
const scoped = render(<BashRow {...rowProps(CHILD)} />)
|
||||
expect(scoped.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
|
||||
const plain = outlet(registry, SID)
|
||||
expect(scoped.getByText('scoped')).toBeTruthy()
|
||||
const plain = render(<BashRow {...rowProps(ROOT)} />)
|
||||
expect(plain.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('teardown removes both registrations and falls back to the generic row', () => {
|
||||
const registry = new ToolViewRegistry()
|
||||
const off = registerBashSamples(registry, () => true)
|
||||
const view = outlet(registry, SID)
|
||||
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
|
||||
act(() => off())
|
||||
expect(view.container.querySelector('[data-sample]')).toBeNull()
|
||||
expect(view.getByText('Bash')).toBeTruthy()
|
||||
it('a session outside the list renders the global arm (no parent known)', () => {
|
||||
const view = render(<BashRow {...rowProps('gone' as SessionId)} />)
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('childSessionScope matches sub-sessions via the injected list read face', () => {
|
||||
const child = 'child' as SessionId
|
||||
const root = 'root' as SessionId
|
||||
const scope = childSessionScope({
|
||||
getSnapshot: () => ({
|
||||
ids: [root, child],
|
||||
current: undefined,
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,12 +4,11 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
|
||||
afterEach(cleanup)
|
||||
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
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"}',
|
||||
@@ -139,11 +138,8 @@ describe('ThinkRow', () => {
|
||||
})
|
||||
|
||||
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', () => {
|
||||
@@ -188,10 +184,10 @@ describe('GenericToolCard', () => {
|
||||
expect(view.container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('row click reaches actions.openDetails', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
// @vitest-environment jsdom
|
||||
// The dissolved tool ring's acceptance chain on the REAL machinery stack:
|
||||
// cordis Context + SlotsService ledger + the web-react renderer + this
|
||||
// package's own apply — no outlet twins. Proves the keyed
|
||||
// 'conversation.chat.toolview' hole end to end: registered rows dispatch by
|
||||
// entryKey (the bash sample lands through its plugin), unregistered tools
|
||||
// fall back to GenericToolCard at the render site, live registration/unload
|
||||
// flips rows in place, duplicate keys fail loud, the inject channel feeds
|
||||
// (sessionId) => I into row components, and a registrant's
|
||||
// inject: ['slots', 'conversation'] load-order seam suspends on real fiber
|
||||
// semantics until the service (and with it the hole declaration) is present.
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
// The chat store persists under its declared key; clear between cases.
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, callId,
|
||||
call: { name, argsRaw: args },
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
|
||||
/** Test-owned AppFrame role: declares the layout-owned children and renders the conversation area under the framework session provider. */
|
||||
type AppRootProps = PropsRenderSlots<'conversation' | 'details' | 'conversation.empty'>
|
||||
function AppRoot({ renderSlot, SessionProvider }: AppRootProps) {
|
||||
return <SessionProvider>{() => renderSlot('conversation', {})}</SessionProvider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-stack bench: SlotsService plugin, renderer installed, sessions/layout
|
||||
* fakes at the service seams only (external boundaries), the package apply on
|
||||
* its own fiber, and the test AppFrame occupying 'root'.
|
||||
*/
|
||||
async function bench(nodes: ToolResultNode[]) {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', running: false, updatedAt: 1 } },
|
||||
current: SID,
|
||||
} as SessionListState)
|
||||
// Identity-stable cell: the renderer caches hooks per source and inject
|
||||
// results per cell, both by object identity.
|
||||
const cell = { sessionId: SID, session }
|
||||
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
ctx.provide('sessions', {
|
||||
list,
|
||||
manager: { get: () => ({ loadOlder: vi.fn() }) },
|
||||
scope: () => ({ get: () => scoped }),
|
||||
cell: (id: string) => (id === SID ? cell : undefined),
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
})
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
|
||||
slots.install(createSlotRenderer())
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
'conversation.empty': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
}, AppRoot)
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber, session, list, layout }
|
||||
}
|
||||
|
||||
/** Render the whole tree through the ctx-level root seam (the shell's own entry). */
|
||||
function mountApp(slots: SlotsService) {
|
||||
return render(<>{slots.renderSlot('root', {})}</>)
|
||||
}
|
||||
|
||||
describe('keyed toolview hole through the real machinery', () => {
|
||||
it('dispatches registered rows by entryKey and unregistered tools to the GenericToolCard fallback', async () => {
|
||||
const b = await bench([
|
||||
toolResult(3, 'c1', 'bash'),
|
||||
toolResult(4, 'c2', 'mystery', '{"n":1}'),
|
||||
])
|
||||
const view = mountApp(b.slots)
|
||||
// bash: the sample plugin's keyed registration took the row (root
|
||||
// session → global arm, decided inside the component off useSessions).
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(view.getByText('Build')).toBeTruthy()
|
||||
// mystery: no registration under that key → render-site fallback.
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => {
|
||||
const b = await bench([toolResult(3, 'c1', 'bash')])
|
||||
const view = mountApp(b.slots)
|
||||
view.getByText('Build').click()
|
||||
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => {
|
||||
const b = await bench([toolResult(3, 'c2', 'mystery', '{"n":1}')])
|
||||
const view = mountApp(b.slots)
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
let dispose = (): void => {}
|
||||
await act(async () => {
|
||||
dispose = b.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'mystery' },
|
||||
() => <div data-testid="mystery-row" />)
|
||||
})
|
||||
// Per-key version tick: the row flipped without a remount of the view.
|
||||
expect(view.getByTestId('mystery-row')).toBeTruthy()
|
||||
expect(view.queryByText('Tool call')).toBeNull()
|
||||
await act(async () => { dispose() })
|
||||
expect(view.queryByTestId('mystery-row')).toBeNull()
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a duplicate key registration fails loud at load', async () => {
|
||||
const b = await bench([])
|
||||
// The bash sample already holds the 'bash' key (later-wins retired with
|
||||
// the ring — the keyed ledger throws instead).
|
||||
expect(() => b.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'bash' },
|
||||
() => null,
|
||||
)).toThrow(/key "bash"/)
|
||||
})
|
||||
|
||||
it('the inject channel feeds (sessionId) => I into the row component', async () => {
|
||||
const b = await bench([toolResult(3, 'c3', 'probe', '{"x":1}')])
|
||||
const poked: string[] = []
|
||||
b.slots.register({
|
||||
name: 'conversation.chat.toolview',
|
||||
key: 'probe',
|
||||
// Two-way business face: data derived from the session id out, a
|
||||
// callback closing over it back in — the askuser-pattern inject shape.
|
||||
inject: (sessionId: SessionId) => ({
|
||||
mark: `for:${sessionId}`,
|
||||
poke: () => { poked.push(sessionId) },
|
||||
}),
|
||||
}, ({ mark, poke }: ToolRowProps & { mark: string; poke: () => void }) => (
|
||||
<button data-testid="probe-row" onClick={poke}>{mark}</button>
|
||||
))
|
||||
const view = mountApp(b.slots)
|
||||
const row = view.getByTestId('probe-row')
|
||||
expect(row.textContent).toBe(`for:${SID}`)
|
||||
row.click()
|
||||
expect(poked).toEqual([SID])
|
||||
})
|
||||
})
|
||||
|
||||
describe('registrant load-order seam', () => {
|
||||
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
ctx.provide('sessions', {
|
||||
list: createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined } as SessionListState),
|
||||
manager: { get: vi.fn() },
|
||||
scope: () => undefined,
|
||||
cell: () => undefined,
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
})
|
||||
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
'conversation.empty': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
}, AppRoot)
|
||||
|
||||
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
||||
// semantics hold it — apply must not run while 'conversation' is absent.
|
||||
// (Plain arrow, not vi.fn: mock functions carry a prototype and trip the
|
||||
// fiber's isConstructor branch.)
|
||||
let applyRuns = 0
|
||||
const registrantApply = (registrantCtx: Context): void => {
|
||||
applyRuns += 1
|
||||
registrantCtx.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'late' }, () => null)
|
||||
}
|
||||
const late = ctx.plugin({
|
||||
name: 'late-registrant',
|
||||
inject: ['slots', 'conversation'],
|
||||
apply: registrantApply,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(applyRuns).toBe(0)
|
||||
|
||||
// Mounting the package resolves the seam: service present ⟹ the chat
|
||||
// entry (and its hole declaration) is already on the ledger, so the
|
||||
// suspended registrant lands without an undeclared-slot throw.
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
await late.await()
|
||||
expect(applyRuns).toBe(1)
|
||||
expect(slots.entries('conversation.chat.toolview').map(e => e.options.key))
|
||||
.toEqual(expect.arrayContaining(['bash', 'late']))
|
||||
})
|
||||
})
|
||||
@@ -7,14 +7,14 @@ 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 { hookOf } from './hook.ts'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConvViewProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
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 { createChatView } from '../src/client/chat/ChatView.tsx'
|
||||
import { ChatView } from '../src/client/chat/ChatView.tsx'
|
||||
import { deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -68,23 +68,41 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
|
||||
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, 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()
|
||||
// Selection rides the REAL chat store (same construction path as
|
||||
// production; the view reads it through the ConvViewProps useStore share).
|
||||
// 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 props: ConvViewProps = {
|
||||
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: hookOf(source) as unknown as UseSession,
|
||||
useStore: hookOf(chat),
|
||||
actions: { openDetails, loadOlder },
|
||||
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, registry, ChatView, props, openDetails, loadOlder, setSelection }
|
||||
return { set, ChatView, props, openDetails, loadOlder, setSelection }
|
||||
}
|
||||
|
||||
describe('chat-flow derivation', () => {
|
||||
@@ -140,6 +158,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')],
|
||||
@@ -169,11 +225,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
|
||||
@@ -211,21 +269,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', () => {
|
||||
@@ -287,7 +343,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()
|
||||
|
||||
@@ -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-ui-slots'
|
||||
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ConversationService, Translate, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { 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()
|
||||
})
|
||||
@@ -67,11 +66,8 @@ describe('tails', () => {
|
||||
call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
}
|
||||
const props: ToolViewProps = {
|
||||
callId: 'c5', toolName: 'todo_write', block: settled,
|
||||
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
|
||||
actions: { openDetails: vi.fn() },
|
||||
t: ((k: string) => k) as Translate,
|
||||
const props: ToolRowOwnerProps = {
|
||||
callId: 'c5', toolName: 'todo_write', block: settled, openDetails: vi.fn(),
|
||||
}
|
||||
const view = render(<GenericToolCard {...props} />)
|
||||
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
|
||||
@@ -79,49 +75,25 @@ describe('tails', () => {
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('BashRow shows the failed pill on error results', () => {
|
||||
it('BashRow shows the failed pill on error results (root session arm)', () => {
|
||||
const errorResult: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 1, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
|
||||
content: [], isError: true, callView: null, resultView: null,
|
||||
}
|
||||
const props: ToolViewProps = {
|
||||
callId: 'c1', toolName: 'bash', block: errorResult,
|
||||
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
|
||||
actions: { openDetails: vi.fn() },
|
||||
t: ((k: string) => k) as Translate,
|
||||
}
|
||||
// Root session (no parentId): the global arm renders, error pill visible.
|
||||
const sid = 'root-1' as SessionId
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid],
|
||||
byId: { [sid]: { id: sid, title: 'r', running: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
} as SessionListState)
|
||||
const props = {
|
||||
callId: 'c1', toolName: 'bash', block: errorResult, openDetails: vi.fn(),
|
||||
sessionId: sid, useSessions: bindSnapshotSelector(list),
|
||||
} as unknown as ToolRowProps
|
||||
const view = render(<BashRow {...props} />)
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(view.getByText('failed')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('registry disposer re-entry is a no-op after the entry was already removed', () => {
|
||||
const registry = new ToolViewRegistry()
|
||||
const off = registry.register('bash', (() => null) as never)
|
||||
const v1 = registry.getVersion()
|
||||
off()
|
||||
const v2 = registry.getVersion()
|
||||
off()
|
||||
expect(registry.getVersion()).toBe(v2)
|
||||
expect(v2).toBeGreaterThan(v1)
|
||||
})
|
||||
|
||||
it('registerChat registers the chat view with the stats footer and disposes cleanly', () => {
|
||||
const disposer = vi.fn()
|
||||
const calls: unknown[] = []
|
||||
const conversation = {
|
||||
registerView: (entry: unknown) => {
|
||||
calls.push(entry)
|
||||
return disposer
|
||||
},
|
||||
} as unknown as ConversationService
|
||||
const toolviews = new ToolViewRegistry()
|
||||
const off = registerChat({ conversation, toolviews, t: ((k: string) => k) as Translate })
|
||||
const entry = calls[0] as { id: string; chrome?: { footer?: unknown } }
|
||||
expect(entry.id).toBe('chat')
|
||||
// footer is a memo exotic component (object, not plain function).
|
||||
expect(entry.chrome?.footer).toBeDefined()
|
||||
off()
|
||||
expect(disposer).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
// @vitest-environment jsdom
|
||||
// Final branch tails for the coverage gate, terminal slot form: apply's
|
||||
// need() throw, AssistantMarkdown non-final reasoning, StatsLine usage-less
|
||||
// node, DetailsPanel titleless selection, registry disposer after a foreign
|
||||
// removal emptied the list. (The old cwd WeakMap-cache account retired with
|
||||
// the mechanism — derivation lives in EmptyState now, covered by the
|
||||
// skeleton specs.)
|
||||
// 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 { hookOf } from './hook.ts'
|
||||
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-ui-slots'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
@@ -54,7 +52,7 @@ describe('render branch tails', () => {
|
||||
}
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine sessionId={SID} useSession={hookOf(source) as unknown as UseSession<ConversationSnapshot>} />,
|
||||
<StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
|
||||
)
|
||||
expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
|
||||
})
|
||||
@@ -76,9 +74,9 @@ describe('render branch tails', () => {
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={hookOf({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSessions={hookOf(emptyList)}
|
||||
useStore={hookOf(chat)}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
/>,
|
||||
@@ -86,15 +84,4 @@ describe('render branch tails', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,23 +123,25 @@ describe('selection survives on the store seat', () => {
|
||||
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
|
||||
})
|
||||
|
||||
it('a title-upgrading list refresh keeps instance identity and the selection value', async () => {
|
||||
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 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()
|
||||
|
||||
const after = storeFor(b, 'conversation', id)
|
||||
expect(after).toBe(store)
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
/**
|
||||
* ConversationService orchestration half after the store-seat slimming:
|
||||
* scope-addressed send/cancel (result folding, root throw), the startSession
|
||||
* chain (create → sessions.open → scoped send), views ordering, and the
|
||||
* service-unavailable loud failures. Selection/draft state left this service
|
||||
* for the declared chat store (chat-store.spec.ts / selection-survival.spec.ts).
|
||||
* 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'
|
||||
@@ -66,9 +67,11 @@ async function bench(opts?: { 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 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
|
||||
@@ -149,17 +152,3 @@ describe('service-unavailable loud failures', () => {
|
||||
.rejects.toThrow(/conversation service unavailable through the new scope/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('views ordering', () => {
|
||||
it('orders by explicit order with undefined treated as zero (both comparator arms)', async () => {
|
||||
const b = await bench()
|
||||
const entry = (id: string, order?: number) => ({
|
||||
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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,16 +11,19 @@ import { hookOf } from './hook.ts'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import { ConversationRoot, type ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
/** Fallback-only chain stub (no takeover registered in these benches). */
|
||||
const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] =
|
||||
(_key, _owner, opts) => opts?.fallback ?? null
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
@@ -43,7 +46,7 @@ function listHook(rows: { id: string; title: string; cwd?: string; parentId?: st
|
||||
const store = createSnapshotStore<SessionListState>({
|
||||
ids: rows.map(r => r.id as SessionId),
|
||||
byId: Object.fromEntries(rows.map(r => [r.id, {
|
||||
id: r.id as SessionId, title: r.title, running: false, updatedAt: 1,
|
||||
id: r.id as SessionId, title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
...(r.parentId !== undefined ? { parentId: r.parentId as SessionId } : {}),
|
||||
}])),
|
||||
@@ -53,9 +56,11 @@ function listHook(rows: { id: string; title: string; cwd?: string; parentId?: st
|
||||
}
|
||||
|
||||
describe('ConversationRoot branches', () => {
|
||||
const chatEntry: ViewEntry = {
|
||||
id: 'chat', label: 'Chat', component: () => <div data-testid="view-body" />,
|
||||
} as unknown as ViewEntry
|
||||
const chatTab: ViewTab = { id: 'chat', label: 'Chat' }
|
||||
/** renderSlot stub in the outlet's baked shape (ring key + only filter marker). */
|
||||
const stubRenderSlot = (() => <div data-testid="view-body" />) as unknown as ConversationRootProps['renderSlot']
|
||||
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
|
||||
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
||||
|
||||
function rootProps(over?: {
|
||||
rows?: { id: string; title: string; parentId?: string }[]
|
||||
@@ -70,11 +75,12 @@ describe('ConversationRoot branches', () => {
|
||||
useSessions={listHook(over?.rows ?? [])}
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
|
||||
renderSlot={stubRenderSlot}
|
||||
renderSlotChain={fallbackRenderSlotChain}
|
||||
SessionProvider={SessionProviderStub}
|
||||
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
|
||||
send={vi.fn()}
|
||||
stop={vi.fn()}
|
||||
openDetails={vi.fn()}
|
||||
loadOlder={vi.fn()}
|
||||
open={open}
|
||||
/>,
|
||||
)
|
||||
@@ -120,7 +126,7 @@ describe('ConversationRoot branches', () => {
|
||||
it('an unknown stored view id falls back to the first registered view', () => {
|
||||
const { chat } = rootProps({})
|
||||
cleanup()
|
||||
chat.actions.setView('gone' as never)
|
||||
chat.actions.setView('gone')
|
||||
const view = render(
|
||||
<ConversationRoot
|
||||
sessionId={SID}
|
||||
@@ -128,11 +134,12 @@ describe('ConversationRoot branches', () => {
|
||||
useSessions={listHook([])}
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
|
||||
renderSlot={stubRenderSlot}
|
||||
renderSlotChain={fallbackRenderSlotChain}
|
||||
SessionProvider={SessionProviderStub}
|
||||
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
|
||||
send={vi.fn()}
|
||||
stop={vi.fn()}
|
||||
openDetails={vi.fn()}
|
||||
loadOlder={vi.fn()}
|
||||
open={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
*/
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FC } from 'react'
|
||||
import { hookOf } from './hook.ts'
|
||||
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-ui-slots'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConversationSnapshot, PendingInteraction, SessionId, SessionListState } 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 { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
@@ -36,13 +38,14 @@ interface FakeSnapshot {
|
||||
running: boolean
|
||||
removed: boolean
|
||||
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
|
||||
pending: readonly PendingInteraction[]
|
||||
}
|
||||
|
||||
function fakeSession(init: Partial<FakeSnapshot> = {}) {
|
||||
const store = createSnapshotStore<FakeSnapshot>({
|
||||
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init,
|
||||
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init,
|
||||
})
|
||||
return { store, useSession: hookOf(store) as unknown as UseSession<ConversationSnapshot> }
|
||||
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
||||
}
|
||||
|
||||
/** Sessions-list stub: the standard useSessions hook over a snapshot store. */
|
||||
@@ -50,15 +53,18 @@ function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?
|
||||
const store = createSnapshotStore<SessionListState>({
|
||||
ids: rows.map(r => sid(r.id)),
|
||||
byId: Object.fromEntries(rows.map(r => [r.id, {
|
||||
id: sid(r.id), title: r.title, running: false, updatedAt: 1,
|
||||
id: sid(r.id), title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
...(r.parentId !== undefined ? { parentId: sid(r.parentId) } : {}),
|
||||
}])),
|
||||
current: undefined,
|
||||
} as SessionListState)
|
||||
return { store, useSessions: hookOf(store) }
|
||||
return { store, useSessions: bindSnapshotSelector(store) }
|
||||
}
|
||||
|
||||
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
|
||||
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</>
|
||||
|
||||
describe('EmptyState', () => {
|
||||
it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
|
||||
const { useSessions } = fakeSessions([
|
||||
@@ -96,49 +102,52 @@ describe('EmptyState', () => {
|
||||
})
|
||||
|
||||
describe('ConversationRoot', () => {
|
||||
function bench(views: ViewEntry[], activeView?: string) {
|
||||
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }] })
|
||||
function bench(
|
||||
tabs: ViewTab[], activeView?: string, init: Partial<FakeSnapshot> = {},
|
||||
renderSlotChain?: ConversationRootProps['renderSlotChain'],
|
||||
) {
|
||||
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
|
||||
const { useSessions } = fakeSessions([
|
||||
{ id: 'root', title: 'proj' },
|
||||
{ id: 's1', title: 'child', parentId: 'root' },
|
||||
])
|
||||
const chat = createChatStore().create()
|
||||
if (activeView !== undefined) chat.actions.setView(activeView as never)
|
||||
if (activeView !== undefined) chat.actions.setView(activeView)
|
||||
const send = vi.fn()
|
||||
const stop = vi.fn()
|
||||
const openDetails = vi.fn()
|
||||
const loadOlder = vi.fn()
|
||||
const open = vi.fn()
|
||||
// The renderSlot share as the outlet would bake it: renders a marker for
|
||||
// the ring key carrying the active-id filter (a Mock cannot satisfy the
|
||||
// generic method type directly — cast once at the prop seam).
|
||||
const renderSlot = vi.fn((key: string, _owner: object, opts?: { only?: string }) => (
|
||||
<div data-testid={`view-${opts?.only ?? '(all)'}`} data-slot={key} />
|
||||
))
|
||||
const ui = render(
|
||||
<ConversationRoot
|
||||
sessionId={sid('s1')}
|
||||
useSession={useSession}
|
||||
useSessions={useSessions}
|
||||
useStore={hookOf(chat)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot as unknown as ConversationRootProps['renderSlot']}
|
||||
renderSlotChain={renderSlotChain ?? ((_key, _owner, opts) => opts?.fallback ?? null)}
|
||||
SessionProvider={SessionProviderStub}
|
||||
views={{
|
||||
list: () => views,
|
||||
list: () => tabs,
|
||||
subscribe: () => () => {},
|
||||
version: () => 1,
|
||||
}}
|
||||
send={send}
|
||||
stop={stop}
|
||||
openDetails={openDetails}
|
||||
loadOlder={loadOlder}
|
||||
open={open}
|
||||
/>)
|
||||
return { ui, chat, send, stop, open }
|
||||
return { ui, chat, send, stop, open, renderSlot }
|
||||
}
|
||||
|
||||
/** View bodies record their mount via testid (renderView is in-component now). */
|
||||
const view = (id: string, label: string): ViewEntry =>
|
||||
({
|
||||
id, label,
|
||||
component: (() => <div data-testid={`view-${id}`} />) as unknown as FC<never>,
|
||||
}) as unknown as ViewEntry
|
||||
const tab = (id: string, label: string): ViewTab => ({ id, label })
|
||||
|
||||
it('renders breadcrumb chain (useSessions-derived), meta turns, and the default chat view', () => {
|
||||
const { open } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
|
||||
const { open } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
|
||||
expect(screen.getByText('proj')).toBeTruthy()
|
||||
expect(screen.getByText('child')).toBeTruthy()
|
||||
expect(screen.getByText(/2 turns/)).toBeTruthy()
|
||||
@@ -150,33 +159,25 @@ describe('ConversationRoot', () => {
|
||||
})
|
||||
|
||||
it('switches views through the store view field and falls back on unknown ids', () => {
|
||||
const { chat } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
|
||||
const { chat } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(chat.store.getSnapshot().view).toBe('trajectory')
|
||||
expect(screen.getByTestId('view-trajectory')).toBeTruthy()
|
||||
cleanup()
|
||||
// A stale persisted id (its view plugin unloaded) falls to the first view.
|
||||
bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')], 'ghost-view')
|
||||
bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')], 'ghost-view')
|
||||
expect(screen.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('mounts chrome header/footer around the view body', () => {
|
||||
const entry = {
|
||||
id: 'chat', label: 'Chat',
|
||||
component: () => <div data-testid="body" />,
|
||||
chrome: {
|
||||
header: () => <div data-testid="hd" />,
|
||||
footer: () => <div data-testid="ft" />,
|
||||
},
|
||||
} as unknown as ViewEntry
|
||||
bench([entry])
|
||||
expect(screen.getByTestId('hd')).toBeTruthy()
|
||||
expect(screen.getByTestId('body')).toBeTruthy()
|
||||
expect(screen.getByTestId('ft')).toBeTruthy()
|
||||
it('renders the active view through the declared ring slot with the only filter', () => {
|
||||
const { renderSlot } = bench([tab('chat', 'Chat')])
|
||||
// No owner share: views take everything from the standard kit (contract).
|
||||
expect(renderSlot).toHaveBeenCalledWith('conversation.view', {}, { only: 'chat' })
|
||||
expect(screen.getByTestId('view-chat').getAttribute('data-slot')).toBe('conversation.view')
|
||||
})
|
||||
|
||||
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
|
||||
const { chat, send } = bench([view('chat', 'Chat')])
|
||||
const { chat, send } = bench([tab('chat', 'Chat')])
|
||||
expect(screen.queryByRole('tablist')).toBeNull()
|
||||
const box = screen.getByPlaceholderText(/输入消息/)
|
||||
fireEvent.change(box, { target: { value: 'hi' } })
|
||||
@@ -185,6 +186,30 @@ describe('ConversationRoot', () => {
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(send).toHaveBeenCalledWith('hi', 'queue')
|
||||
})
|
||||
|
||||
it('dispatches the pending list to the composer chain; all-decline falls back to InputBar', () => {
|
||||
const wait = new PendingWait('question', RpcId('rq'), sid('s1'),
|
||||
{ questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn())
|
||||
// A matching entry takes the composer over.
|
||||
const renderSlotChain = vi.fn(() => <div>question takeover</div>) as unknown as ConversationRootProps['renderSlotChain']
|
||||
bench([tab('chat', 'Chat')], undefined, { pending: [wait] }, renderSlotChain)
|
||||
expect(screen.getByText('question takeover')).toBeTruthy()
|
||||
expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull()
|
||||
// The owner dispatches the raw pending list (chain currency); routing
|
||||
// lives in entry selectors, not here.
|
||||
expect(renderSlotChain).toHaveBeenCalledWith(
|
||||
'conversation.composer',
|
||||
expect.objectContaining({
|
||||
interactions: expect.arrayContaining([expect.objectContaining({ key: 'q:rq' })]),
|
||||
}),
|
||||
expect.objectContaining({ fallback: expect.anything() }),
|
||||
)
|
||||
cleanup()
|
||||
// Zero registered entries (default all-decline stub): the fallback IS the
|
||||
// default InputBar — behavior equals the pre-chain composer.
|
||||
bench([tab('chat', 'Chat')], undefined, { pending: [wait] })
|
||||
expect(screen.getByPlaceholderText(/输入消息/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel', () => {
|
||||
@@ -199,7 +224,7 @@ describe('DetailsPanel', () => {
|
||||
sessionId={sid('s1')}
|
||||
useSession={useSession}
|
||||
useSessions={useSessions}
|
||||
useStore={hookOf(chat)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={closeDetails}
|
||||
/>)
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Tool-ring Entry typing (design §7): I inferred from the inject factory at
|
||||
* the register site, component must accept ToolViewProps & I, and the resolve
|
||||
* read face carries the erased-but-present inject. Compile-time checks via
|
||||
* @ts-expect-error pairs; the runtime assertions just keep vitest happy.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FC } from 'react'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
// Positive control: component's own injected share matches the factory's product.
|
||||
interface RowInjected { useMyStore: () => number }
|
||||
const InjectedRowComp: FC<ToolViewProps & RowInjected> = () => null
|
||||
// Plain rows take the shared props only.
|
||||
const PlainRowComp: FC<ToolViewProps> = () => null
|
||||
|
||||
describe('tool-ring entry typing', () => {
|
||||
it('register infers I from the inject factory and accepts a matching component', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const off = reg.register('bash', InjectedRowComp, {
|
||||
inject: () => ({ useMyStore: () => 1 }),
|
||||
})
|
||||
expect(reg.resolve('bash', sid('s'))?.inject).toBeDefined()
|
||||
off()
|
||||
})
|
||||
|
||||
it('injectless registration needs no options and resolves without inject', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
reg.register('read', PlainRowComp)
|
||||
expect('inject' in (reg.resolve('read', sid('s')) ?? {})).toBe(false)
|
||||
})
|
||||
|
||||
it('compile-time: factory product must cover the component injected share', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
reg.register('bash', InjectedRowComp, {
|
||||
// @ts-expect-error the factory misses useMyStore, which the component requires
|
||||
inject: () => ({ somethingElse: 1 }),
|
||||
})
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
// Known boundary (not asserted): a component demanding an injected share CAN
|
||||
// register bare — with I defaulting to `object`, FC<ToolViewProps & RowInjected>
|
||||
// is structurally assignable to FC<ToolViewProps & object> (parameter
|
||||
// bivariance over a wider props type). The register-site guarantee holds in
|
||||
// the direction that matters: WITH an inject factory, its product must cover
|
||||
// the component's share (previous case). The bare-register gap is the same
|
||||
// one SlotMap's single-kind register has and is accepted by design §7.
|
||||
|
||||
it('compile-time: scope filter receives the branded SessionId', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
reg.register('bash', PlainRowComp, {
|
||||
// @ts-expect-error number is not assignable to SessionId
|
||||
scope: (id: number) => id > 0,
|
||||
})
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const sid = (s: string) => s as SessionId
|
||||
const comp = (name: string) => {
|
||||
const fc = () => null
|
||||
fc.displayName = name
|
||||
return fc as unknown as import('react').FC<ToolViewProps>
|
||||
}
|
||||
|
||||
describe('ToolViewRegistry', () => {
|
||||
it('resolves a global registration for any session', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const bash = comp('Bash')
|
||||
reg.register('bash', bash)
|
||||
expect(reg.resolve('bash', sid('a'))?.component).toBe(bash)
|
||||
expect(reg.resolve('bash', sid('b'))?.component).toBe(bash)
|
||||
expect(reg.resolve('read', sid('a'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers a matching scope filter over the global registration', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const global = comp('Global')
|
||||
const swarm = comp('Swarm')
|
||||
reg.register('bash', global)
|
||||
reg.register('bash', swarm, { scope: id => id === sid('swarm-1') })
|
||||
expect(reg.resolve('bash', sid('swarm-1'))?.component).toBe(swarm)
|
||||
expect(reg.resolve('bash', sid('plain'))?.component).toBe(global)
|
||||
})
|
||||
|
||||
it('later registration wins within the same tier, scoped and global', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const s1 = comp('S1')
|
||||
const s2 = comp('S2')
|
||||
const g1 = comp('G1')
|
||||
const g2 = comp('G2')
|
||||
reg.register('bash', g1)
|
||||
reg.register('bash', s1, { scope: () => true })
|
||||
reg.register('bash', s2, { scope: () => true })
|
||||
reg.register('bash', g2)
|
||||
expect(reg.resolve('bash', sid('x'))?.component).toBe(s2)
|
||||
const scopeless = new ToolViewRegistry()
|
||||
scopeless.register('bash', g1)
|
||||
scopeless.register('bash', g2)
|
||||
expect(scopeless.resolve('bash', sid('x'))?.component).toBe(g2)
|
||||
})
|
||||
|
||||
it('a non-matching scope filter falls through to global, then undefined', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const scoped = comp('Scoped')
|
||||
reg.register('bash', scoped, { scope: () => false })
|
||||
expect(reg.resolve('bash', sid('x'))).toBeUndefined()
|
||||
const global = comp('Global')
|
||||
reg.register('bash', global)
|
||||
expect(reg.resolve('bash', sid('x'))?.component).toBe(global)
|
||||
})
|
||||
|
||||
it('disposer removes exactly its registration and is idempotent', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const g = comp('G')
|
||||
const s = comp('S')
|
||||
const off = reg.register('bash', s, { scope: () => true })
|
||||
reg.register('bash', g)
|
||||
off()
|
||||
off()
|
||||
expect(reg.resolve('bash', sid('x'))?.component).toBe(g)
|
||||
})
|
||||
|
||||
it('unregistering the last entry resolves undefined (GenericToolCard fallback)', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const off = reg.register('bash', comp('B'))
|
||||
off()
|
||||
expect(reg.resolve('bash', sid('x'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries the inject factory through resolve', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const inject = () => ({})
|
||||
reg.register('bash', comp('B'), { inject })
|
||||
expect(reg.resolve('bash', sid('x'))?.inject).toBe(inject)
|
||||
reg.register('read', comp('R'))
|
||||
expect('inject' in reg.resolve('read', sid('x'))!).toBe(false)
|
||||
})
|
||||
|
||||
it('notifies subscribers and bumps the version on register and dispose', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const fn = vi.fn()
|
||||
const unsub = reg.subscribe(fn)
|
||||
const v0 = reg.getVersion()
|
||||
const off = reg.register('bash', comp('B'))
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
expect(reg.getVersion()).toBeGreaterThan(v0)
|
||||
off()
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
unsub()
|
||||
reg.register('read', comp('R'))
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
// Tool-ring type-chain samples (design §9 item 5, toolviews half): the
|
||||
// register→inject→resolve chain where `I` is inferred from the inject
|
||||
// factory and proved against the component at the register site, plus
|
||||
// expect-error duals. Tool names stay an open set (no per-tool props table —
|
||||
// design §7); the strong typing under test is Entry-internal. The known
|
||||
// bare-register variance edge (FC<Props & I> assignable to FC<Props & object>
|
||||
// without an inject factory) is accepted by design §7 and deliberately not
|
||||
// pinned here. Follows the slots-ring exemplar's shape.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { ToolViewOptions, ToolViewProps } from '../src/client/contract/toolview.ts'
|
||||
import { ToolViewRegistry } from '../src/client/toolviews/registry.ts'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
/** Registrant's own injected share (locally declared — ownership rule). */
|
||||
interface RowInjected { useRuns: () => number; actions2: { rerun: () => void } }
|
||||
|
||||
const InjectedRow: FC<ToolViewProps & RowInjected> = () => null
|
||||
const PlainRow: FC<ToolViewProps> = () => null
|
||||
|
||||
describe('tool-ring type-chain negatives (compile-time; body never runs)', () => {
|
||||
it('holds the negative samples as expect-error sites', () => {
|
||||
const negatives = (registry: ToolViewRegistry) => {
|
||||
// 1. Inject factory under-produces the component's declared share:
|
||||
// I infers from the factory, and the component position then fails.
|
||||
registry.register(
|
||||
'bash',
|
||||
// @ts-expect-error component wants actions2, which the factory never produces
|
||||
InjectedRow,
|
||||
{ inject: () => ({ useRuns: () => 1 }) },
|
||||
)
|
||||
// 2. Inject factory produces a drifted value type for a declared key
|
||||
// (I infers from the component position here, so TS flags the factory).
|
||||
registry.register(
|
||||
'bash',
|
||||
InjectedRow,
|
||||
// @ts-expect-error useRuns returns string here, component wants number
|
||||
{ inject: () => ({ useRuns: () => 'one', actions2: { rerun: () => {} } }) },
|
||||
)
|
||||
// 3. Options object drifts: scope filter with a wrong parameter shape.
|
||||
const badScope: ToolViewOptions<RowInjected> = {
|
||||
// @ts-expect-error scope takes a SessionId, not a numeric index
|
||||
scope: (index: number) => index > 0,
|
||||
}
|
||||
void badScope
|
||||
// 4. Component demanding props outside ToolViewProps & I (a key neither
|
||||
// standard nor injected) cannot register even with a full factory.
|
||||
const Overreaching: FC<ToolViewProps & RowInjected & { fromNowhere: boolean }> = () => null
|
||||
registry.register(
|
||||
'bash',
|
||||
// @ts-expect-error fromNowhere is neither a standard prop nor produced by the factory
|
||||
Overreaching,
|
||||
{ inject: (): RowInjected => ({ useRuns: () => 1, actions2: { rerun: () => {} } }) },
|
||||
)
|
||||
return null as ReactNode
|
||||
}
|
||||
expect(negatives).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-ring full chain (positive dual)', () => {
|
||||
it('registers with an inferred inject share, resolves by scope order, and reads the erased face back', () => {
|
||||
const registry = new ToolViewRegistry()
|
||||
// Registration: I inferred from the factory, component proved ⊇ ToolViewProps & I.
|
||||
const disposeGlobal = registry.register('bash', InjectedRow, {
|
||||
// Terminal channel form: the factory receives the session id only.
|
||||
inject: (sessionId: SessionId): RowInjected => ({
|
||||
useRuns: () => sessionId.length,
|
||||
actions2: { rerun: () => {} },
|
||||
}),
|
||||
})
|
||||
const disposeScoped = registry.register('bash', PlainRow, {
|
||||
scope: id => id === sid('swarm-1'),
|
||||
})
|
||||
|
||||
// Resolve: scope match beats global; elsewhere the global row wins.
|
||||
expect(registry.resolve('bash', sid('swarm-1'))?.component).toBe(PlainRow)
|
||||
const global = registry.resolve('bash', sid('other'))
|
||||
expect(global?.component).toBe(InjectedRow)
|
||||
// Read face: I is erased to object, the factory reference survives; the
|
||||
// outlet-side restoration is the budgeted cast (same boundary as slots).
|
||||
const injected = (global?.inject as (sessionId: SessionId) => RowInjected)(sid('ab'))
|
||||
expect(injected.useRuns()).toBe(2)
|
||||
// Unknown tool → undefined (caller falls back to the generic card).
|
||||
expect(registry.resolve('ghost-tool', sid('other'))).toBeUndefined()
|
||||
|
||||
disposeScoped()
|
||||
expect(registry.resolve('bash', sid('swarm-1'))?.component).toBe(InjectedRow)
|
||||
disposeGlobal()
|
||||
expect(registry.resolve('bash', sid('other'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,111 +1,122 @@
|
||||
// View-ring type-chain samples (design §9 item 5, views half): the
|
||||
// register→inject→render chain composed through ConversationViewMap's
|
||||
// per-view extension shapes, plus expect-error duals for each stage.
|
||||
// Follows the slots-ring exemplar (ui-slots/tests/type-chain.spec.tsx):
|
||||
// negatives live in a never-executed function body; the positive dual runs
|
||||
// the real ConversationService view registry.
|
||||
// View-ring + toolview-hole type-chain samples, slot form: both are declared
|
||||
// slots, so the register→inject→render chain and its compile-time locks are
|
||||
// the slot system's (ui-slots/tests/type-chain.spec.tsx owns the generic
|
||||
// duals). This spec pins the package-specific surface: the SlotMap rows
|
||||
// (kind/scope/owner), list- and keyed-kind registration shapes, the ChatView
|
||||
// and tool-row composed-props contracts, and the runtime dual — a real
|
||||
// SlotsService ledger driving registration/order/disposal the way
|
||||
// ConversationRoot's tab projection consumes it.
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type {
|
||||
ChromePropsOf, ConvViewProps, ConvViewPropsOf, ViewEntry,
|
||||
} from '../src/client/contract/views.ts'
|
||||
import { ConversationService } from '../src/client/service.ts'
|
||||
import type { ReactNode } from 'react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatViewSlotProps, ConvViewProps, ToolRowProps } from '../src/client/contract/slots.ts'
|
||||
|
||||
// Test-only view keys with distinct extension shapes (merged like
|
||||
// ui-trajectory does; extension fields are optional per ViewEntryDef).
|
||||
declare module '../src/client/contract/views.ts' {
|
||||
interface ConversationViewMap {
|
||||
'vt-extended': { chromeProps: { statLabel: string }; extraProps: { density: 'compact' | 'wide' } }
|
||||
'vt-plain': object
|
||||
}
|
||||
}
|
||||
|
||||
const ExtendedView: FC<ConvViewPropsOf<'vt-extended'>> = ({ density }) => (density === 'compact' ? null : null)
|
||||
const ExtendedChrome: FC<ChromePropsOf<'vt-extended'>> = ({ statLabel }) => (statLabel === '' ? null : null)
|
||||
const PlainView: FC<ConvViewPropsOf<'vt-plain'>> = () => null
|
||||
|
||||
describe('view-ring type-chain negatives (compile-time; body never runs)', () => {
|
||||
describe('view-ring type negatives (compile-time; body never runs)', () => {
|
||||
it('holds the negative samples as expect-error sites', () => {
|
||||
const negatives = (service: ConversationService) => {
|
||||
// 1. Registration: a component missing the entry's declared extraProps
|
||||
// cannot register under that id (props flow from the map entry).
|
||||
const NarrowComp: FC<ConvViewProps & { density: number }> = () => null
|
||||
service.registerView({
|
||||
id: 'vt-extended',
|
||||
label: 'x',
|
||||
// @ts-expect-error density has the wrong value type vs the map entry's extraProps
|
||||
component: NarrowComp,
|
||||
})
|
||||
// 2. Registration: chrome typed for another view's chromeProps drifts.
|
||||
service.registerView({
|
||||
id: 'vt-plain',
|
||||
label: 'x',
|
||||
component: PlainView,
|
||||
// @ts-expect-error vt-plain declares no statLabel chromeProps
|
||||
chrome: { footer: ExtendedChrome },
|
||||
})
|
||||
// 3. Registration: id outside the map is rejected at the entry.
|
||||
service.registerView({
|
||||
// @ts-expect-error unregistered view id
|
||||
id: 'vt-ghost',
|
||||
label: 'x',
|
||||
component: PlainView,
|
||||
})
|
||||
// 4. Render side: per-view props narrow — the extended view's density
|
||||
// is not accessible under another id's props type.
|
||||
const renderPlain = (props: ConvViewPropsOf<'vt-plain'>): ReactNode => {
|
||||
// @ts-expect-error density belongs to vt-extended's extension, not vt-plain
|
||||
return props.density === 'compact' ? null : null
|
||||
}
|
||||
void renderPlain
|
||||
// 5. Entry-shape drift: ViewEntry<Id> ties chrome and component to the
|
||||
// SAME id — mixing ids inside one entry fails.
|
||||
const mixed: ViewEntry<'vt-extended'> = {
|
||||
id: 'vt-extended',
|
||||
label: 'x',
|
||||
component: ExtendedView,
|
||||
// @ts-expect-error chrome for vt-plain cannot ride a vt-extended entry
|
||||
chrome: { header: (props: ChromePropsOf<'vt-plain'> & { onlyPlain: true }) => null },
|
||||
}
|
||||
void mixed
|
||||
// 6. Zero-renderSlot inference: the view ring declares no children, so
|
||||
// view props carry no delegation face (the old hand-written
|
||||
// ScopedSlots<never> empty surface is retired, not replaced).
|
||||
const renderless = (props: ConvViewPropsOf<'vt-plain'>): ReactNode => {
|
||||
const negatives = (slots: SlotsService) => {
|
||||
// 1. List-kind registration requires the id shape field.
|
||||
// @ts-expect-error missing `id` on a list-slot registration
|
||||
slots.register({ name: 'conversation.view', order: 1 }, (_p: ConvViewProps) => null)
|
||||
// 2. A keyed-kind shape field is rejected on the list slot.
|
||||
slots.register(
|
||||
// @ts-expect-error `key` belongs to keyed slots, not the list ring
|
||||
{ name: 'conversation.view', id: 'x', key: 'k' },
|
||||
(_p: ConvViewProps) => null)
|
||||
// 3. Component props must stay within the composed contract: an
|
||||
// undeclared member cannot be required.
|
||||
// @ts-expect-error component demands a prop no share supplies
|
||||
slots.register(
|
||||
{ name: 'conversation.view', id: 'y' },
|
||||
(_p: ConvViewProps & { phantom: number }) => null)
|
||||
// 4. Views receive no renderSlot — the ring's entries declare no children.
|
||||
const renderless = (props: ConvViewProps): ReactNode => {
|
||||
// @ts-expect-error views receive no renderSlot — no sub-slot delegation
|
||||
void props.renderSlot
|
||||
// @ts-expect-error the legacy slots face is gone from view props
|
||||
void props.slots
|
||||
return null
|
||||
}
|
||||
void renderless
|
||||
// 5. The chat entry's face is its own: openDetails does not exist on the
|
||||
// base view props (store-less riders never see it).
|
||||
const baseOnly = (props: ConvViewProps): ReactNode => {
|
||||
// @ts-expect-error openDetails lives on ChatViewSlotProps, not the base
|
||||
void props.openDetails
|
||||
return null
|
||||
}
|
||||
void baseOnly
|
||||
// 6. ChatViewSlotProps carries the full composition (standard kit +
|
||||
// store + inject face) — a handler with a wrong signature is red.
|
||||
const chatProps = (props: ChatViewSlotProps): ReactNode => {
|
||||
// @ts-expect-error openDetails takes a SelectionTarget, not a string
|
||||
props.openDetails('nope')
|
||||
return null
|
||||
}
|
||||
void chatProps
|
||||
// 7. Keyed hole registration requires the key shape field.
|
||||
// @ts-expect-error missing `key` on a keyed-slot registration
|
||||
slots.register({ name: 'conversation.chat.toolview' }, (_p: ToolRowProps) => null)
|
||||
// 8. A list-kind shape field is rejected on the keyed hole.
|
||||
slots.register(
|
||||
// @ts-expect-error `id`/`order` belong to list slots, not the keyed hole
|
||||
{ name: 'conversation.chat.toolview', key: 'k', order: 1 },
|
||||
(_p: ToolRowProps) => null)
|
||||
// 9. Tool-row components stay within their composed contract: the
|
||||
// owner share + standard kit supply no chat-view members.
|
||||
const overreaching = (props: ToolRowProps): ReactNode => {
|
||||
// @ts-expect-error loadOlder lives on ChatViewSlotProps, not the row contract
|
||||
void props.loadOlder
|
||||
return null
|
||||
}
|
||||
void overreaching
|
||||
// 10. Owner-share drift is red at the row component seam: block is the
|
||||
// call union, not arbitrary payload.
|
||||
const drifted = (props: ToolRowProps): ReactNode => {
|
||||
// @ts-expect-error the block union has no `argsParsed` member
|
||||
void props.block.argsParsed
|
||||
return null
|
||||
}
|
||||
void drifted
|
||||
return null as ReactNode
|
||||
}
|
||||
expect(negatives).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('view-ring full chain (positive dual)', () => {
|
||||
it('registers, lists, and renders through the per-view extension shapes', () => {
|
||||
describe('view-ring runtime dual (real ledger)', () => {
|
||||
function bench() {
|
||||
const ctx = new Context()
|
||||
const service = new ConversationService(ctx)
|
||||
// Registration: extension-typed component + same-id chrome compose cleanly.
|
||||
const dispose = service.registerView({
|
||||
id: 'vt-extended',
|
||||
label: '扩展视图',
|
||||
order: 7,
|
||||
component: ExtendedView,
|
||||
chrome: { footer: ExtendedChrome },
|
||||
})
|
||||
const entry = service.views().find(v => v.id === 'vt-extended')
|
||||
expect(entry?.label).toBe('扩展视图')
|
||||
// Render surface: the listed entry's component accepts the composed props
|
||||
// (base ConvViewProps + the map extension), spelled here as the same type
|
||||
// the runtime hands over.
|
||||
expect(typeof entry?.component).toBe('function')
|
||||
expect(typeof entry?.chrome?.footer).toBe('function')
|
||||
dispose()
|
||||
expect(service.views().some(v => v.id === 'vt-extended')).toBe(false)
|
||||
const slots = new SlotsService(ctx)
|
||||
// The conversation entry's role: declare the ring (declaring is claiming).
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
return { slots }
|
||||
}
|
||||
|
||||
it('registers, orders, projects tabs, and disposes through the slot ledger', () => {
|
||||
const { slots } = bench()
|
||||
const offLate = slots.register(
|
||||
{ name: 'conversation.view', id: 'z-late', order: 20, label: '晚' }, () => null)
|
||||
const offEarly = slots.register(
|
||||
{ name: 'conversation.view', id: 'early', order: 0, label: '早' }, () => null)
|
||||
// Order-sorted ledger, label fallback for a labelless rider.
|
||||
const offBare = slots.register(
|
||||
{ name: 'conversation.view', id: 'bare', order: 10 }, () => null)
|
||||
const tabs = slots.entries('conversation.view')
|
||||
.map(e => ({ id: e.options.id, label: e.options.label ?? e.options.id }))
|
||||
expect(tabs).toEqual([
|
||||
{ id: 'early', label: '早' },
|
||||
{ id: 'bare', label: 'bare' },
|
||||
{ id: 'z-late', label: '晚' },
|
||||
])
|
||||
// Duplicate ids fail loud at load (the ring's uniqueness contract).
|
||||
expect(() => slots.register({ name: 'conversation.view', id: 'early' }, () => null))
|
||||
.toThrow(/already has an entry with id "early"/)
|
||||
offEarly()
|
||||
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['bare', 'z-late'])
|
||||
offBare()
|
||||
offLate()
|
||||
expect(slots.entries('conversation.view')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-client-ui-layout
|
||||
|
||||
Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. A closed sidebar retains a 56px control rail while details closes to zero width; collapse/expand animates the grid tracks on the deepsuite sider curve. Contract: api-contracts v3 §5.
|
||||
Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. The sidebar is fixed-width (it never concedes to viewport pressure — only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width; collapse/expand animates the grid tracks on the deepsuite sider curve. Contract: api-contracts v3 §5.
|
||||
|
||||
Slot declarations use the composed-props entry form (`owner` share, no full `props`): the exported OwnerShare contracts are `SidebarOwnerProps` / `ConvOwnerProps` / `DetailsOwnerProps` / `EmptyOwnerProps` — registrants reference them via `OwnerOf<'sidebar' | ...>` and compose their own injected share locally. No entry declares `children` (declaring it requires the registered component to carry the slots face — reserved for future business slots): delegation authority is the component-side whitelist, i.e. AppFrame's `ScopedSlots<FrameSlotKey>` face over sidebar/conversation/details/conversation.empty. Since the root-slot rework the frame itself registers into 'root' and renders those child slots at its own render sites; the shell only renders 'root'.
|
||||
|
||||
|
||||
@@ -33,19 +33,20 @@
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^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"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@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",
|
||||
|
||||
@@ -86,11 +86,25 @@
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
background: var(--dsw-alias-button-floating-fill);
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
/* Hover affordance: the pill hides until the pointer is over the owning
|
||||
column (data-side pairs handle and column), the strip itself, or a drag. */
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.sidebarCol:hover ~ .handle[data-side='sidebar']::after,
|
||||
.detailsCol:hover ~ .handle[data-side='details']::after,
|
||||
.handle:hover::after,
|
||||
.handle[data-dragging='true']::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.handle:hover::after,
|
||||
.handle[data-dragging='true']::after {
|
||||
background: var(--dsw-alias-button-floating-hover);
|
||||
border-color: var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ function DetailsColumn(props: { children?: ReactNode }) {
|
||||
return <div className={css.detailsCol}>{props.children}</div>
|
||||
}
|
||||
|
||||
/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. */
|
||||
function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) {
|
||||
/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. `side` keys the hover-reveal CSS to the owning column. */
|
||||
function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const origin = useRef(0)
|
||||
const latest = useRef(0)
|
||||
@@ -72,6 +72,7 @@ function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: num
|
||||
<div
|
||||
className={css.handle}
|
||||
style={{ left: props.left }}
|
||||
data-side={props.side}
|
||||
data-dragging={dragging || undefined}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
@@ -161,8 +162,8 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App
|
||||
)}
|
||||
</SessionProvider>
|
||||
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
|
||||
{panels.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
|
||||
{cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}
|
||||
{panels.sidebar > 0 && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
|
||||
{cols.details > 0 && <DragHandle side="details" left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Pure concession-chain column solver for the three-column AppFrame.
|
||||
* Chain order is fixed by contract: keep center >= CENTER_MIN by shrinking
|
||||
* details first, then sidebar, then auto-closing details (derived zero width —
|
||||
* persisted width preferences are never rewritten, so widening the window
|
||||
* restores them). Center absorbs any remaining deficit as the last resort.
|
||||
* Inputs are the layout store's plain width preferences (0 = closed); a
|
||||
* closed sidebar resolves to the fixed SIDEBAR_COLLAPSED control rail while
|
||||
* closed details resolve to zero width.
|
||||
* details, then auto-closing it (derived zero width — persisted width
|
||||
* preferences are never rewritten, so widening the window restores them).
|
||||
* The sidebar never concedes: its rendered width is always the drag
|
||||
* preference (or the collapsed rail), and center absorbs any remaining
|
||||
* deficit as the last resort. Inputs are the layout store's plain width
|
||||
* preferences (0 = closed); a closed sidebar resolves to the fixed
|
||||
* SIDEBAR_COLLAPSED control rail while closed details resolve to zero width.
|
||||
*/
|
||||
|
||||
/** Resolved widths for one frame; center may drop below CENTER_MIN only at the final fallback. */
|
||||
@@ -16,11 +17,11 @@ export interface Columns { sidebar: number; center: number; details: number }
|
||||
/** Center column floor; only the final fallback may go below it. */
|
||||
export const CENTER_MIN = 640
|
||||
/** Sidebar drag clamp floor. */
|
||||
export const SIDEBAR_MIN = 240
|
||||
export const SIDEBAR_MIN = 280
|
||||
/** Sidebar drag clamp ceiling. */
|
||||
export const SIDEBAR_MAX = 420
|
||||
/** Sidebar width before any user drag. */
|
||||
export const SIDEBAR_DEFAULT = 300
|
||||
/** Sidebar width before any user drag (= the drag floor). */
|
||||
export const SIDEBAR_DEFAULT = 280
|
||||
/** Closed-sidebar rail: a 24px icon column between 16px horizontal paddings. */
|
||||
export const SIDEBAR_COLLAPSED = 56
|
||||
/** Details drag clamp floor. */
|
||||
@@ -44,38 +45,26 @@ export function clampWidth(px: number, min: number, max: number): number {
|
||||
/**
|
||||
* Solve the three column widths for one viewport frame. Pure: no hysteresis —
|
||||
* the output is a function of (viewport, preferences) only, so recovery on
|
||||
* re-widening is automatic. After the auto-close step the details pressure is
|
||||
* gone, so the sidebar returns to its preferred width when it fits.
|
||||
* Preferences re-clamp here because they cross a durable boundary
|
||||
* (localStorage rehydration may carry stale ranges).
|
||||
* re-widening is automatic. Preferences re-clamp here because they cross a
|
||||
* durable boundary (localStorage rehydration may carry stale ranges).
|
||||
* @param viewport - available frame width in px.
|
||||
* @param sidebar - sidebar width preference in px (0 = closed).
|
||||
* @param details - details width preference in px (0 = closed).
|
||||
* @returns resolved widths; details 0 means visually closed (never unmounted), while a closed sidebar keeps its compact rail.
|
||||
*/
|
||||
export function computeColumns(viewport: number, sidebar: number, details: number): Columns {
|
||||
const s0 = sidebar === 0 ? SIDEBAR_COLLAPSED : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
|
||||
// The sidebar is fixed at its preference (or the rail) — it never concedes.
|
||||
const s = sidebar === 0 ? SIDEBAR_COLLAPSED : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
|
||||
const d0 = details === 0 ? 0 : clampWidth(details, DETAILS_MIN, DETAILS_MAX)
|
||||
|
||||
// Step 1: everything fits at preferred widths.
|
||||
if (s0 + d0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0 - d0, details: d0 }
|
||||
if (s + d0 + CENTER_MIN <= viewport) return { sidebar: s, center: viewport - s - d0, details: d0 }
|
||||
|
||||
// Step 2: shrink details toward its minimum.
|
||||
const d1 = d0 === 0 ? 0 : Math.max(DETAILS_MIN, viewport - s0 - CENTER_MIN)
|
||||
if (s0 + d1 + CENTER_MIN <= viewport) return { sidebar: s0, center: CENTER_MIN, details: d1 }
|
||||
const d1 = d0 === 0 ? 0 : Math.max(DETAILS_MIN, viewport - s - CENTER_MIN)
|
||||
if (s + d1 + CENTER_MIN <= viewport) return { sidebar: s, center: CENTER_MIN, details: d1 }
|
||||
|
||||
// Step 3: shrink sidebar toward its minimum (the collapsed rail never shrinks).
|
||||
const s1 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN)
|
||||
if (s1 + d1 + CENTER_MIN <= viewport) return { sidebar: s1, center: CENTER_MIN, details: d1 }
|
||||
|
||||
// Step 4: auto-close details (derived — preferences untouched). With the
|
||||
// details pressure gone the sidebar concession is re-solved from preference.
|
||||
if (d1 > 0) {
|
||||
if (s0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0, details: 0 }
|
||||
const s2 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - CENTER_MIN)
|
||||
return { sidebar: s2, center: Math.max(0, viewport - s2), details: 0 }
|
||||
}
|
||||
|
||||
// Step 5: center absorbs the deficit (may drop below CENTER_MIN).
|
||||
return { sidebar: s1, center: Math.max(0, viewport - s1 - d1), details: d1 }
|
||||
// Step 3: auto-close details (derived — preferences untouched); center
|
||||
// absorbs any remaining deficit (may drop below CENTER_MIN).
|
||||
return { sidebar: s, center: Math.max(0, viewport - s), details: 0 }
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapsho
|
||||
function mountFrame() {
|
||||
window.innerWidth = frameWidth // first-render viewport source before the observer fires
|
||||
const instance = createLayoutStore().create()
|
||||
instance.actions.openDetails() // seed: sidebar at default 300, details open at default 360
|
||||
instance.actions.openDetails() // seed: sidebar at default 280, details open at default 360
|
||||
const slotCalls: { key: string; props: unknown }[] = []
|
||||
const renderSlot = ((key: string, owner: object) => {
|
||||
slotCalls.push({ key, props: owner })
|
||||
@@ -116,7 +116,7 @@ afterEach(() => {
|
||||
describe('AppFrame', () => {
|
||||
it('renders three tracks from store state', () => {
|
||||
const { frame } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([300, 360])
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
})
|
||||
|
||||
it('renders the session pair with empty owner shares (sessionId is framework-standard)', () => {
|
||||
@@ -142,13 +142,13 @@ describe('AppFrame', () => {
|
||||
|
||||
it('sidebar slot receives live concession output as owner props', () => {
|
||||
const { slotCalls } = mountFrame()
|
||||
expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 300 })
|
||||
expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 })
|
||||
})
|
||||
|
||||
it('sidebar drag widens through rAF-batched pointer moves', () => {
|
||||
const { frame } = mountFrame()
|
||||
const handles = frame.querySelectorAll('[class*="handle"]')
|
||||
drag(handles[0]!, 300, 350)
|
||||
drag(handles[0]!, 280, 350)
|
||||
expect(tracks(frame)[0]).toBe(350)
|
||||
})
|
||||
|
||||
@@ -160,18 +160,18 @@ describe('AppFrame', () => {
|
||||
})
|
||||
|
||||
it('drag base is the rendered (concession-clamped) width, not the preference', () => {
|
||||
frameWidth = 1250 // step-2 squeeze: details renders 310 while preference is 360
|
||||
frameWidth = 1250 // step-2 squeeze: details renders 330 while preference is 360
|
||||
const { frame, instance } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([300, 310])
|
||||
expect(tracks(frame)).toEqual([280, 330])
|
||||
const handles = frame.querySelectorAll('[class*="handle"]')
|
||||
drag(handles[1]!, 940, 950) // shrink by 10 from the rendered width
|
||||
expect(instance.getSnapshot().details).toBe(300)
|
||||
drag(handles[1]!, 920, 930) // shrink by 10 from the rendered width
|
||||
expect(instance.getSnapshot().details).toBe(320)
|
||||
})
|
||||
|
||||
it('details column stays mounted at zero width', () => {
|
||||
const { frame, instance, getByTestId } = mountFrame()
|
||||
act(() => { instance.actions.closeDetails() })
|
||||
expect(tracks(frame)).toEqual([300, 0])
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
expect(getByTestId('details-content')).toBeTruthy()
|
||||
expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
|
||||
})
|
||||
@@ -190,10 +190,10 @@ describe('AppFrame', () => {
|
||||
const { frame } = mountFrame()
|
||||
frameWidth = 1250
|
||||
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
expect(tracks(frame)).toEqual([300, 310])
|
||||
expect(tracks(frame)).toEqual([280, 330])
|
||||
frameWidth = 1920
|
||||
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
expect(tracks(frame)).toEqual([300, 360])
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
})
|
||||
|
||||
it('drag handles disappear for collapsed columns', () => {
|
||||
@@ -223,7 +223,7 @@ describe('AppFrame — guard branches', () => {
|
||||
it('two moves inside one frame coalesce through the pending rAF', () => {
|
||||
const { frame, instance } = mountFrame()
|
||||
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
|
||||
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
|
||||
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) })
|
||||
act(() => {
|
||||
// Two moves before the frame flushes: the second must ride the pending
|
||||
// rAF (frame.current ??= guard), and the flush sees the latest x.
|
||||
@@ -238,7 +238,7 @@ describe('AppFrame — guard branches', () => {
|
||||
it('pointerup with a pending rAF cancels it and commits the final position', () => {
|
||||
const { frame, instance } = mountFrame()
|
||||
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
|
||||
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
|
||||
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) })
|
||||
act(() => {
|
||||
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 360, bubbles: true }))
|
||||
// No timer advance: the rAF is still pending when pointerup arrives.
|
||||
@@ -252,7 +252,7 @@ describe('AppFrame — guard branches', () => {
|
||||
frameWidth = 0
|
||||
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
// Track template still reflects the last non-zero viewport.
|
||||
expect(tracks(frame)).toEqual([300, 360])
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -270,6 +270,6 @@ describe('AppFrame — unmount with an in-flight resize frame', () => {
|
||||
const { frame } = mountFrame()
|
||||
frameWidth = 1250
|
||||
act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
expect(tracks(frame)).toEqual([300, 310])
|
||||
expect(tracks(frame)).toEqual([280, 330])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('clampWidth', () => {
|
||||
describe('computeColumns', () => {
|
||||
it('step 1: everything fits at preferred widths', () => {
|
||||
const cols = computeColumns(1920, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: 300, center: 1920 - 300 - 360, details: 360 })
|
||||
expect(cols).toEqual({ sidebar: 280, center: 1920 - 280 - 360, details: 360 })
|
||||
})
|
||||
|
||||
it('closed sidebar keeps its compact rail while closed details contribute zero width', () => {
|
||||
@@ -31,12 +31,13 @@ describe('computeColumns', () => {
|
||||
const cols = computeColumns(1920, open(9999), open(1))
|
||||
expect(cols.sidebar).toBe(420)
|
||||
expect(cols.details).toBe(300)
|
||||
expect(computeColumns(1920, open(1), open(DETAILS_DEFAULT)).sidebar).toBe(SIDEBAR_MIN)
|
||||
})
|
||||
|
||||
it('step 2: details shrinks first, center pinned at min', () => {
|
||||
// 300 + 360 + 640 = 1300 > 1250; details concedes to 1250-300-640 = 310.
|
||||
// 280 + 360 + 640 = 1280 > 1250; details concedes to 1250-280-640 = 330.
|
||||
const cols = computeColumns(1250, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: 300, center: CENTER_MIN, details: 310 })
|
||||
expect(cols).toEqual({ sidebar: 280, center: CENTER_MIN, details: 330 })
|
||||
})
|
||||
|
||||
it('boundary: exactly at the step-1/step-2 seam', () => {
|
||||
@@ -46,28 +47,16 @@ describe('computeColumns', () => {
|
||||
expect(one).toEqual({ sidebar: 300, center: CENTER_MIN, details: 359 })
|
||||
})
|
||||
|
||||
it('step 3: sidebar concedes after details hits its min', () => {
|
||||
// details floor 300: sidebar = 1220-300-640 = 280.
|
||||
const cols = computeColumns(1220, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: 280, center: CENTER_MIN, details: DETAILS_MIN })
|
||||
it('step 3: details auto-closes when its min still starves center — sidebar holds its preference', () => {
|
||||
// 280 + 300 + 640 = 1220 > 1210 → details 0; sidebar untouched: center = 1210-280 = 930.
|
||||
const cols = computeColumns(1210, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: 280, center: 930, details: 0 })
|
||||
})
|
||||
|
||||
it('step 4: details auto-closes when both panels are at min and center still starves', () => {
|
||||
// 240 + 300 + 640 = 1180 > 1100 → details 0; sidebar preference (300) fits: 1100-300 = 800 center.
|
||||
const cols = computeColumns(1100, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: 300, center: 800, details: 0 })
|
||||
})
|
||||
|
||||
it('step 4 keeps squeezing sidebar when preference no longer fits', () => {
|
||||
// 900 < 300+640: sidebar = max(240, 900-640) = 260.
|
||||
const cols = computeColumns(900, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: 260, center: CENTER_MIN, details: 0 })
|
||||
})
|
||||
|
||||
it('step 5: center absorbs the deficit as last resort (details closed)', () => {
|
||||
// 700 < 240+640: sidebar floors at 240, center takes 460 < CENTER_MIN.
|
||||
it('the sidebar never concedes: center absorbs the deficit below CENTER_MIN', () => {
|
||||
// 700 < 280+640: sidebar keeps 280, center takes 420 < CENTER_MIN.
|
||||
const cols = computeColumns(700, open(SIDEBAR_DEFAULT), closed(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: SIDEBAR_MIN, center: 460, details: 0 })
|
||||
expect(cols).toEqual({ sidebar: SIDEBAR_DEFAULT, center: 420, details: 0 })
|
||||
})
|
||||
|
||||
it('sidebar-closed narrow window: details concedes then auto-closes', () => {
|
||||
@@ -81,11 +70,11 @@ describe('computeColumns', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('tiny viewport: both panels yield everything to center', () => {
|
||||
it('tiny viewport: details closes, sidebar holds, center takes the remainder', () => {
|
||||
const cols = computeColumns(400, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols.details).toBe(0)
|
||||
expect(cols.sidebar).toBe(SIDEBAR_MIN)
|
||||
expect(cols.center).toBe(Math.max(0, 400 - SIDEBAR_MIN))
|
||||
expect(cols.sidebar).toBe(SIDEBAR_DEFAULT)
|
||||
expect(cols.center).toBe(Math.max(0, 400 - SIDEBAR_DEFAULT))
|
||||
})
|
||||
|
||||
it('recovery is pure: re-widening restores preferred widths untouched', () => {
|
||||
@@ -99,7 +88,7 @@ describe('computeColumns', () => {
|
||||
|
||||
describe('computeColumns — degenerate viewports', () => {
|
||||
it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes the rest', () => {
|
||||
// Reaches step 4's re-solve with the compact rail as the sidebar floor.
|
||||
// Reaches step 3's auto-close with the compact rail sidebar.
|
||||
expect(computeColumns(500, closed(300), open(DETAILS_DEFAULT)))
|
||||
.toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 500 - SIDEBAR_COLLAPSED, details: 0 })
|
||||
})
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# @deepseek-ai/dsh-client-ui-primitives
|
||||
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/JsonBlock). Contract: api-contracts v3 §8.
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8.
|
||||
|
||||
## Markdown rendering
|
||||
|
||||
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -15,4 +19,3 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
|
||||
- **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
|
||||
- **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface.
|
||||
- **MessageText renders plain text** — markdown support swaps this component's internals later; consumers must not assume block structure.
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0"
|
||||
"react": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
|
||||
56
packages/client/ui-primitives/src/BrandWordmark.tsx
Normal file
56
packages/client/ui-primitives/src/BrandWordmark.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
// DeepSeek Harness brand wordmark (figma 356:14644, exact extract): whale +
|
||||
// "deepseek" letterforms + HARNESS badge plate in one svg. Native 182x24.
|
||||
// Ink rides currentColor; the badge text is knocked out in the inverted
|
||||
// label color so the plate stays legible in both themes.
|
||||
|
||||
import type { IconProps } from './icons/props.ts'
|
||||
|
||||
/**
|
||||
* Render the full brand wordmark.
|
||||
* @param props.size - height in px (default 24; width keeps the 182:24 ratio).
|
||||
* @param props.className - extra class for layout placement.
|
||||
* @returns the wordmark svg (aria-hidden decorative brand art).
|
||||
*/
|
||||
export function BrandWordmark({ size = 24, className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={(size * 182) / 24}
|
||||
height={size}
|
||||
className={className}
|
||||
viewBox="0 0 182 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M68.416 18.2447H67.0501V16.1272H68.416C69.2619 16.1272 70.1166 15.9163 70.6671 15.3304C71.2181 14.7444 71.426 13.8455 71.426 12.9471C71.426 12.0487 71.2268 11.1498 70.6671 10.5643C70.1083 9.97831 69.2619 9.76744 68.416 9.76744C67.5701 9.76744 66.7154 9.97831 66.1639 10.5643C65.6129 11.1503 65.4049 12.0487 65.4049 12.9471V21.6435H63.009V7.6582H65.4049V8.54883H65.8442C65.8918 8.49393 65.9394 8.44728 65.9875 8.40064C66.5871 7.85353 67.5049 7.6582 68.4072 7.6582C69.8212 7.6582 71.2341 8.00998 72.1607 8.98662C73.0868 9.96325 73.4143 11.4632 73.4143 12.9558C73.4143 14.4485 73.0785 15.9406 72.1607 16.925C71.2424 17.9094 69.8212 18.2457 68.416 18.2457V18.2447Z" fill="currentColor"/>
|
||||
<path d="M31.9551 8.03497H33.3204V10.1525H31.9551C31.1087 10.1525 30.2545 10.3633 29.7035 10.9493C29.1525 11.5353 28.945 12.4342 28.945 13.3326C28.945 14.231 29.1447 15.1294 29.7035 15.7154C30.2623 16.3014 31.1087 16.5122 31.9551 16.5122C32.8015 16.5122 33.6562 16.3014 34.2072 15.7154C34.7582 15.1294 34.9657 14.231 34.9657 13.3326V4.62842H37.3611V18.6219H34.9657V17.7313H34.5264C34.4783 17.7857 34.4307 17.8329 34.3826 17.8795C33.7835 18.4261 32.8652 18.6219 31.9629 18.6219C30.5494 18.6219 29.136 18.2707 28.2099 17.294C27.2838 16.3174 26.9563 14.817 26.9563 13.3248C26.9563 11.8327 27.2916 10.34 28.2099 9.35561C29.136 8.37898 30.5494 8.03497 31.9551 8.03497Z" fill="currentColor"/>
|
||||
<path d="M49.3786 13.1431V13.9948H42.9984V12.2996H47.2305C47.1348 11.6825 46.9113 11.1043 46.5119 10.682C45.9371 10.0727 45.0503 9.85409 44.1723 9.85409C43.2943 9.85409 42.4076 10.0727 41.8328 10.682C41.258 11.2913 41.05 12.2213 41.05 13.1435C41.05 14.0658 41.2575 15.003 41.8328 15.6046C42.4076 16.2061 43.2939 16.433 44.1723 16.433C45.0508 16.433 45.9371 16.2143 46.5119 15.6046C46.5916 15.5186 46.6635 15.4248 46.7354 15.331H49.0992C48.8918 16.0657 48.5643 16.7299 48.0691 17.2454C47.111 18.2531 45.6339 18.6205 44.1723 18.6205C42.7108 18.6205 41.2337 18.2609 40.2755 17.2454C39.3174 16.2299 38.9661 14.6828 38.9661 13.1435C38.9661 11.6043 39.3096 10.0494 40.2755 9.04168C41.242 8.03396 42.7108 7.66663 44.1723 7.66663C45.6339 7.66663 47.111 8.02618 48.0691 9.04168C49.0351 10.0572 49.3786 11.6043 49.3786 13.1435V13.1431Z" fill="currentColor"/>
|
||||
<path d="M61.4045 13.1431V13.9948H55.0243V12.2996H59.2564C59.1602 11.6825 58.9372 11.1043 58.5378 10.682C57.963 10.0727 57.0762 9.85409 56.1982 9.85409C55.3202 9.85409 54.4335 10.0727 53.8587 10.682C53.2839 11.2913 53.0759 12.2213 53.0759 13.1435C53.0759 14.0658 53.2834 15.003 53.8587 15.6046C54.4335 16.2061 55.3202 16.433 56.1982 16.433C57.0762 16.433 57.963 16.2143 58.5378 15.6046C58.6179 15.5186 58.6894 15.4248 58.7608 15.331H61.1251C60.9171 16.0657 60.5897 16.7299 60.0945 17.2454C59.1364 18.2531 57.6593 18.6205 56.1982 18.6205C54.7372 18.6205 53.2596 18.2609 52.3014 17.2454C51.3432 16.2299 50.9919 14.6828 50.9919 13.1435C50.9919 11.6043 51.3355 10.0494 52.3014 9.04168C53.2678 8.03396 54.7367 7.66663 56.1982 7.66663C57.6598 7.66663 59.1364 8.02618 60.0945 9.04168C61.061 10.0572 61.4045 11.6043 61.4045 13.1435V13.1431Z" fill="currentColor"/>
|
||||
<path d="M80.242 18.6214C81.7035 18.6214 83.1801 18.4105 84.1383 17.809C85.0965 17.2075 85.4482 16.2931 85.4482 15.3869C85.4482 14.4807 85.1042 13.5585 84.1383 12.9647C83.1801 12.371 81.703 12.1518 80.242 12.1518C79.6186 12.1518 79.0438 12.0658 78.6366 11.8394C78.2294 11.6047 78.0778 11.2534 78.0778 10.9017C78.0778 10.5499 78.2216 10.1908 78.6366 9.9639C79.0438 9.72921 79.6749 9.65147 80.2973 9.65147C80.9198 9.65147 81.5509 9.73747 81.9591 9.9639C82.3663 10.1986 82.5179 10.5499 82.5179 10.9017H84.9531C84.9531 9.99499 84.6421 9.07327 83.7719 8.47951C82.9017 7.88576 81.5679 7.66663 80.2424 7.66663C78.9169 7.66663 77.5837 7.8775 76.713 8.47951C75.8427 9.08104 75.5308 9.99499 75.5308 10.9017C75.5308 11.8083 75.8423 12.73 76.713 13.3238C77.5832 13.9176 78.9165 14.1367 80.2424 14.1367C80.929 14.1367 81.688 14.2227 82.1428 14.4491C82.5985 14.676 82.7579 15.0351 82.7579 15.3869C82.7579 15.7387 82.5985 16.0977 82.1428 16.3246C81.688 16.5511 80.9931 16.6371 80.3066 16.6371C79.62 16.6371 78.9169 16.5511 78.4694 16.3246C78.0224 16.0982 77.8543 15.7387 77.8543 15.3869H75.0435C75.0435 16.2935 75.3865 17.2153 76.3534 17.809C77.3194 18.4028 78.7809 18.6214 80.2424 18.6214H80.242Z" fill="currentColor"/>
|
||||
<path d="M97.4733 13.1431V13.9948H91.0932V12.2996H95.3252C95.23 11.6825 95.006 11.1043 94.6071 10.682C94.0313 10.0727 93.1456 9.85409 92.2666 9.85409C91.3876 9.85409 90.5018 10.0727 89.927 10.682C89.3522 11.2913 89.1452 12.2213 89.1452 13.1435C89.1452 14.0658 89.3522 15.003 89.927 15.6046C90.5018 16.2061 91.3886 16.433 92.2666 16.433C93.1446 16.433 94.0313 16.2143 94.6071 15.6046C94.6863 15.5186 94.7587 15.4248 94.8301 15.331H97.1935C96.9855 16.0657 96.6585 16.7299 96.1639 17.2454C95.2057 18.2531 93.7281 18.6205 92.2666 18.6205C90.805 18.6205 89.3284 18.2609 88.3703 17.2454C87.4121 16.2299 87.0613 14.6828 87.0613 13.1435C87.0613 11.6043 87.4043 10.0494 88.3703 9.04168C89.3367 8.03396 90.806 7.66663 92.2666 7.66663C93.7272 7.66663 95.2057 8.02618 96.1639 9.04168C97.1298 10.0572 97.4729 11.6043 97.4729 13.1435L97.4733 13.1431Z" fill="currentColor"/>
|
||||
<path d="M109.499 13.1431V13.9948H103.119V12.2996H107.351C107.256 11.6825 107.032 11.1043 106.632 10.682C106.057 10.0727 105.172 9.85409 104.293 9.85409C103.414 9.85409 102.528 10.0727 101.953 10.682C101.378 11.2913 101.17 12.2213 101.17 13.1435C101.17 14.0658 101.378 15.003 101.953 15.6046C102.528 16.2061 103.415 16.433 104.293 16.433C105.171 16.433 106.057 16.2143 106.632 15.6046C106.712 15.5186 106.784 15.4248 106.856 15.331H109.22C109.012 16.0657 108.685 16.7299 108.19 17.2454C107.231 18.2531 105.754 18.6205 104.293 18.6205C102.831 18.6205 101.355 18.2609 100.396 17.2454C99.4382 16.2299 99.0864 14.6828 99.0864 13.1435C99.0864 11.6043 99.4295 10.0494 100.396 9.04168C101.362 8.03396 102.832 7.66663 104.293 7.66663C105.754 7.66663 107.231 8.02618 108.19 9.04168C109.156 10.0572 109.499 11.6043 109.499 13.1435V13.1431Z" fill="currentColor"/>
|
||||
<path d="M113.5 4.62817H111.104V18.6217H113.5V4.62817Z" fill="currentColor"/>
|
||||
<path d="M117.589 12.8154L121.517 18.6208H118.554L114.625 12.8154L118.554 8.15088H121.517L117.589 12.8154Z" fill="currentColor"/>
|
||||
<g clipPath="url(#dsh-wordmark-whale-clip)">
|
||||
<path d="M23.0584 4.95203C22.8129 4.83203 22.7074 5.06103 22.5639 5.17704C22.5149 5.21454 22.4734 5.26354 22.4319 5.30854C22.0734 5.69155 21.6543 5.94306 21.1073 5.91306C20.3073 5.86806 19.6243 6.11957 19.0203 6.73158C18.8918 5.97706 18.4652 5.52655 17.8162 5.23754C17.4767 5.08753 17.1332 4.93703 16.8952 4.61052C16.7292 4.37801 16.6837 4.11901 16.6007 3.8635C16.5477 3.70949 16.4952 3.55199 16.3177 3.52549C16.1252 3.49549 16.0497 3.65699 15.9742 3.792C15.6722 4.34401 15.5552 4.95203 15.5667 5.56805C15.5932 6.95359 16.1782 8.05712 17.3407 8.84215C17.4727 8.93215 17.5067 9.02215 17.4652 9.15366C17.3857 9.42416 17.2917 9.68667 17.2087 9.95718C17.1557 10.1297 17.0767 10.1677 16.8917 10.0922C16.2537 9.82568 15.7027 9.43117 15.2156 8.95465C14.3891 8.15513 13.6416 7.2726 12.7096 6.58158C12.4906 6.42007 12.2716 6.27007 12.045 6.12707C11.094 5.20354 12.1696 4.44502 12.4186 4.35501C12.6791 4.26101 12.5091 3.938 11.6675 3.942C10.826 3.9455 10.056 4.22751 9.07446 4.60302C8.93096 4.65952 8.77995 4.70052 8.62545 4.73452C7.73492 4.56552 6.80989 4.52802 5.84386 4.63702C4.02481 4.83953 2.57177 5.69955 1.50373 7.1676C0.220694 8.93215 -0.0813148 10.9372 0.288196 13.0283C0.676708 15.2323 1.80174 17.0569 3.53029 18.4834C5.32285 19.9625 7.38741 20.6875 9.74298 20.5485C11.1735 20.466 12.7661 20.2745 14.5626 18.7539C15.0156 18.9795 15.4912 19.0695 16.2797 19.137C16.8872 19.1935 17.4722 19.107 17.9252 19.013C18.6347 18.8629 18.5857 18.2059 18.3292 18.0854C16.2497 17.1169 16.7062 17.5109 16.2912 17.1919C17.3477 15.9419 18.9618 13.7198 19.4598 10.6942C19.5088 10.3602 19.5713 9.88968 19.5638 9.61917C19.5598 9.45417 19.5978 9.39016 19.7863 9.37116C20.3073 9.31116 20.8128 9.16866 21.2773 8.91315C22.6249 8.17713 23.1684 6.96809 23.2964 5.51905C23.3154 5.29754 23.2924 5.06853 23.0584 4.95203ZM11.3165 17.9954C9.30097 16.4109 8.32344 15.8894 7.91992 15.9119C7.54241 15.9344 7.61042 16.3664 7.69342 16.6479C7.78042 16.9259 7.89342 17.1174 8.05193 17.3614C8.16143 17.5229 8.23694 17.7629 7.94243 17.9434C7.29341 18.3449 6.16487 17.8084 6.11187 17.7819C4.79833 17.0084 3.7003 15.9874 2.92628 14.5908C2.17875 13.2468 1.74474 11.8047 1.67324 10.2657C1.65424 9.89418 1.76374 9.76267 2.13375 9.69517C2.62077 9.60517 3.12278 9.58617 3.6093 9.65767C5.66636 9.95818 7.41741 10.8777 8.88545 12.3348C9.72348 13.1643 10.3575 14.1558 11.0105 15.1243C11.705 16.1529 12.4521 17.1329 13.4036 17.9364C13.7396 18.2179 14.0076 18.4319 14.2641 18.5899C13.4906 18.6764 12.1996 18.6949 11.3165 17.9964V17.9954ZM12.2826 11.7817C12.2826 11.6167 12.4146 11.4852 12.5806 11.4852C12.6181 11.4852 12.6521 11.4927 12.6826 11.5037C12.7241 11.5187 12.7621 11.5412 12.7921 11.5752C12.8451 11.6277 12.8751 11.7027 12.8751 11.7817C12.8751 11.9467 12.7431 12.0782 12.5771 12.0782C12.4111 12.0782 12.2826 11.9467 12.2826 11.7817ZM15.2831 13.3208C15.0906 13.3998 14.8981 13.4673 14.7131 13.4748C14.4261 13.4898 14.1131 13.3733 13.9431 13.2308C13.6791 13.0093 13.4901 12.8853 13.4111 12.4988C13.3771 12.3338 13.3961 12.0782 13.4261 11.9317C13.4941 11.6162 13.4186 11.4137 13.1961 11.2297C13.0151 11.0797 12.7846 11.0382 12.5316 11.0382C12.4371 11.0382 12.3506 10.9967 12.2861 10.9632C12.1806 10.9107 12.0936 10.7792 12.1766 10.6177C12.2031 10.5652 12.3316 10.4377 12.3616 10.4152C12.7051 10.2197 13.1011 10.2837 13.4676 10.4302C13.8071 10.5692 14.0641 10.8242 14.4336 11.1847C14.8111 11.6202 14.8791 11.7402 15.0941 12.0672C15.2641 12.3228 15.4186 12.5853 15.5247 12.8858C15.5887 13.0733 15.5057 13.2268 15.2831 13.3208Z" fill="currentColor"/>
|
||||
</g>
|
||||
<rect x="129.348" y="5.5" width="52" height="14" rx="2" fill="currentColor"/>
|
||||
<g clipPath="url(#dsh-wordmark-badge-clip)">
|
||||
<path d="M132.848 8.93205H134.08V16.137H132.848V8.93205ZM136.5 8.93205H137.732V16.137H136.5V8.93205ZM133.365 13.024V11.99H137.193V13.024H133.365Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M140.397 14.432L140.672 13.453H143.202L143.532 14.432H140.397ZM140.287 16.137H139.055L141.277 8.93205H142.201L142.146 9.74605L140.947 13.915H140.969L140.287 16.137ZM145.039 16.137H143.741L143.07 13.948L143.081 13.937L141.871 9.74605L141.926 8.93205H142.817L145.039 16.137Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M146.846 8.93205H149.068C149.852 8.93205 150.443 9.11538 150.839 9.48205C151.235 9.84138 151.433 10.3327 151.433 10.956C151.433 11.22 151.396 11.4657 151.323 11.693C151.249 11.9204 151.125 12.1257 150.949 12.309C150.773 12.4924 150.531 12.65 150.223 12.782C149.922 12.9067 149.541 13.0057 149.079 13.079V13.321H146.846V12.639L148.023 12.485C148.631 12.4044 149.09 12.298 149.398 12.166C149.706 12.034 149.915 11.8764 150.025 11.693C150.135 11.5024 150.19 11.2934 150.19 11.066C150.19 10.6994 150.083 10.417 149.871 10.219C149.658 10.021 149.324 9.92205 148.87 9.92205H146.846V8.93205ZM146.395 8.93205H147.627V16.137H146.395V8.93205ZM151.917 16.093V16.137H150.366L149.024 14.322C148.87 14.1094 148.73 13.9407 148.606 13.816C148.481 13.684 148.345 13.5887 148.199 13.53C148.052 13.464 147.872 13.42 147.66 13.398C147.447 13.3687 147.176 13.3504 146.846 13.343V13.145H149.079C149.233 13.211 149.368 13.2844 149.486 13.365C149.61 13.4457 149.735 13.5447 149.86 13.662C149.992 13.7794 150.138 13.937 150.3 14.135L151.917 16.093Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M153.58 9.57005L153.591 8.93205H154.46L157.584 15.51V16.137H156.704L153.58 9.57005ZM158.024 16.137H156.968L156.88 8.93205H158.024V16.137ZM154.24 16.137H153.096V8.93205H154.152L154.24 16.137Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M159.963 8.93205H161.206V16.137H159.963V8.93205ZM160.095 9.96605V8.93205H164.858V9.96605H160.095ZM160.095 16.137V15.103H164.902V16.137H160.095ZM160.095 13.013V11.99H164.374V13.013H160.095Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M169.052 15.257C169.543 15.257 169.895 15.1654 170.108 14.982C170.328 14.7987 170.438 14.5457 170.438 14.223C170.438 14.047 170.405 13.8967 170.339 13.772C170.273 13.6474 170.152 13.5337 169.976 13.431C169.807 13.321 169.558 13.2147 169.228 13.112L168.491 12.881C167.846 12.6757 167.38 12.4044 167.094 12.067C166.808 11.7297 166.665 11.3007 166.665 10.78C166.665 10.428 166.76 10.1017 166.951 9.80105C167.142 9.50038 167.428 9.25838 167.809 9.07505C168.19 8.89172 168.663 8.80005 169.228 8.80005C169.631 8.80005 169.998 8.82938 170.328 8.88805C170.665 8.93938 171.039 9.01638 171.45 9.11905L171.274 10.175C170.834 10.0504 170.442 9.96238 170.097 9.91105C169.76 9.85238 169.463 9.82305 169.206 9.82305C168.737 9.82305 168.403 9.90738 168.205 10.076C168.007 10.2374 167.908 10.439 167.908 10.681C167.908 10.857 167.941 11.0147 168.007 11.154C168.073 11.286 168.19 11.407 168.359 11.517C168.535 11.627 168.784 11.7334 169.107 11.836L169.866 12.078C170.526 12.276 170.995 12.5327 171.274 12.848C171.553 13.156 171.692 13.585 171.692 14.135C171.692 14.5604 171.589 14.9344 171.384 15.257C171.179 15.5797 170.878 15.8327 170.482 16.016C170.093 16.1994 169.609 16.291 169.03 16.291C168.627 16.291 168.212 16.247 167.787 16.159C167.362 16.071 166.9 15.9427 166.401 15.774L166.665 14.718C167.156 14.894 167.6 15.0297 167.996 15.125C168.399 15.213 168.751 15.257 169.052 15.257Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M175.809 15.257C176.3 15.257 176.652 15.1654 176.865 14.982C177.085 14.7987 177.195 14.5457 177.195 14.223C177.195 14.047 177.162 13.8967 177.096 13.772C177.03 13.6474 176.909 13.5337 176.733 13.431C176.564 13.321 176.315 13.2147 175.985 13.112L175.248 12.881C174.603 12.6757 174.137 12.4044 173.851 12.067C173.565 11.7297 173.422 11.3007 173.422 10.78C173.422 10.428 173.517 10.1017 173.708 9.80105C173.899 9.50038 174.185 9.25838 174.566 9.07505C174.947 8.89172 175.42 8.80005 175.985 8.80005C176.388 8.80005 176.755 8.82938 177.085 8.88805C177.422 8.93938 177.796 9.01638 178.207 9.11905L178.031 10.175C177.591 10.0504 177.199 9.96238 176.854 9.91105C176.517 9.85238 176.22 9.82305 175.963 9.82305C175.494 9.82305 175.16 9.90738 174.962 10.076C174.764 10.2374 174.665 10.439 174.665 10.681C174.665 10.857 174.698 11.0147 174.764 11.154C174.83 11.286 174.947 11.407 175.116 11.517C175.292 11.627 175.541 11.7334 175.864 11.836L176.623 12.078C177.283 12.276 177.752 12.5327 178.031 12.848C178.31 13.156 178.449 13.585 178.449 14.135C178.449 14.5604 178.346 14.9344 178.141 15.257C177.936 15.5797 177.635 15.8327 177.239 16.016C176.85 16.1994 176.366 16.291 175.787 16.291C175.384 16.291 174.969 16.247 174.544 16.159C174.119 16.071 173.657 15.9427 173.158 15.774L173.422 14.718C173.913 14.894 174.357 15.0297 174.753 15.125C175.156 15.213 175.508 15.257 175.809 15.257Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="dsh-wordmark-whale-clip">
|
||||
<rect width="23.16" height="17.0435" fill="white" transform="translate(0.141602 3.52185)"/>
|
||||
</clipPath>
|
||||
<clipPath id="dsh-wordmark-badge-clip">
|
||||
<rect width="46" height="14" fill="white" transform="translate(132.348 5.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
38
packages/client/ui-primitives/src/Tooltip.module.css
Normal file
38
packages/client/ui-primitives/src/Tooltip.module.css
Normal file
@@ -0,0 +1,38 @@
|
||||
/* Visual spec mirrors deepsuite @deepseek/ui Tooltip.css (size m, no arrow),
|
||||
except padding tightened 6/12 -> 4/8 and radius 10 -> 8 by product ruling:
|
||||
tooltip-bg plate,
|
||||
one text color across both themes (the plate stays dark in light and dark
|
||||
mode). Behavior (fixed positioning off the anchor rect) is local — the
|
||||
upstream Floating stack is intentionally not vendored. */
|
||||
|
||||
.bubble {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-tooltip-bg);
|
||||
color: var(--dsw-static-neutral-bluish-00);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
animation: tooltip-in 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.bubble[data-side='right'] {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.bubble[data-side='bottom'] {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
@keyframes tooltip-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bubble {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
77
packages/client/ui-primitives/src/Tooltip.tsx
Normal file
77
packages/client/ui-primitives/src/Tooltip.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
// Hover/focus label bubble (figma tooltip pill: dark plate, white text).
|
||||
// TODO: interaction is a placeholder (no show delay, no flip on viewport
|
||||
// collision, no arrow) — visuals and behavior get a proper pass later.
|
||||
// The anchor is the child element itself (cloneElement, no wrapper node), so
|
||||
// attaching a tooltip never changes the anchor's layout context. The bubble is
|
||||
// position:fixed and coordinates come from the anchor's rect at show time, so
|
||||
// it escapes ancestor overflow clipping (the sidebar rail clips its column)
|
||||
// without a portal.
|
||||
|
||||
import { cloneElement, useEffect, useRef, useState } from 'react'
|
||||
import type { FocusEventHandler, MouseEventHandler, ReactElement, Ref } from 'react'
|
||||
import css from './Tooltip.module.css'
|
||||
|
||||
/** Bubble placement relative to the anchor. */
|
||||
export type TooltipSide = 'right' | 'bottom'
|
||||
|
||||
/** Props Tooltip injects into its anchor child; the child's own handlers are chained ahead of the tooltip's. */
|
||||
interface AnchorProps {
|
||||
ref?: Ref<HTMLElement> | undefined
|
||||
onMouseEnter?: MouseEventHandler | undefined
|
||||
onMouseLeave?: MouseEventHandler | undefined
|
||||
onFocus?: FocusEventHandler | undefined
|
||||
onBlur?: FocusEventHandler | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a hover/focus tooltip to an anchor element.
|
||||
* @param props.label - bubble text.
|
||||
* @param props.side - placement relative to the anchor (default 'right').
|
||||
* @param props.disabled - suppress the bubble while true; the anchor renders identically so toggling never remounts it (which would cut its CSS transitions).
|
||||
* @param props.children - a single anchor element. Tooltip owns its ref (no current consumer passes one).
|
||||
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
|
||||
*/
|
||||
export function Tooltip({ label, side = 'right', disabled = false, children }: { label: string; side?: TooltipSide; disabled?: boolean; children: ReactElement<AnchorProps> }) {
|
||||
const anchor = useRef<HTMLElement | null>(null)
|
||||
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
|
||||
// Hover and focus are independent triggers: the bubble hides only after
|
||||
// BOTH clear (hovering away from a focused anchor must not drop it).
|
||||
const triggers = useRef({ hover: false, focus: false })
|
||||
|
||||
// Disabling mid-hover (e.g. clicking a rail control expands the sidebar)
|
||||
// must drop an already-visible bubble: no mouseleave fires.
|
||||
useEffect(() => {
|
||||
if (disabled) { triggers.current = { hover: false, focus: false }; setPos(null) }
|
||||
}, [disabled])
|
||||
|
||||
const show = () => {
|
||||
if (disabled) return
|
||||
const el = anchor.current
|
||||
/* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */
|
||||
if (el === null) return
|
||||
const r = el.getBoundingClientRect()
|
||||
setPos(side === 'right'
|
||||
? { x: r.right + 10, y: r.top + r.height / 2 }
|
||||
: { x: r.left + r.width / 2, y: r.bottom + 8 })
|
||||
}
|
||||
const hide = () => {
|
||||
if (!triggers.current.hover && !triggers.current.focus) setPos(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{cloneElement(children, {
|
||||
ref: anchor,
|
||||
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() },
|
||||
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() },
|
||||
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() },
|
||||
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
|
||||
})}
|
||||
{pos !== null && (
|
||||
<span className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -165,6 +165,16 @@ export const IconChevronRightOutline14 = ({ size = 14, className }: IconProps) =
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_triangle_right_fill_14 — tree expand arrow; points right, consumers rotate it 90° for the open state. */
|
||||
export const IconTriangleRightFill14 = ({ size = 14, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M4.25 2.82782L4.25 11.1722C4.25 11.6622 4.84243 11.9076 5.18891 11.5611L9.36109 7.38891C9.57588 7.17412 9.57588 6.82588 9.36109 6.61109L5.18891 2.43891C4.84243 2.09243 4.25 2.33782 4.25 2.82782Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_chevron_up_outline_14 */
|
||||
export const IconChevronUpOutline14 = ({ size = 14, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -552,11 +562,11 @@ export const IconProjectAddOutline16 = ({ size = 16, className }: IconProps) =>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** folder_open_16 (figma extract) */
|
||||
/** folder_open_16 (figma extract): outline at full ink + 20%-opacity inner fill riding the same currentColor. */
|
||||
export const IconFolderOpen16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
||||
<path transform="translate(0.5996 1.645)" d="M4.69624 0C5.3113 0.000140941 5.88623 0.307626 6.22749 0.819336L6.69917 1.52734C6.78449 1.65523 6.92823 1.7324 7.08198 1.73242L11.6699 1.73242C13.0038 1.73257 14.0859 2.81452 14.0859 4.14844L14.0859 5.05566C14.7693 5.4559 15.1595 6.2791 14.9374 7.11621L13.8837 11.0869C13.6026 12.1454 12.644 12.8818 11.5488 12.8818L2.41596 12.8818C1.01395 12.8816 -0.0511855 11.7074 0.00190073 10.376L0.00190073 2.41602C0.00190073 1.08201 1.08391 0 2.41792 0L4.69624 0ZM3.27827 6.18457C2.80902 6.18474 2.39772 6.50054 2.27729 6.9541L1.41499 10.2012C1.2407 10.8579 1.73653 11.5017 2.41596 11.502L11.5488 11.502C12.0182 11.502 12.4293 11.1861 12.5498 10.7324L13.6035 6.7627C13.681 6.47081 13.4611 6.18474 13.1591 6.18457L3.27827 6.18457ZM2.41792 1.38086C1.8462 1.38086 1.38276 1.8443 1.38276 2.41602L1.38276 5.72266C1.83056 5.15603 2.52166 4.80383 3.27827 4.80371L12.705 4.80371L12.705 4.14844C12.705 3.57681 12.2415 3.11342 11.6699 3.11328L7.08198 3.11328C6.46674 3.11326 5.89205 2.80484 5.55073 2.29297L5.07905 1.58496C4.99378 1.45723 4.84981 1.381 4.69624 1.38086L2.41792 1.38086Z" fill="currentColor"/>
|
||||
<path transform="translate(1.979 3.026)" d="M11.7793 4.80371C12.0811 4.80388 12.3008 5.09009 12.2236 5.38184L11.1699 9.35156C11.0494 9.80525 10.6383 10.1211 10.1689 10.1211L1.03612 10.1211C0.356864 10.1206 -0.139141 9.47695 0.0351403 8.82031L0.897445 5.57324C1.01797 5.12 1.42946 4.80406 1.89842 4.80371L11.7793 4.80371ZM3.31639 0C3.46985 0.000107244 3.61388 0.0765707 3.6992 0.204102L4.17088 0.912109C4.51213 1.42391 5.08701 1.73228 5.70213 1.73242L10.29 1.73242C10.8616 1.73251 11.325 2.19605 11.3252 2.76758L11.3252 3.42285L1.89842 3.42285C1.14203 3.42309 0.450638 3.77535 0.00291371 4.3418L0.00291371 1.03516C0.00307753 0.463694 0.466614 0.000188756 1.03807 0L3.31639 0Z" fill="currentColor"/>
|
||||
<path d="M5.19629 1.57104C5.81144 1.5711 6.38623 1.8786 6.72754 2.39038L7.19922 3.09839C7.28454 3.22635 7.42824 3.30344 7.58203 3.30347H12.1699C13.5039 3.30348 14.5859 4.38548 14.5859 5.71948V6.62671C15.2694 7.02689 15.6605 7.85012 15.4385 8.68726L14.3848 12.658C14.1037 13.7164 13.1449 14.4527 12.0498 14.4529H2.91699C1.51651 14.4529 0.451662 13.2814 0.501954 11.9519V3.98706C0.501954 2.65305 1.58396 1.57104 2.91797 1.57104H5.19629ZM3.7793 7.75562C3.30994 7.75562 2.89883 8.07153 2.77832 8.52515L1.91602 11.7722C1.74167 12.4291 2.23734 13.073 2.91699 13.073H12.0498C12.5191 13.0728 12.9304 12.757 13.0508 12.3035L14.1045 8.33374C14.1819 8.04202 13.9619 7.756 13.6602 7.75562H3.7793ZM2.91797 2.9519C2.34625 2.9519 1.88281 3.41534 1.88281 3.98706V7.2937C2.33068 6.7269 3.02249 6.37476 3.7793 6.37476H13.2051V5.71948C13.2051 5.14777 12.7416 4.68434 12.1699 4.68433H7.58203C6.96675 4.6843 6.39209 4.37595 6.05078 3.86401L5.5791 3.15601C5.49379 3.02821 5.34995 2.95196 5.19629 2.9519H2.91797Z" fill="currentColor"/>
|
||||
<path opacity="0.2" d="M13.6602 7.75525C13.9618 7.7556 14.1815 8.04179 14.1045 8.33337L13.0508 12.3031C12.9304 12.7567 12.5191 13.0725 12.0498 13.0726H2.91701C2.23744 13.0725 1.7417 12.4287 1.91603 11.7719L2.77834 8.52478C2.89898 8.07146 3.31018 7.75532 3.77931 7.75525H13.6602ZM5.1963 2.95154C5.34985 2.95159 5.49377 3.02803 5.57912 3.15564L6.0508 3.86365C6.39205 4.37553 6.96685 4.68385 7.58205 4.68396H12.1699C12.7416 4.68396 13.2049 5.14754 13.2051 5.71912V6.37439H3.77931C3.02267 6.37444 2.33067 6.72671 1.88283 7.29333V3.98669C1.88299 3.4152 2.34649 2.95168 2.91798 2.95154H5.1963Z" fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ export { Menu } from './Menu.tsx'
|
||||
export type { MenuItem } from './Menu.tsx'
|
||||
export { ConnectionBanner } from './ConnectionBanner.tsx'
|
||||
export { FishLogo } from './FishLogo.tsx'
|
||||
export { BrandWordmark } from './BrandWordmark.tsx'
|
||||
export { Tooltip } from './Tooltip.tsx'
|
||||
export type { TooltipSide } from './Tooltip.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
export { MessageText } from './markdown/MessageText.tsx'
|
||||
export * from './icons/index.tsx'
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
.markdown {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--dsw-font-markdown-base);
|
||||
}
|
||||
|
||||
.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre, hr) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.markdown h1 {
|
||||
font: var(--dsw-font-markdown-h1);
|
||||
}
|
||||
|
||||
.markdown h2 {
|
||||
font: var(--dsw-font-markdown-h2);
|
||||
}
|
||||
|
||||
.markdown h3 {
|
||||
font: var(--dsw-font-markdown-h3);
|
||||
}
|
||||
|
||||
.markdown :where(h4, h5, h6) {
|
||||
font: var(--dsw-font-markdown-h4);
|
||||
}
|
||||
|
||||
.markdown :where(strong, th) {
|
||||
font-weight: var(--dsw-font-markdown-base-strong-font-weight);
|
||||
}
|
||||
|
||||
.markdown :where(ul, ol) {
|
||||
padding-inline-start: 24px;
|
||||
}
|
||||
|
||||
.markdown li + li {
|
||||
margin-block-start: 4px;
|
||||
}
|
||||
|
||||
.markdown li > :where(ul, ol) {
|
||||
margin-block-start: 4px;
|
||||
}
|
||||
|
||||
.markdown blockquote {
|
||||
padding-inline-start: 12px;
|
||||
border-inline-start: 3px solid var(--dsw-alias-markdown-citation);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.markdown a {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.markdown :not(pre) > code {
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--dsw-alias-markdown-inline-code);
|
||||
font: var(--dsw-font-markdown-code);
|
||||
}
|
||||
|
||||
.markdown pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
}
|
||||
|
||||
.markdown pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.markdown hr {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-block-start: 1px solid var(--dsw-alias-markdown-citation);
|
||||
}
|
||||
|
||||
.markdown input[type='checkbox'] {
|
||||
margin: 0 8px 0 0;
|
||||
accent-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.tableScroll {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
|
||||
.tableScroll table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
font: var(--dsw-font-markdown-table);
|
||||
}
|
||||
|
||||
.tableScroll :where(th, td) {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--dsw-alias-markdown-citation);
|
||||
text-align: start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tableScroll th {
|
||||
background: var(--dsw-alias-markdown-code-block-banner);
|
||||
font: var(--dsw-font-markdown-table-head);
|
||||
}
|
||||
|
||||
.imageAlt {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
64
packages/client/ui-primitives/src/markdown/MarkdownText.tsx
Normal file
64
packages/client/ui-primitives/src/markdown/MarkdownText.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import type { Components, UrlTransform } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import css from './MarkdownText.module.css'
|
||||
|
||||
const remarkPlugins = [remarkGfm]
|
||||
|
||||
function sanitizeUrl(url: string): string {
|
||||
try {
|
||||
switch (new URL(url).protocol) {
|
||||
case 'http:':
|
||||
case 'https:':
|
||||
case 'mailto:':
|
||||
return url
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const safeUrl: UrlTransform = url => sanitizeUrl(url)
|
||||
|
||||
const components: Components = {
|
||||
a: ({ href = '', children }) => {
|
||||
const safeHref = sanitizeUrl(href)
|
||||
if (safeHref === '') return <>{children}</>
|
||||
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
|
||||
return (
|
||||
<a
|
||||
href={safeHref}
|
||||
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
|
||||
table: ({ children }) => (
|
||||
<div className={css.tableScroll}>
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Render untrusted assistant-authored Markdown as semantic React elements.
|
||||
* @param props - Markdown source text preserved by the session projection.
|
||||
* @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
|
||||
*/
|
||||
export function MarkdownText({ text }: { text: string }) {
|
||||
return (
|
||||
<div className={css.markdown}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
components={components}
|
||||
urlTransform={safeUrl}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// MessageText: the single text-block rendering point (Markdown support later = swap this component's internals, zero card-structure changes).
|
||||
// MessageText is the literal-text primitive for user and steering content; assistant output uses MarkdownText.
|
||||
|
||||
import css from './MessageText.module.css'
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ const icons = Object.fromEntries(
|
||||
const iconNames = Object.keys(icons)
|
||||
|
||||
describe('ic_ds_ icon set', () => {
|
||||
it('exports the full P-I set (43 deepsuite + 6 figma extracts)', () => {
|
||||
expect(iconNames.length).toBe(49)
|
||||
it('exports the full P-I set (43 deepsuite + 7 figma extracts)', () => {
|
||||
expect(iconNames.length).toBe(50)
|
||||
})
|
||||
|
||||
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => {
|
||||
|
||||
@@ -1,14 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('MessageText', () => {
|
||||
it('renders the text verbatim', () => {
|
||||
const { container } = render(<MessageText text={'line1\nline2'} />)
|
||||
expect(container.textContent).toBe('line1\nline2')
|
||||
const { container } = render(<MessageText text={'# line1\n`line2`'} />)
|
||||
expect(container.textContent).toBe('# line1\n`line2`')
|
||||
expect(container.querySelector('h1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownText', () => {
|
||||
it('renders CommonMark and GFM elements as semantic DOM', () => {
|
||||
const markdown = [
|
||||
'# Heading',
|
||||
'',
|
||||
'Paragraph with **strong**, *emphasis*, ~~deleted~~, `inline`, and [safe](https://example.com). ',
|
||||
'Hard break.',
|
||||
'',
|
||||
'> Quote',
|
||||
'',
|
||||
'- parent',
|
||||
' - child',
|
||||
'',
|
||||
'1. first',
|
||||
'2. second',
|
||||
'',
|
||||
'- [x] done',
|
||||
'- [ ] pending',
|
||||
'',
|
||||
'| Name | Value |',
|
||||
'| --- | --- |',
|
||||
'| alpha | beta |',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'```ts',
|
||||
'const answer = 42',
|
||||
'```',
|
||||
'',
|
||||
'<https://deepseek.com>',
|
||||
].join('\n')
|
||||
const { container } = render(<MarkdownText text={markdown} />)
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Heading' })).toBeTruthy()
|
||||
expect(container.querySelector('strong')?.textContent).toBe('strong')
|
||||
expect(container.querySelector('em')?.textContent).toBe('emphasis')
|
||||
expect(container.querySelector('del')?.textContent).toBe('deleted')
|
||||
expect(container.querySelector('blockquote')?.textContent?.trim()).toBe('Quote')
|
||||
expect(container.querySelectorAll('ul')).toHaveLength(3)
|
||||
expect(container.querySelector('ol')).not.toBeNull()
|
||||
expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(2)
|
||||
expect(container.querySelector('table')?.textContent).toContain('alphabeta')
|
||||
expect(container.querySelector('hr')).not.toBeNull()
|
||||
expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
|
||||
expect(container.querySelector('br')).not.toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
|
||||
expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
|
||||
const markdown = [
|
||||
'<script>globalThis.compromised = true</script>',
|
||||
'<img src="x" onerror="globalThis.compromised = true">',
|
||||
'[script](javascript:alert(1)) [relative](/settings)',
|
||||
'[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)',
|
||||
'',
|
||||
].join('\n\n')
|
||||
const { container } = render(<MarkdownText text={markdown} />)
|
||||
|
||||
expect(container.querySelector('script')).toBeNull()
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
const neutralized = [...container.querySelectorAll('p')]
|
||||
.find(paragraph => paragraph.textContent === 'script relative')
|
||||
expect(neutralized?.querySelector('a')).toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer')
|
||||
expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank')
|
||||
expect(screen.getByText('remote diagram')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps incomplete streaming Markdown renderable', () => {
|
||||
const { container } = render(<MarkdownText text={'## Streaming\n\n- first\n- **unfinished'} />)
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Streaming' })).toBeTruthy()
|
||||
expect(container.querySelectorAll('li')).toHaveLength(2)
|
||||
expect(screen.getByText('**unfinished')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user