Merge branch 'master' into feat/session-completed-dot

This commit is contained in:
GeeeekExplorer
2026-08-06 12:15:44 +08:00
committed by GitHub
267 changed files with 8169 additions and 2007 deletions

View File

@@ -88,7 +88,7 @@ Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the l
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/config/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case).
4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads.
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.
## New component checklist

View File

@@ -27,7 +27,7 @@ import type {
// Type-only: the brand constructor is host-side; the fixture casts at its
// wire-fabrication boundary (the schema layer's one-cast-point posture).
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import { foldSurface } from '@deepseek-ai/dsh-session/surface'
import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
@@ -167,7 +167,7 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin
{ lineNumber: 33, line: 'export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {' },
{ lineNumber: 35, line: ' const search = searchCardModel(block)' },
{ lineNumber: 52, line: ' search={search}' },
{ lineNumber: 73, line: " ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)" },
{ lineNumber: 78, line: " yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)" },
],
},
]
@@ -358,6 +358,12 @@ function buildAlphaLog(): SessionEvent[] {
events.push({ seq, time: (time += 800), ...authored })
return seq
}
// This resident history represents completed model requests, so retain the
// route capacity that accompanied them just as the live prompt path does.
push({
type: 'request/context',
data: { provider: 'deepseek-official', model: 'deepseek-v4-flash', contextWindow: 128_000 },
})
for (let turn = 0; turn < 60; turn++) {
push({ type: 'turn/start', data: { turn } })
const userSeq = push({
@@ -819,6 +825,62 @@ interface FixtureRequestContext {
contextWindow?: number
}
interface FixtureContextBreakdownProjection {
systemTokens: number
toolsTokens: number
messageTokens: number
}
/** Fixed token-meter heuristic constants mirrored by this client-only fixture. */
const CHARS_PER_TOKEN = 4
const BLOCK_OVERHEAD = 4
const ROLE_OVERHEAD = 4
/** Price fixture content with token-meter's fixed-density heuristic. */
function estimateFixtureContent(blocks: readonly ContentBlock[]): number {
const densityPrice = (value: string): number => Math.ceil(value.length / CHARS_PER_TOKEN)
return blocks.reduce((tokens, block) => {
if (block.type === 'text' || block.type === 'reasoning') {
return tokens + densityPrice(block.text) + BLOCK_OVERHEAD
}
if (block.type === 'tool-call') {
return tokens + densityPrice(block.name) + densityPrice(block.arguments) + BLOCK_OVERHEAD
}
// ContentBlockMap is merge-extensible: this client graph sees only the
// base four members, but fixture turns do carry extended blocks at
// runtime, so the structural JSON fallback below is live code.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the type collapses without the out-of-graph merges (see above).
if (block.type === 'tool-result') {
return tokens + estimateFixtureContent(block.content) + BLOCK_OVERHEAD
}
return tokens + densityPrice(JSON.stringify(block)) + BLOCK_OVERHEAD
}, 0)
}
/** Fixture parallel of token-meter's heuristic context-composition projection. */
function contextBreakdownOf(log: readonly SessionEvent[]): FixtureContextBreakdownProjection {
const headerEvent = log.findLast(event => event.type === 'request/header')
const header = headerEvent === undefined
? undefined
: headerEvent.data.header
let messageTokens = 0
for (const seq of foldSurface(log).nodes) {
const event = log[seq]
if (event === undefined) continue
const message = deriveEventMessage(event)
if (message !== null) messageTokens += estimateFixtureContent(message.content) + ROLE_OVERHEAD
}
return {
systemTokens: header?.system === undefined
? 0
: Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD,
toolsTokens: header?.tools === undefined || header.tools.length === 0
? 0
: Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD,
messageTokens,
}
}
/** Latest log-only route context, or undefined before any request ran. */
function lastRequestContext(
log: readonly SessionEvent[],
@@ -832,7 +894,11 @@ function lastRequestContext(
/**
* Fixture parallel of token-meter's request-pressure projection: the last
* provider-reported prompt size paired with the last recorded capacity. The
* two need not come from one request — see the token-meter README.
* two need not come from one request — see the token-meter README. The host's
* `projectedTokens` is deliberately absent: reproducing it would mean
* reimplementing the estimator client-side, and every consumer falls back to
* the bare sample, so a fixture-driven view simply lags a compaction the way
* the projection did before that field existed.
*/
function contextPressureOf(
log: readonly SessionEvent[],
@@ -870,28 +936,44 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
values['tokenUsage'] = tokenUsageOf(log)
// Always present (token-meter composed): last request pressure and capacity.
values['contextPressure'] = contextPressureOf(log)
// Always present (token-meter composed): heuristic request composition.
values['contextBreakdown'] = contextBreakdownOf(log)
return values
}
/** Host push-frame parallel: emit one session/projection frame per key the given event advanced. */
function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: SessionEvent): Extract<MuxFrame, { type: 'session/projection' }>[] {
const type = (event as { type: string }).type
const frames: Extract<MuxFrame, { type: 'session/projection' }>[] = []
// One usage sample advances both token-meter units.
if (usageSampleOf(event) !== undefined) {
return [
frames.push(
{ type: 'session/projection', sessionId: id, key: 'tokenUsage', value: tokenUsageOf(log), seq: event.seq },
{ type: 'session/projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), seq: event.seq },
]
)
}
if (type === 'request/context') {
return [{
frames.push({
type: 'session/projection',
sessionId: id,
key: 'contextPressure',
value: contextPressureOf(log),
seq: event.seq,
}]
})
}
if (type === 'request/header'
|| type === 'user/message'
|| type === 'assistant/message'
|| type === 'tool/result') {
frames.push({
type: 'session/projection',
sessionId: id,
key: 'contextBreakdown',
value: contextBreakdownOf(log),
seq: event.seq,
})
}
if (frames.length > 0) return frames
if (type === 'session/title') {
const values = projectionValuesOf(log)
/* v8 ignore next -- the advancing title event is in the log, so the key is present. */

View File

@@ -159,6 +159,11 @@ describe('createFixtureApi', () => {
},
// No request ran, so neither pressure nor capacity is known yet.
contextPressure: {},
contextBreakdown: {
systemTokens: 0,
toolsTokens: 0,
messageTokens: 0,
},
} },
})
})
@@ -304,6 +309,10 @@ describe('createFixtureApi', () => {
frame.type === 'session/projection'
&& frame.key === 'contextPressure'
&& (frame.value as { contextWindow?: number }).contextWindow === 128_000)).toBe(true)
expect(frames.some(frame =>
frame.type === 'session/projection'
&& frame.key === 'contextBreakdown'
&& (frame.value as { messageTokens?: number }).messageTokens! > 0)).toBe(true)
const finalize = frames.find((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event' && f.event.type === 'assistant/message')
expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)')
// Idle cancel: no replay in flight, must not explode; running flips false.
@@ -335,7 +344,7 @@ describe('createFixtureApi', () => {
const envelopes: RpcRequest<MuxFrame>[] = []
for await (const envelope of api.events.mux(req({}), abort.signal)) {
envelopes.push(envelope)
if (envelopes.length >= 10) abort.abort()
if (envelopes.length >= 11) abort.abort()
}
return envelopes
}
@@ -351,10 +360,15 @@ describe('createFixtureApi', () => {
expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
expect(first[6]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'tokenUsage' })
expect(first[7]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'contextPressure' })
expect(first[8]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[8]?.rpcId).toBe(first[8]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[9]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[9]?.rpcId).toBe(first[9]?.rpcId)
expect(first[8]?.payload).toMatchObject({
type: 'session/projection', sessionId: 'fx-alpha', key: 'contextBreakdown',
value: { systemTokens: 0, toolsTokens: 0 },
})
expect((first[8]?.payload as { value: { messageTokens: number } }).value.messageTokens).toBeGreaterThan(0)
expect(first[9]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[9]?.rpcId).toBe(first[9]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[10]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[10]?.rpcId).toBe(first[10]?.rpcId)
})
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {

View File

@@ -11,7 +11,6 @@
* string-typed. The rule fires on the narrow-map view, not real redundancy. */
import type { Context } from 'cordis'
import {
deferRegistration,
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
@@ -384,16 +383,12 @@ export function apply(ctx: ClientContext): void {
setLocale: (id) => { locale.setLocale(id) },
}
}
ctx.effect(() => {
const deferred = deferRegistration(ctx.slots, 'settings.general.item', LanguageRow, () =>
ctx.slots.register({
name: 'settings.general.item',
id: 'language',
order: 0,
store,
locale: SETTINGS_NS,
inject: injected,
}, LanguageRow))
return () => { deferred.dispose() }
}, 'locale: language settings row registration')
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',
id: 'language',
order: 0,
store,
locale: SETTINGS_NS,
inject: injected,
}, LanguageRow))
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: b390c2830a47ed380e01bc7ec763b4bd8d8459e8
README.zh.md: 0aaad0b7620394f151b6a757f924d22d4f2140ab
README.md: f95e06162bca132a9aa83e0b84e875a81d2f8fc6
README.zh.md: 4b1a9dda3bbe02ef9241a8a797a07e5285e6daae

View File

@@ -4,6 +4,12 @@ English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
## Slot declaration injection
`ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws.
The callback returns one synchronous disposer or an iterable of disposers. A generator can therefore yield several `slots.register()` calls as one transaction: setup failure rolls earlier yields back and teardown runs them in reverse order. Declaration lifetimes use a dedicated monotonic epoch, so a collapse and redeclaration batched into one renderer notification still restarts the callback, while ordinary entry changes do not. Declaration-bound teardown runs synchronously with the ledger mutation, releasing runtime resources before subsequent same-tick registrations. See the [declaration-injection decision](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md).
## Workspace and Session lists
Workspace and Session lists have independent monotone `pending``ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.

View File

@@ -4,6 +4,12 @@
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
## Slot 声明注入
`ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose资源释放回调 effect重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。
回调返回一个同步 disposer 或由多个 disposer 构成的 iterable。因此generator 可以 yield 多个 `slots.register()` 调用并将它们组成一项事务setup 失败会回滚先前 yield 的 effectteardown 则按逆序运行它们。声明生命周期使用专用的单调 declaration epoch声明代次因此即使折叠与重新声明合并在同一次 renderer 通知中,回调仍会重启,而普通条目变更不会重启它。声明绑定的 teardown 与账本变更同步运行,在同一 tick 内的后续注册之前释放运行时资源。详见 [slot 声明注入决策](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md)。
## Workspace 与 Session 列表
Workspace 和 Session 列表各自具有单调的 `pending``ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
@@ -46,7 +52,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 模型重试投影
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose(资源释放)时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限always mode 提示则保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败则只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose 时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限always mode 提示则保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败则只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。
## 会话 fork

View File

@@ -16,6 +16,8 @@ import type {
} from '../sessions/conversation-context.ts'
import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts'
import { PartialAccumulator } from '../sessions/partial.ts'
import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts'
import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts'
interface CallIndexEntry {
name: string
@@ -30,11 +32,6 @@ interface FoldedContext {
originSeq?: number
}
interface AssistantStepMetadata {
stepStartTime: number | null
firstTokenTime: number | null
}
/** Immutable conversation projections derived only from the history source. */
export interface ConversationHistoryProjection {
eventNodes: readonly ConversationNode[]
@@ -45,10 +42,6 @@ export interface ConversationHistoryProjection {
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
}
function assistantStepKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
@@ -64,21 +57,7 @@ function contextOriginKind(event: SessionEvent | undefined): ConversationContext
return 'rewrite'
}
function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
switch (chunk.type) {
case 'text-delta':
case 'reasoning-delta':
return chunk.text !== ''
case 'tool-call-delta':
return chunk.argumentsDelta !== '' || chunk.name !== undefined
default:
return false
}
}
function foldContexts(
events: readonly SessionEvent[],
): readonly FoldedContext[] {
function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
const replay: SessionEvent[] = []
const originalSeqs: number[] = []
const rebasedSeqByOriginal = new Map<number, number>()
@@ -381,6 +360,7 @@ export function projectConversationHistory(
contextGeneration++
if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt)
}
indexAssistantStepTiming(assistantSteps, event)
if (event.type === 'request/header') {
activeRequestConfig = event.data.header.config
activePrompt = {
@@ -389,30 +369,10 @@ export function projectConversationHistory(
tools: event.data.header.tools ?? [],
}
promptsByContext.set(contextGeneration, activePrompt)
} else if (event.type === 'step/start') {
assistantSteps.set(
assistantStepKey(event.data.turn, event.data.step),
{ stepStartTime: event.time, firstTokenTime: null },
)
} else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) {
const key = assistantStepKey(event.data.turn, event.data.step)
const current = assistantSteps.get(key) ?? {
stepStartTime: null,
firstTokenTime: null,
}
if (current.firstTokenTime === null) {
assistantSteps.set(key, { ...current, firstTokenTime: event.time })
}
} else if (event.type === 'assistant/message') {
assistantTimings.set(
event.seq,
{
...(assistantSteps.get(assistantStepKey(event.data.turn, event.data.step)) ?? {
stepStartTime: null,
firstTokenTime: null,
}),
completedTime: event.time,
},
settledAssistantTiming(assistantSteps, event.data.turn, event.data.step, event.time),
)
if (activeRequestConfig !== undefined) {
assistantRequestConfigs.set(event.seq, activeRequestConfig)

View File

@@ -0,0 +1,84 @@
// Shared assistant step-timing fold: both transcript projections (the live
// window adapter and the trajectory history fold) derive AssistantTiming from
// the same step/start -> first token delta -> assistant/message sequence, so
// the derivation lives once here instead of drifting per projection.
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { AssistantTiming } from './conversation.ts'
/** Pre-finalize timing boundaries for one assistant step (start + first token). */
export interface AssistantStepMetadata {
stepStartTime: number | null
firstTokenTime: number | null
}
/**
* Composite map key for one assistant step.
* @param turn - turn number from the event payload.
* @param step - step number from the event payload.
* @returns collision-free `turn`/`step` key (NUL separator).
*/
export function assistantStepKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
/**
* Whether a chunk carries visible model output (first-token boundary). Empty
* deltas (heartbeats, empty tool-call frames) do not count as a first token.
* @param chunk - the assistant/chunk payload.
* @returns true when the chunk contains a non-empty text/reasoning/tool delta.
*/
export function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
switch (chunk.type) {
case 'text-delta':
case 'reasoning-delta':
return chunk.text !== ''
case 'tool-call-delta':
return chunk.argumentsDelta !== '' || chunk.name !== undefined
default:
return false
}
}
/**
* Fold one event into the per-step timing index: step/start opens the entry,
* the first non-empty token delta stamps first-token time once. Other event
* types are no-ops.
* @param steps - the mutable per-step index, keyed by {@link assistantStepKey}.
* @param event - the raw window event.
*/
export function indexAssistantStepTiming(steps: Map<string, AssistantStepMetadata>, event: SessionEvent): void {
if (event.type === 'step/start') {
steps.set(
assistantStepKey(event.data.turn, event.data.step),
{ stepStartTime: event.time, firstTokenTime: null },
)
} else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) {
const key = assistantStepKey(event.data.turn, event.data.step)
const current = steps.get(key) ?? { stepStartTime: null, firstTokenTime: null }
if (current.firstTokenTime === null) {
steps.set(key, { ...current, firstTokenTime: event.time })
}
}
}
/**
* Settle one finalized assistant message's timing from its step entry; a step
* whose start or first token fell outside the window yields null boundaries.
* @param steps - the per-step index built by {@link indexAssistantStepTiming}.
* @param turn - the assistant/message turn number.
* @param step - the assistant/message step number.
* @param completedTime - the assistant/message event timestamp (epoch ms).
* @returns the node-ready timing record.
*/
export function settledAssistantTiming(
steps: ReadonlyMap<string, AssistantStepMetadata>,
turn: number,
step: number,
completedTime: number,
): AssistantTiming {
return {
...(steps.get(assistantStepKey(turn, step)) ?? { stepStartTime: null, firstTokenTime: null }),
completedTime,
}
}

View File

@@ -22,6 +22,8 @@ import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpo
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
import type { AssistantStepMetadata } from './assistant-timing.ts'
import { indexAssistantStepTiming, settledAssistantTiming } from './assistant-timing.ts'
/**
* The compaction seam's checkpoint plugin, pinned to the seam's own declaration
@@ -49,6 +51,7 @@ function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
): ConversationNode {
switch (event.type) {
case 'user/message':
@@ -70,6 +73,7 @@ function materializeNode(
kind: 'assistant', seq: event.seq, time: event.time,
turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
timing: settledAssistantTiming(stepTimings, event.data.turn, event.data.step, event.time),
}
case 'tool/result': {
const result = event.data.message.content[0]
@@ -170,6 +174,8 @@ export class TranscriptAdapter {
/** Transcript nodes in log order; copy-on-write so a published array never mutates. */
private projected: ConversationNode[] = []
private callIdx = new Map<string, CallIndexEntry>()
/** Per-step timing boundaries (step/start + first token delta), consumed when the step's assistant/message materializes. */
private stepTimings = new Map<string, AssistantStepMetadata>()
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
private resultViews = new Map<number, ToolResultView>()
/**
@@ -200,6 +206,7 @@ export class TranscriptAdapter {
this.callIdx = new Map()
this.resultViews.clear()
this.commandIdx = new Map()
this.stepTimings = new Map()
for (let i = 0; i < events.length; i++) {
const event = events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
@@ -207,6 +214,7 @@ export class TranscriptAdapter {
this.eventIndex.set(event.seq, event)
this.indexCall(event, views?.[i])
this.indexCommand(event)
indexAssistantStepTiming(this.stepTimings, event)
}
// Indexes first, then project: a tool/result materializes against the
// complete call index, and a checkpoint against the complete event index.
@@ -229,6 +237,7 @@ export class TranscriptAdapter {
append(event: SessionEvent, view?: ToolEventView): void {
this.eventIndex.set(event.seq, event)
this.indexCall(event, view)
indexAssistantStepTiming(this.stepTimings, event)
if (this.indexCommand(event)) this.rev++
if (!isTranscriptEvent(event)) return
this.projected = [...this.projected, this.materialize(event)]
@@ -267,7 +276,7 @@ export class TranscriptAdapter {
private materialize(event: SessionEvent): ConversationNode {
return isCompactCheckpoint(event)
? materializeCompaction(event, this.eventIndex)
: materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null)
: materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null, this.stepTimings)
}
/**

View File

@@ -2,12 +2,12 @@
* SlotsService: the cordis Service layer of the slot system over the pure
* SlotCore (ui-slots owns registration semantics, the declaration ledger,
* the load-time validations, and the unload cascade). This layer owns what
* needs the runtime: the 'slots/changed' event bridge, register through the
* caller's ctx.effect (fiber unload collects registrations), the renderer
* install seam (install()/renderSlot('root') + the SlotRendererHost face),
* and the store INSTANCE axis — handle x scope key -> create/cache, dropped
* with the last holding entry, session instances cleared (with persisted
* state) on scope death.
* needs the runtime: the 'slots/changed' event bridge, register and
* declaration injection through the caller's ctx.effect (fiber unload
* collects both), the renderer install seam (install()/renderSlot('root') +
* the SlotRendererHost face), and the store INSTANCE axis — handle x scope
* key -> create/cache, dropped with the last holding entry, session instances
* cleared (with persisted state) on scope death.
*/
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap only
@@ -78,6 +78,9 @@ interface ErasedRegisterOptions {
/** Erased core call face (the service re-erases at its own boundary; the core's typed face targets end callers). */
interface ErasedCore { register(options: object, component: unknown): () => void }
/** One synchronous effect installed while an injected slot declaration is live. */
type SlotInjectionEffect = (() => void) | Iterable<() => void, void, void>
/** cordis Service layer of the slot system; see the module doc for the split with SlotCore. */
export class SlotsService extends Service {
private readonly _core = new SlotCore()
@@ -114,6 +117,85 @@ export class SlotsService extends Service {
*/
declare readonly register: SlotCore['register']
/**
* Install an effect for each declaration lifetime of a slot. The callback
* runs synchronously when the declaration already exists; otherwise it runs
* inside the declaring `register()` call after the declaration is committed.
* Collapse disposes the effect and a later declaration runs it again.
* Callback effects are synchronous disposers; iterable effects install
* transactionally and dispose in reverse order. The controller belongs to
* the caller's fiber, so plugin unload cancels a pending wait and removes any
* active contribution.
*
* @param key - declared SlotMap key to depend on.
* @param callback - creates one disposer or an iterable of disposers.
* @returns idempotent disposer for the wait and active effect.
* @throws callback setup failures synchronously when the slot is already declared.
*/
inject(key: keyof SlotMap & string, callback: () => SlotInjectionEffect): () => void {
const ctx = this.ctx
const disposeController = ctx.effect(() => {
let active: (() => void) | undefined
let activeEpoch: number | undefined
let stopped = false
let unsubscribe = (): void => {}
const stop = (): void => {
if (stopped) return
// Failure callers retire the injection permanently: a delayed setup
// failure never retries on a later declaration.
stopped = true
unsubscribe()
const dispose = active
active = undefined
activeEpoch = undefined
dispose?.()
}
const reconcile = (): void => {
if (stopped) return
const spec = this._core.specDynamic(key)
const epoch = this._core.declarationEpoch(key)
if (active !== undefined && activeEpoch === epoch) return
const dispose = active
active = undefined
activeEpoch = undefined
dispose?.()
if (spec === undefined) return
// A declaration lifetime is a nested Cordis effect. This gives
// generator callbacks the same transactional setup, reverse teardown,
// diagnostics tree, and idempotence as every other plugin effect.
const disposeEffect = ctx.effect(callback, `slots.inject(${JSON.stringify(key)}): declaration`)
active = () => { void disposeEffect() }
activeEpoch = epoch
}
const changed = (): void => {
try {
reconcile()
} catch (error) {
if ((error as { code?: unknown } | null)?.code === 'INACTIVE_EFFECT') {
stop()
return
}
stop()
const failure = error instanceof Error ? error : new Error(String(error))
queueMicrotask(() => { throw failure })
}
}
unsubscribe = this._core.subscribeDeclaration(key, changed)
try {
reconcile()
} catch (error) {
stop()
throw error
}
return stop
}, `slots.inject(${JSON.stringify(key)})`)
return () => { void disposeController() }
}
/**
* Install the shell's renderer (web-react's createSlotRenderer product).
* Boot-once: a second install throws. Runs through the caller's ctx.effect,

View File

@@ -29,6 +29,7 @@ const C: FC<object> = () => null
*/
interface ErasedService {
register(options: object, component: unknown): () => void
inject(name: string, callback: () => (() => void) | Iterable<() => void>): () => void
install(renderer: object): void
renderSlot(key: string, owner: object): unknown
}
@@ -166,6 +167,266 @@ describe('load-time validation', () => {
})
})
describe('declaration injection', () => {
it('activates immediately and ignores ordinary entry mutations', async () => {
const bench = await boot()
bench.erased.register({
name: 'root', children: { 't.rows': { kind: 'list', scope: 'root' } },
}, C)
const setup = vi.fn(() => bench.erased.register({ name: 't.rows', id: 'injected' }, C))
const dispose = bench.erased.inject('t.rows', setup)
expect(setup).toHaveBeenCalledOnce()
bench.erased.register({ name: 't.rows', id: 'ordinary' }, C)
await Promise.resolve()
expect(setup).toHaveBeenCalledOnce()
dispose()
expect(bench.svc.entries('t.rows').map(entry => entry.options.id)).toEqual(['ordinary'])
})
it('waits for declaration, cleans up on collapse, and reruns after redeclaration', async () => {
const bench = await boot()
const cleanup = vi.fn()
const setup = vi.fn(() => {
const unregister = bench.erased.register({ name: 't.host' }, C)
return () => { unregister(); cleanup() }
})
bench.erased.inject('t.host', setup)
expect(setup).not.toHaveBeenCalled()
const disposeFrame = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
await Promise.resolve()
expect(setup).toHaveBeenCalledOnce()
expect(bench.svc.entries('t.host')).toHaveLength(1)
disposeFrame()
await Promise.resolve()
expect(cleanup).toHaveBeenCalledOnce()
expect(bench.svc.entries('t.host')).toHaveLength(0)
bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
await Promise.resolve()
expect(setup).toHaveBeenCalledTimes(2)
expect(bench.svc.entries('t.host')).toHaveLength(1)
})
it('observes a same-tick collapse and redeclaration through the declaration epoch', async () => {
const bench = await boot()
const firstFrame = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
const cleanup = vi.fn()
const setup = vi.fn(() => {
const unregister = bench.erased.register({ name: 't.host' }, C)
return () => { unregister(); cleanup() }
})
bench.erased.inject('t.host', setup)
firstFrame()
bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
await Promise.resolve()
expect(cleanup).toHaveBeenCalledOnce()
expect(setup).toHaveBeenCalledTimes(2)
expect(bench.svc.entries('t.host')).toHaveLength(1)
})
it('plugin disposal removes an active injection and prevents a waiting one from resurrecting', async () => {
const active = await boot()
active.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
const activeFiber = active.ctx.plugin({
name: 'active-injection',
inject: ['slots'],
apply: (ctx: Context) => { ctx.slots.inject('t.host', () => ctx.slots.register({ name: 't.host' }, C)) },
})
await activeFiber.await()
expect(active.svc.entries('t.host')).toHaveLength(1)
await activeFiber.dispose()
expect(active.svc.entries('t.host')).toHaveLength(0)
const waiting = await boot()
const setup = vi.fn(() => waiting.erased.register({ name: 't.host' }, C))
const waitingFiber = waiting.ctx.plugin({
name: 'waiting-injection',
inject: ['slots'],
apply: (ctx: Context) => { ctx.slots.inject('t.host', setup) },
})
await waitingFiber.await()
await waitingFiber.dispose()
waiting.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
await Promise.resolve()
expect(setup).not.toHaveBeenCalled()
})
it('rolls back earlier yielded registrations when generator setup fails', async () => {
const bench = await boot()
bench.erased.register({
name: 'root',
children: {
't.host': { kind: 'single', scope: 'root' },
't.rows': { kind: 'list', scope: 'root' },
},
}, C)
bench.erased.register({ name: 't.host' }, C)
expect(() => bench.erased.inject('t.rows', function* () {
yield bench.erased.register({ name: 't.rows', id: 'rolled-back' }, C)
yield bench.erased.register({ name: 't.host' }, C)
})).toThrow(/already has a registration/)
expect(bench.svc.entries('t.rows')).toHaveLength(0)
})
it('contains and wraps a delayed setup failure so later slot listeners still run', async () => {
const bench = await boot()
const failures: unknown[] = []
const onLoud = (error: unknown): void => { failures.push(error) }
process.on('uncaughtException', onLoud)
try {
const setup = vi.fn(function* () {
yield bench.erased.register({ name: 't.host' }, C)
throw null
})
bench.erased.inject('t.host', setup)
const later = vi.fn(() => () => undefined)
bench.erased.inject('t.host', later)
const disposeFrame = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
await new Promise(resolve => setTimeout(resolve, 20))
expect(failures).toHaveLength(1)
expect(failures[0]).toBeInstanceOf(Error)
expect(String(failures[0])).toContain('null')
expect(later).toHaveBeenCalledOnce()
disposeFrame()
bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
expect(setup).toHaveBeenCalledOnce()
} finally {
process.off('uncaughtException', onLoud)
}
})
it('skips a stopped controller retained by the current declaration snapshot', async () => {
const bench = await boot()
let stopLater = (): void => {}
const first = vi.fn(() => {
stopLater()
return () => undefined
})
const later = vi.fn(() => () => undefined)
bench.erased.inject('t.host', first)
stopLater = bench.erased.inject('t.host', later)
bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
expect(first).toHaveBeenCalledOnce()
expect(later).not.toHaveBeenCalled()
})
it('keeps a nested redeclaration activation when the outer collapse resumes', async () => {
const bench = await boot()
const disposeFrame = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
let disposeReplacement = (): void => {}
let replaced = false
const first = vi.fn(() => () => {
if (replaced) return
replaced = true
disposeReplacement = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
})
const later = vi.fn(() => () => undefined)
bench.erased.inject('t.host', first)
bench.erased.inject('t.host', later)
disposeFrame()
expect(first).toHaveBeenCalledTimes(2)
expect(later).toHaveBeenCalledTimes(2)
expect(bench.svc.spec('t.host')).toBeDefined()
disposeReplacement()
})
it('cancels a waiting injection when its contributor is already unloading', async () => {
const bench = await boot()
const setup = vi.fn(() => bench.erased.register({ name: 't.host' }, C))
let release = (): void => {}
const blocked = new Promise<void>((resolve) => { release = resolve })
const pauseUnload = vi.fn(async () => { await blocked })
const contributor = bench.ctx.plugin({
name: 'unloading-injection',
inject: ['slots'],
apply: (ctx: Context) => {
ctx.slots.inject('t.host', setup)
ctx.effect(() => pauseUnload, 'pause contributor unload')
},
})
await contributor.await()
const disposing = contributor.dispose()
expect(() => bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)).not.toThrow()
expect(setup).not.toHaveBeenCalled()
await vi.waitFor(() => { expect(pauseUnload).toHaveBeenCalledOnce() })
release()
await disposing
})
it('supports dynamic plugin replacement without retaining the old rendered entry', async () => {
const bench = await boot()
bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
const componentA = (): null => null
const componentB = (): null => null
const mount = (name: string, component: FC<object>) => bench.ctx.plugin({
name,
inject: ['slots'],
apply: (ctx: Context) => { ctx.slots.inject('t.host', () => ctx.slots.register({ name: 't.host' }, component)) },
})
const first = mount('replacement-a', componentA)
await first.await()
expect(bench.svc.entries('t.host')[0]?.component).toBe(componentA)
await first.dispose()
expect(bench.svc.entries('t.host')).toHaveLength(0)
const second = mount('replacement-b', componentB)
await second.await()
expect(bench.svc.entries('t.host')[0]?.component).toBe(componentB)
})
it('releases service-layer store state when the declaration collapses', async () => {
const bench = await boot()
let host: SlotRendererHost | undefined
bench.erased.install({ renderRoot: (value: SlotRendererHost) => { host = value; return null } })
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
const disposeFrame = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
bench.erased.renderSlot('root', {})
if (host === undefined) throw new Error('renderer never received the host')
const { handle } = fakeHandle()
bench.erased.inject('t.host', () => bench.erased.register({ name: 't.host', store: handle }, C))
const oldEntry = host.entriesOf('t.host')[0]
expect(host.storeOf(oldEntry as never, undefined)).toBeDefined()
disposeFrame()
expect(() => host?.storeOf(oldEntry as never, undefined)).toThrow(/not registered/)
bench.erased.register({
name: 'root', children: { 't.panel': { kind: 'single', scope: 'session' } },
}, C)
bench.erased.register({ name: 't.panel', store: handle }, C)
const panelEntry = host.entriesOf('t.panel')[0]
expect(host.storeOf(panelEntry as never, 's1')).toBeDefined()
expect(handle.create).toHaveBeenLastCalledWith('s1')
})
})
describe('renderer install seam', () => {
it('throws on renderSlot before install (boot-order guidance)', async () => {
const bench = await boot()

View File

@@ -408,4 +408,48 @@ describe('TranscriptAdapter', () => {
expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } })
})
})
describe('assistant timing', () => {
const base = 1_700_000_000_000
it('derives step timing across a window rebuild (start + first token + completion)', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.turnStart(0, 0),
ev.user(1, '问'),
ev.stepStart(2, 0),
ev.chunkStart(3, 0),
ev.chunkText(4, 0, '答'),
ev.chunkText(5, 0, '案'),
ev.assistant(6, 0, '答案'),
ev.turnEnd(7, 0),
])
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
expect(assistant).toMatchObject({
timing: { stepStartTime: base + 2, firstTokenTime: base + 4, completedTime: base + 6 },
})
})
it('derives the same timing on the live append path, first token winning once', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.user(0, '问')])
adapter.append(ev.stepStart(1, 0))
adapter.append(ev.chunkText(2, 0, '首'))
adapter.append(ev.chunkText(3, 0, '次'))
adapter.append(ev.assistant(4, 0, '首次'))
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
expect(assistant).toMatchObject({
timing: { stepStartTime: base + 1, firstTokenTime: base + 2, completedTime: base + 4 },
})
})
it('soft-falls to null boundaries when the step opening fell outside the window', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.assistant(100, 0, '被切窗的答案')])
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
expect(assistant).toMatchObject({
timing: { stepStartTime: null, firstTokenTime: null, completedTime: base + 100 },
})
})
})
})

View File

@@ -55,14 +55,10 @@ export const inject = ['slash', 'sessions', 'connection', 'locale']
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-command: dictionaries')
ctx.plugin(CommandService)
// Conditional mount, same seam as ui-slash's MenuView registration:
// 'conversation.input.overlay' is declared by the conversation composer
// entry, and the conversation service's presence is the registration-safe
// signal that the declaration is on the ledger.
ctx.inject(['slots', 'conversation', 'command', 'sessions'], (scope: ClientContext) => {
ctx.inject(['slots', 'command', 'sessions'], (scope: ClientContext) => {
const command = scope.command
const sessions = scope.sessions
scope.effect(() => scope.slots.register({
scope.slots.inject('conversation.input.overlay', () => scope.slots.register({
name: 'conversation.input.overlay',
id: 'command-popup',
order: 1,
@@ -72,6 +68,6 @@ export function apply(ctx: ClientContext): void {
if (actx === undefined) throw new Error(`ui-command: session "${String(sessionId)}" resolved no scope`)
return { popup: command.popupFor(actx) }
},
}, PopupSelectView), 'ui-command: popupSelect overlay registration')
}, PopupSelectView))
})
}

View File

@@ -2,13 +2,13 @@
* ui-command browser half on a real cordis Context with fake slash/slots
* faces and real session scopes: the plugin body mounts CommandService as
* `command`, the popupSelect shell registers into conversation.input.overlay
* once the conversation seam is up with a per-session inject (sessionId →
* through slot declaration injection with a per-session inject (sessionId →
* scope → popupFor; unknown id fails loud), both fold up on fiber disposal
* (HMR safety), and the service satisfies the frozen CommandServiceContract.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandServiceContract } from '../src/client/contract.ts'
@@ -21,7 +21,6 @@ const sid = (k: string): SessionId => k as SessionId
async function bench() {
const ctx = new Context()
const sources = new Map<string, SlashSource>()
const overlays = new Map<string, { inject: unknown }>()
ctx.provide('slash', {
registerSource(src: SlashSource) {
sources.set(`${src.trigger} ${src.name}`, src)
@@ -34,14 +33,10 @@ async function bench() {
scopeOf: (c: Context) => scopeOf(c),
})
ctx.provide('connection', { api: { commands: { list: () => Promise.resolve({ result: { ok: true, value: { commands: [] } } }) } } })
ctx.provide('slots', {
register(options: { name: string; id?: string; inject?: unknown }) {
const key = `${options.name}#${options.id ?? ''}`
overlays.set(key, { inject: options.inject })
return () => { overlays.delete(key) }
},
})
ctx.provide('conversation', {})
await ctx.plugin(SlotsService).await()
ctx.slots.register({
name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } },
} as never, (() => null) as never)
ctx.provide('locale', new LocaleService(ctx))
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
@@ -50,7 +45,7 @@ async function bench() {
scopes.set(sid(key), handle.ctx)
return handle
}
return { ctx, fiber, sources, overlays, mint }
return { ctx, fiber, sources, slots: ctx.slots, mint }
}
describe('apply', () => {
@@ -59,7 +54,7 @@ describe('apply', () => {
})
it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => {
const { ctx, fiber, sources, overlays } = await bench()
const { ctx, fiber, sources, slots } = await bench()
const command = ctx.get('command')
expect(command).toBeInstanceOf(CommandService)
// Frozen-contract conformance (compile-time check rides the assignment).
@@ -67,18 +62,18 @@ describe('apply', () => {
expect(typeof contract.register).toBe('function')
expect(typeof contract.popupFor).toBe('function')
expect([...sources.keys()]).toEqual(['/ command'])
expect([...overlays.keys()]).toEqual(['conversation.input.overlay#command-popup'])
expect(slots.entries('conversation.input.overlay').map(entry => entry.options.id)).toEqual(['command-popup'])
await fiber.dispose()
expect(sources.size).toBe(0)
expect(overlays.size).toBe(0)
expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
})
it('the overlay inject resolves the per-session popup controller by sessionId and fails loud on an unknown id', async () => {
const { ctx, overlays, mint } = await bench()
const { ctx, slots, mint } = await bench()
const command = ctx.get('command') as CommandService
const scope = mint('s1')
const entry = overlays.get('conversation.input.overlay#command-popup')!
const injectEntry = entry.inject as (sessionId: SessionId) => PopupSelectInjected
const entry = slots.entries('conversation.input.overlay')[0]!
const injectEntry = entry.inject as unknown as (sessionId: SessionId) => PopupSelectInjected
expect(injectEntry(sid('s1')).popup).toBe(command.popupFor(scope.ctx))
expect(() => injectEntry(sid('ghost'))).toThrow(/resolved no scope/)
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 4869fa4df929027f031082deb04cc1ab3d18921c
README.zh.md: 5c3091efa11b65f43ea5ea3037a60d0aa1bafbda
README.md: b262c4f89ebcfb8148a0c9a579efe18c2bd4f7a9
README.zh.md: 5a333e84000893040ec26f7c10dbb32cb3e064b7

View File

@@ -32,9 +32,9 @@ The chat flow projects consecutive model-retry nodes across retry turns into one
A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, composes the shared `ToolRow`, feeding the card as ToolRow's `search` body, so it is the row's collapsed-by-default expanded card; the render-site fallback routes it the same way. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) surfaces its flattened result text through ToolRow's Output section so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).
Tool rows use the keyed, session-scoped `'conversation.chat.toolview'` slot; its render site dispatches via `entryKey: toolName` with `GenericToolCard` as the call-site fallback. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`), and `ToolRowProps` 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. Trajectory and waterfall toolview slots share this shape and use their own render sites; RendersCheck rejects a declaration nobody renders.
Tool rows use the keyed, session-scoped `'conversation.chat.toolview'` slot; its render site dispatches via `entryKey: toolName` with `GenericToolCard` as the call-site fallback. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`), and `ToolRowProps` composes it with the session standard kit. A registrant is a plain plugin with only the slot service edge: `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`. The declaration is the activation and reload dependency; `ConversationService` is required only by registrations that call its actions. Trajectory and waterfall toolview slots share this shape and use their own render sites; RendersCheck rejects a declaration nobody renders.
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
The todo surfaces are two registrations over that shape, both using slot declaration injection without a `ConversationService` edge. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do.
@@ -46,7 +46,7 @@ Per-session UI state for selection and the active view lives in the declared cha
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
The chat stats line takes its token accounting from two generic token-meter projections read through the standard-kit `useProjection`: `tokenUsage` for full-log billing (billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total) and `contextPressure` for context occupancy. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. A deployment without token-meter drops the token groups, and occupancy stays hidden until both provider pressure and route capacity are known. Occupancy is deliberately an approximation — its numerator and capacity are independent last-wins projection fields, not one atomic request observation ([rationale](../../llm/token-meter/README.md)). The inline stats row remains the sole context UI; the model selector has no circle or accessory.
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy moved off the row onto the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.
`src/client/` is organized by domain. `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations and composed props, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` 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 and the store factory stay internal and reach the page through apply's slot registrations.
@@ -61,7 +61,7 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced.
- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
- **Stats-line durations and speeds cover the in-window flow only** — LLM and tool wall times plus the TTFT and throughput averages fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
- **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)).
- **Sent user messages cannot be edited** — user bubbles retain clock, copy, and branch; branch stays disabled unless a completed turn's transcript ends at that user message. Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)).

View File

@@ -30,11 +30,11 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
声明 `search` 渲染意图的 `grep``glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line`glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card``kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files``paths` 格式错误的已知 kind它都返回 null落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep``glob` 下,组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `search` body 传入,因此它是该行默认折叠的展开卡片;渲染点兜底行以同样方式渲染它。两者上限都是 `CHAT_SEARCH_MAX_LINES`8面板为 16。被截断的搜索会从卡片里丢掉一些行但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则经 ToolRow 的 Output 区呈现其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
工具行使用键控、Session scope 的 `'conversation.chat.toolview'` slot其渲染点通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 fallback。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile``ToolRowProps` 将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam。Trajectory 与 waterfall瀑布式事件工具视图 slot 共享此形状并使用各自的渲染点RendersCheck 会拒绝没有任何渲染方的声明。
工具行使用键控、Session scope 的 `'conversation.chat.toolview'` slot其渲染点通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 fallback。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile``ToolRowProps` 将其与 Session 标准工具包组合。注册方是只依赖 slot 服务的普通插件:`ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`。声明本身就是激活与重载依赖;只有调用 `ConversationService` 操作的注册项才需要该服务。Trajectory 与 waterfall瀑布式事件工具视图 slot 共享此形状并使用各自的渲染点RendersCheck 会拒绝没有任何渲染方的声明。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用取消、Escape、关闭按钮与点击遮罩都不会提交命令。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: 0` 占用 `'conversation.input.dock'` 列表 slot位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注入,不依赖 `ConversationService``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: 0` 占用 `'conversation.input.dock'` 列表 slot位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
`QueueDock``order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded``aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering中途引导操作已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
@@ -46,9 +46,9 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher而非附件入口它要求当前会话的 `SlashController` 基于 textarea 当前 selection只打开 `/` trigger 的 `command` source同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染machine face 均缺席、`disabled` owner prop而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的两个通用 token-meter 投影`tokenUsage` 提供完整日志计费用量(计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量`contextPressure` 提供上下文占用率。可见节点只提供轮次与步骤计数,以及 LLM大语言模型和工具的墙钟时间这些是关于「屏幕上有什么」的窗口作用域事实而非账目压缩compaction使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。未组合 token-meter 的部署会整组省略 token 分组;只有提供方压力与路由容量都已知时才显示占用率。占用率是刻意为之的近似值:它的分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测([原理](../../llm/token-meter/README.md))。行内统计行仍是唯一的上下文 UI模型选择器不增加圆环或附属控件
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM大语言模型和工具的墙钟时间这些是关于「屏幕上有什么」的窗口作用域事实而非账目压缩compaction使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率从统计行移到了 composer 尾部的 ContextMeter模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测
`src/client/`未来的包拆分组织`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props包括工具行契约`views.ts` 共享原语、`tool-call-model.ts``skeleton/``chat/``toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply``inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册达页面(测试通过 `./src/*` 子路径获取它们)
`src/client/`领域组织`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明组合后的 props`views.ts` 共享原语、`tool-call-model.ts``skeleton/``chat/``toolviews/` 目录只导入 contract 文件,彼此之间从不互相导入`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply``inject`、两个服务类和 `contract/` 类型家族;实现组件与 store factory 保持内部,经 apply 的 slot 注册达页面。
## 模型体验
@@ -61,8 +61,8 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
## 已知限制与暂缓事项
- **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。
- **统计行的耗时只覆盖窗口内消息流**LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖
- **统计行的耗时与速率只覆盖窗口内消息流**LLM 与工具墙钟时间以及 TTFT 与吞吐平均值由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
- **详情面板没有入口**`ChatViewInjected.openDetails` 虽已实现却无人调用,因此以原始形式显示已选择调用的那部分在组装后的应用中不可达。没有 Input/Output/Metadata 切换、Prev/Next 步进,也没有 trajectory 深链接。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。
- **已发送的 user 消息无法编辑**user 气泡保留时钟、复制和分支;除非已完成轮次的 transcript 结束于该 user 消息,否则分支保持禁用。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。

View File

@@ -1,6 +1,6 @@
/** Registers the conversation components, shared store, and service callbacks. */
import type { Context } from 'cordis'
import { deferRegistration, resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
@@ -98,20 +98,16 @@ export function apply(ctx: Context): void {
const chatStore = createChatStore()
const submissionPolicy = new ComposerSubmissionPolicy()
ctx.effect(() => {
const row = deferRegistration(ctx.slots, 'settings.general.item', EnterBehaviorRow, () =>
ctx.slots.register({
name: 'settings.general.item',
id: 'composer-enter',
order: 20,
locale: NS,
inject: (): EnterBehaviorRowInjected => ({
hooks: { busyEnter: submissionPolicy.busyEnter },
setBusyEnter: (behavior) => { submissionPolicy.setBusyEnter(behavior) },
}),
}, EnterBehaviorRow))
return () => { row.dispose() }
}, 'ui-conversation: Enter behavior settings row')
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',
id: 'composer-enter',
order: 20,
locale: NS,
inject: (): EnterBehaviorRowInjected => ({
hooks: { busyEnter: submissionPolicy.busyEnter },
setBusyEnter: (behavior) => { submissionPolicy.setBusyEnter(behavior) },
}),
}, EnterBehaviorRow))
// Chat semantic reader positions by session, surviving view switches and
// width reflow when the tab ring remounts the view. Deliberately not
@@ -334,17 +330,15 @@ export function apply(ctx: Context): void {
}, ChatView)
// Session stats stick with the composer (composer.dock = stats-line family).
slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0 }, StatsLine)
slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS }, StatsLine)
// 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.
// Presentation registrants depend directly on their slot declarations;
// this service remains only where conversation actions are required.
ctx.plugin(ConversationService, { input: inputHub })
// The bash sample rides that exact seam, in third-party posture
// The bash sample rides the same declaration seam, in third-party posture
// (ToolRow-matching Bash · {description} chrome).
ctx.plugin(bashToolviewSample)

View File

@@ -30,6 +30,10 @@ export interface AssistantMarkdownProps {
/** Turn wall time in ms for the IconActions run-time label; omitted when the
* turn's triggering input is outside the loaded window. */
runMs?: number | undefined
/** Turn first-step TTFT in ms for the IconActions label; omitted when unrecorded. */
ttftMs?: number | undefined
/** Turn decode throughput for the IconActions label; omitted when unrecorded. */
tokensPerSecond?: number | undefined
/** Event sequence used as the fork boundary; omitted while streaming. */
seq?: number | undefined
/** Fork the session through this finalized message's completed turn when eligible. */
@@ -82,7 +86,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time, runMs, seq, onFork, forkUnavailable, t,
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
@@ -125,6 +129,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
text={copyText(blocks)}
time={time}
runMs={runMs}
ttftMs={ttftMs}
tokensPerSecond={tokensPerSecond}
clock="end"
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
branchUnavailable={forkUnavailable}

View File

@@ -36,6 +36,7 @@ import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
import { formatRunDuration } from './message-chrome.ts'
import { deriveTurnMetrics } from './turn-metrics.ts'
import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24
@@ -362,6 +363,7 @@ export function ChatView({
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings])
const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes])
const listRef = useRef<HTMLDivElement | null>(null)
const columnRef = useRef<HTMLDivElement | null>(null)
@@ -599,6 +601,9 @@ export function ChatView({
const node: ConversationNode = item.node
if (node.kind === 'assistant') {
const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined
// Metrics gate on the settled in-window timing: turn/start loaded means
// every step of the turn is loaded, so first-step TTFT is genuine.
const metrics = timing?.endTime === undefined ? undefined : turnMetrics.get(node.turn)
return (
<AssistantMarkdown
blocks={node.blocks}
@@ -608,6 +613,8 @@ export function ChatView({
runMs={timing?.endTime === undefined
? undefined
: Math.max(0, timing.endTime - timing.startTime)}
ttftMs={metrics?.ttftMs}
tokensPerSecond={metrics?.tokensPerSecond}
seq={node.seq}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}

View File

@@ -6,7 +6,7 @@ import {
IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { formatMessageClock, formatRunDuration } from './message-chrome.ts'
import { formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond } from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import css from './MessageIconActions.module.css'
@@ -17,6 +17,10 @@ export interface MessageIconActionsProps {
time?: number | undefined
/** Turn wall time in ms, appended to the clock as `· Ran for 15s`; omitted when the turn's start is unknown. */
runMs?: number | undefined
/** Turn first-step TTFT in ms, appended as `· TTFT 1.2s`; omitted when unrecorded. */
ttftMs?: number | undefined
/** Turn decode throughput, appended as `· 34 tok/s`; omitted when unrecorded. */
tokensPerSecond?: number | undefined
/** Clock before icons (user) or after (assistant). */
clock: 'start' | 'end'
/** Fork the session at this message; omission hides the branch action. */
@@ -37,7 +41,7 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, runMs, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const reasonId = useId()
@@ -67,15 +71,36 @@ export function MessageIconActions({
}, 1000)
})
}, [copied, text])
// The dot is decorative and stays hidden, but its margins separate the
// readings only on screen: without the flanking spaces a reader hears one
// run-on string ("Ran for 13sTTFT 0.2s12 tok/s") instead of three facts.
const clockEl = time === undefined ? null : (
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
{formatMessageClock(time, t, day)}
{runMs !== undefined && (
<>
{' '}
<span className={css.runTimeDot} aria-hidden>·</span>
{' '}
{t('message.ranFor', { duration: formatRunDuration(runMs, t) })}
</>
)}
{ttftMs !== undefined && (
<>
{' '}
<span className={css.runTimeDot} aria-hidden>·</span>
{' '}
{t('message.ttft', { seconds: formatLatencySeconds(ttftMs) })}
</>
)}
{tokensPerSecond !== undefined && (
<>
{' '}
<span className={css.runTimeDot} aria-hidden>·</span>
{' '}
{t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}
</>
)}
</span>
)
return (

View File

@@ -2,10 +2,14 @@
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
import { Fragment, memo, useMemo } from 'react'
import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ConversationSnapshot, UseProjection } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
import type { ComposerBarProps } from '../contract/slots.ts'
import { formatTokensPerSecond } from './message-chrome.ts'
import { assistantStepReading } from './turn-metrics.ts'
import css from './StatsLine.module.css'
interface WindowStats {
@@ -15,6 +19,14 @@ interface WindowStats {
llmMs: number
/** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
toolMs: number
/** Summed first-token latency over `ttftSteps`; 0 when no step records it. */
ttftMs: number
/** Steps carrying a recorded TTFT. */
ttftSteps: number
/** Summed decode wall time over steps that also report output tokens. */
decodeMs: number
/** Summed output tokens over the same decode-timed steps. */
decodeTokens: number
}
/**
@@ -32,6 +44,10 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats {
let steps = 0
let llmMs = 0
let toolMs = 0
let ttftMs = 0
let ttftSteps = 0
let decodeMs = 0
let decodeTokens = 0
for (const node of nodes) {
if (node.kind === 'tool-result') {
if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
@@ -43,8 +59,17 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats {
if (node.timing !== undefined && node.timing.stepStartTime !== null) {
llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
}
const reading = assistantStepReading(node)
if (reading.ttftMs !== null) {
ttftMs += reading.ttftMs
ttftSteps += 1
}
if (reading.decodeMs !== null && reading.outputTokens !== null) {
decodeMs += reading.decodeMs
decodeTokens += reading.outputTokens
}
}
return { turns: turns.size, steps, llmMs, toolMs }
return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }
}
/**
@@ -84,30 +109,41 @@ export function cacheHitPercent(usage: TokenUsageProjection): number | null {
: Math.round(usage.cacheReadTokens / denominator * 100)
}
/** Sum the three disjoint prompt-side billing buckets. */
function billedInputTokens(usage: TokenUsageProjection): number {
/**
* Sum the three disjoint prompt-side billing buckets.
* @param usage - the session's token-usage projection value.
* @returns billed input tokens.
*/
export function billedInputTokens(usage: TokenUsageProjection): number {
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
}
interface ContextOccupancy {
percent: number
usedTokens: number
contextWindow: number
}
/**
* Approximate context occupancy, using the TUI's integer rounding and upper
* clamp. The numerator and capacity are independent last-wins projection
* fields, so this is a reference figure rather than an exact measurement of one
* request (see the token-meter README).
* clamp. The numerator is `projectedTokens` — the provider sample carried
* forward over the surface's movement since — so compaction shows immediately
* instead of waiting for the next request to report usage; it falls back to the
* bare sample only for a log whose projection predates that field. Numerator
* and capacity remain independent last-wins projection fields, so this is a
* reference figure rather than an exact measurement of one request (see the
* token-meter README).
* @param pressure - the session's context-pressure projection value.
* @returns occupancy and its denominator, or null until both values are known.
* @returns occupancy with its numerator and denominator, or null until both values are known.
*/
export function contextOccupancy(
pressure: ContextPressureProjection | undefined,
): ContextOccupancy | null {
if (pressure?.pressureTokens === undefined || pressure.contextWindow === undefined) return null
const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens
if (usedTokens === undefined || pressure?.contextWindow === undefined) return null
return {
percent: Math.min(100, Math.round(pressure.pressureTokens / pressure.contextWindow * 100)),
percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)),
usedTokens,
contextWindow: pressure.contextWindow,
}
}
@@ -116,46 +152,73 @@ export function contextOccupancy(
export interface StatsLineProps {
useSession: SnapshotSelectorHook<ConversationSnapshot>
useProjection: UseProjection
/** The owning dock's locale seat. */
t: ComposerBarProps['t']
}
export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) {
export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) {
const nodes = useSession(s => s.nodes)
const usage = useProjection('tokenUsage')
const pressure = useProjection('contextPressure')
const stats = useMemo(() => deriveStats(nodes), [nodes])
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
const groups: string[] = []
if (stats.steps > 0) {
groups.push(`${stats.turns} turns · ${stats.steps} steps`)
groups.push(t('stats.counts', { turns: stats.turns, steps: stats.steps }))
const durations: string[] = []
if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs) }))
if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs) }))
if (durations.length > 0) groups.push(durations.join(' · '))
// Window-scoped like the wall times above: averages describe loaded steps.
const speeds: string[] = []
if (stats.ttftSteps > 0) {
speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps) }))
}
if (stats.decodeMs > 0) {
speeds.push(t('stats.tokensPerSecond', {
throughput: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
}))
}
if (speeds.length > 0) groups.push(speeds.join(' · '))
}
const context = contextOccupancy(pressure)
if (context !== null) {
groups.push(`Context ${context.percent}% of ${formatTokens(context.contextWindow)}`)
}
// Context occupancy deliberately lives on the composer's ContextMeter ring,
// not here — one home per fact.
// Billing rides the durable projection, so these survive paging and
// compaction. Suppress the empty projection on a brand-new session.
if (usage !== undefined
&& (stats.steps > 0 || billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
const cacheHit = cacheHitPercent(usage)
if (cacheHit !== null) groups.push(`Cache hit ${cacheHit}%`)
groups.push(
`Input ${formatTokens(billedInputTokens(usage))} tok`
+ ` · Output ${formatTokens(usage.outputTokens)} tok`,
)
if (cacheHit !== null) groups.push(t('stats.cacheHit', { percent: cacheHit }))
groups.push(t('stats.tokens', {
input: formatTokens(billedInputTokens(usage)),
output: formatTokens(usage.outputTokens),
}))
}
const line = groups.join(' | ')
// The row elides with ellipsis when overlong; a delayed hover tooltip carries
// the full line, enabled only while content is actually clipped.
const rootRef = useRef<HTMLDivElement | null>(null)
const [truncated, setTruncated] = useState(false)
useLayoutEffect(() => {
const el = rootRef.current
if (el === null) return
const measure = () => { setTruncated(el.scrollWidth > el.clientWidth) }
measure()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => { observer.disconnect() }
}, [line])
if (groups.length === 0) return null
return (
<div className={css.root}>
{groups.map((group, i) => (
<Fragment key={group}>
{i > 0 && <><span className={css.sep} aria-hidden>|</span>{' '}</>}
<span>{group}</span>
</Fragment>
))}
</div>
<Tooltip label={line} side="top" delayMs={500} disabled={!truncated}>
<div ref={rootRef} className={css.root}>
{groups.map((group, i) => (
<Fragment key={group}>
{i > 0 && <><span className={css.sep} aria-hidden>|</span>{' '}</>}
<span>{group}</span>
</Fragment>
))}
</div>
</Tooltip>
)
})

View File

@@ -48,6 +48,27 @@ export function formatRunDuration(ms: number, t: RunDurationTranslate): string {
: t('duration.seconds', { seconds })
}
/**
* Sub-turn latency figure: one decimal under ten seconds, whole seconds
* beyond. Unit-less so the locale template owns the second suffix.
* @param ms - Latency in milliseconds (negatives clamp to zero).
* @returns Display number in seconds without unit.
*/
export function formatLatencySeconds(ms: number): string {
const s = Math.max(0, ms) / 1000
return s < 10 ? String(Math.round(s * 10) / 10) : String(Math.round(s))
}
/**
* Decode-throughput figure: whole tokens from ten up, one decimal below.
* @param tps - Tokens per second.
* @returns Display number without unit.
*/
export function formatTokensPerSecond(tps: number): string {
const clamped = Math.max(0, tps)
return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10)
}
/**
* Compact local timestamp for message IconActions. Same calendar day →
* `HH:mm`; earlier this year → the `clock.md` date template + clock; other

View File

@@ -0,0 +1,97 @@
// Latency/throughput folds shared by the settled turn footer and StatsLine.
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
/** Latency and decode-throughput readings for one turn's footer. */
export interface TurnMetrics {
/** First-step TTFT in ms; absent when that step carries no recorded timing. */
ttftMs?: number
/** Decode throughput over steps carrying both timing and provider usage. */
tokensPerSecond?: number
}
/** One assistant step's derivable latency facts; null marks an unrecorded part. */
export interface StepReading {
/** step/start → first token delta, in ms. */
ttftMs: number | null
/** First token delta → final message, in ms. */
decodeMs: number | null
/** Provider-reported completion tokens. */
outputTokens: number | null
}
interface UsageLike {
outputTokens?: number
}
type AssistantNode = Extract<ConversationSnapshot['nodes'][number], { kind: 'assistant' }>
function usageOutputTokens(usage: unknown): number | null {
if (typeof usage !== 'object' || usage === null) return null
const value = (usage as UsageLike).outputTokens
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
}
/**
* Read one assistant node's TTFT, decode wall time, and output tokens.
* @param node - A settled assistant node.
* @returns Per-part readings with `null` for unrecorded values.
*/
export function assistantStepReading(node: AssistantNode): StepReading {
const timing = node.timing
const ttftMs = timing !== undefined && timing.stepStartTime !== null && timing.firstTokenTime !== null
? Math.max(0, timing.firstTokenTime - timing.stepStartTime)
: null
const decodeMs = timing !== undefined && timing.firstTokenTime !== null
? Math.max(0, timing.completedTime - timing.firstTokenTime)
: null
return { ttftMs, decodeMs, outputTokens: usageOutputTokens(node.usage) }
}
interface TurnFold {
firstStep: number
firstStepTtftMs: number | null
decodeMs: number
outputTokens: number
sampled: boolean
}
/**
* Fold assistant nodes into per-turn footer metrics.
*
* TTFT is the turn's lowest-step request-dispatch-to-first-token reading, so
* it is only meaningful when the turn's start is inside
* the loaded window (the caller gates on `turnTimings`, which shares that
* window). Throughput divides summed output tokens by summed decode wall time,
* counting only steps that carry both.
* @param nodes - Snapshot nodes of the loaded window.
* @returns Turn number → available metrics; turns with none are absent.
*/
export function deriveTurnMetrics(nodes: ConversationSnapshot['nodes']): Map<number, TurnMetrics> {
const folds = new Map<number, TurnFold>()
for (const node of nodes) {
if (node.kind !== 'assistant') continue
const reading = assistantStepReading(node)
let fold = folds.get(node.turn)
if (fold === undefined) {
fold = { firstStep: node.step, firstStepTtftMs: reading.ttftMs, decodeMs: 0, outputTokens: 0, sampled: false }
folds.set(node.turn, fold)
} else if (node.step < fold.firstStep) {
fold.firstStep = node.step
fold.firstStepTtftMs = reading.ttftMs
}
if (reading.decodeMs !== null && reading.outputTokens !== null) {
fold.decodeMs += reading.decodeMs
fold.outputTokens += reading.outputTokens
fold.sampled = true
}
}
const metrics = new Map<number, TurnMetrics>()
for (const [turn, fold] of folds) {
const entry: TurnMetrics = {}
if (fold.firstStepTtftMs !== null) entry.ttftMs = fold.firstStepTtftMs
if (fold.sampled && fold.decodeMs > 0) entry.tokensPerSecond = fold.outputTokens / (fold.decodeMs / 1000)
if (entry.ttftMs !== undefined || entry.tokensPerSecond !== undefined) metrics.set(turn, entry)
}
return metrics
}

View File

@@ -23,6 +23,18 @@ export const zh = {
'input.stop': '停止生成',
'input.send': '发送消息',
'input.accessMode': '访问模式,当前:{name}',
'context.aria': '上下文已用 {percent}',
'context.used': '上下文已用',
'context.system': '系统提示词',
'context.tools': '工具',
'context.messages': '对话消息',
'stats.counts': '{turns} 轮 · {steps} 步',
'stats.llm': 'LLM {duration}',
'stats.toolCall': '工具调用 {duration}',
'stats.ttftAverage': '首 token 平均 {duration}',
'stats.tokensPerSecond': '{throughput} tok/s',
'stats.cacheHit': '缓存命中 {percent}%',
'stats.tokens': '输入 {input} tok · 输出 {output} tok',
'settings.enter.title': '繁忙时 Enter 键行为',
'settings.enter.description': '仅在智能体运行时生效Cmd/Ctrl+Enter 使用另一行为',
'settings.enter.queue': '排队发送',
@@ -71,6 +83,8 @@ export const zh = {
'message.retry.failure': '失败原因:',
'message.turnError': '本轮运行失败',
'message.ranFor': '用时 {duration}',
'message.ttft': '首 token {seconds}秒',
'message.tokensPerSecond': '{tps} tok/s',
'duration.seconds': '{seconds}秒',
'duration.minutes': '{minutes}分{seconds}秒',
'command.running': '执行中…',
@@ -136,6 +150,18 @@ export const en = {
'input.stop': 'Stop generating',
'input.send': 'Send message',
'input.accessMode': 'Access mode, current: {name}',
'context.aria': '{percent} of context used',
'context.used': 'of context used',
'context.system': 'System prompt',
'context.tools': 'Tools',
'context.messages': 'Messages',
'stats.counts': '{turns} turns · {steps} steps',
'stats.llm': 'LLM {duration}',
'stats.toolCall': 'Tool call {duration}',
'stats.ttftAverage': 'TTFT avg {duration}',
'stats.tokensPerSecond': '{throughput} tok/s',
'stats.cacheHit': 'Cache hit {percent}%',
'stats.tokens': 'Input {input} tok · Output {output} tok',
'settings.enter.title': 'Enter behavior while busy',
'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior',
'settings.enter.queue': 'Queue',
@@ -184,6 +210,8 @@ export const en = {
'message.retry.failure': 'Failure reason: ',
'message.turnError': 'This turn failed',
'message.ranFor': 'Ran for {duration}',
'message.ttft': 'TTFT {seconds}s',
'message.tokensPerSecond': '{tps} tok/s',
'duration.seconds': '{seconds}s',
'duration.minutes': '{minutes}m {seconds}s',
'command.running': 'Running…',

View File

@@ -213,8 +213,8 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
}
/**
* The dock entry as a plain registrant plugin. The conversation service is the
* ordering and action seam; session scopes provide the exact queue owner.
* The dock entry as a plain registrant plugin. The conversation service is
* the action seam; the slot declaration is its independent lifecycle seam.
*/
export const queueDockEntry = {
name: 'conversation-queue-dock',
@@ -224,7 +224,7 @@ export const queueDockEntry = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
name: 'conversation.input.dock',
id: 'queue',
order: 20,
@@ -239,6 +239,6 @@ export const queueDockEntry = {
notify: (level, text) => { conversation.input.for(actx).notify(level, text) },
}
},
}, QueueDock)
}, QueueDock))
},
}

View File

@@ -0,0 +1,147 @@
/* Context-occupancy ring beside the send button plus its click-open breakdown
panel (menu surface: r12, inverted hairline, shadow-lv3). */
.root {
position: relative;
display: inline-flex;
}
/* Same 28px circular hit target family as the composer's attach button. */
.trigger {
display: grid;
place-items: center;
flex: none;
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.trigger:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.track {
fill: none;
stroke: var(--dsw-alias-border-l3);
stroke-width: 2;
}
.fill {
fill: none;
stroke: var(--dsw-alias-label-tertiary);
stroke-width: 2;
stroke-linecap: round;
}
.panel {
position: absolute;
bottom: calc(100% + 8px);
right: 0;
z-index: 100;
box-sizing: border-box;
width: 264px;
padding: 12px;
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 12px;
background: var(--dsw-specific-menu);
box-shadow: var(--dsw-shadow-lv3);
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
cursor: default;
}
.header {
display: flex;
align-items: center;
gap: 6px;
}
.figures {
margin-left: auto;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-primary);
}
.percent {
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.headline {
color: var(--dsw-alias-label-tertiary);
}
/* The headline brackets the reading, so the side a locale leaves empty must
drop out of the flex row rather than spend a gap. */
.headline:empty {
display: none;
}
.bar {
display: flex;
gap: 1px;
margin: 10px 0 12px;
height: 4px;
border-radius: 999px;
background: var(--dsw-alias-interactive-bg-hover);
overflow: hidden;
}
.segment {
flex: none;
min-width: 2px;
height: 100%;
border-radius: 1px;
background: var(--meter-tint, var(--dsw-alias-label-tertiary));
}
.swatch {
display: inline-block;
margin-right: 6px;
width: 8px;
height: 8px;
border-radius: 2px;
background: var(--meter-tint);
vertical-align: baseline;
}
.colorSystem {
--meter-tint: var(--dsw-static-neutral-bluish-400);
}
.colorTools {
/* The design platform ships no purple static token; violet-400 literal. */
--meter-tint: rgb(167, 139, 250);
}
.colorMessages {
--meter-tint: var(--dsw-static-blue-450);
}
.rows {
margin: 6px 0 0;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 0;
}
.row dt {
color: var(--dsw-alias-label-secondary);
}
.row dd {
margin: 0;
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-primary);
}

View File

@@ -0,0 +1,153 @@
/** Composer context-occupancy meter: a ring beside the send button fed by the
* `contextPressure` projection, with a click-open panel of the heuristic
* `contextBreakdown` composition (system prompt, tools, conversation).
* Renders nothing until a provider reports both pressure and a route capacity
* (same gate as the stats row used). */
import { useEffect, useRef, useState } from 'react'
import type { UseProjection } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the `contextPressure` / `contextBreakdown` projection key merges.
import type {} from '@deepseek-ai/dsh-token-meter/client'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerBarProps } from '../contract/slots.ts'
import { contextOccupancy, formatTokens } from '../chat/StatsLine.tsx'
import css from './ContextMeter.module.css'
/** Ring geometry: 14px viewBox, 2px stroke. */
const RADIUS = 5.5
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
/**
* Marker the localized occupancy sentence is split on, so the panel headline
* keeps the reading in its own tone while each locale still owns the word
* order (`45% of context used` / `上下文已用 45%`).
*/
const READING_SLOT = '\u0000'
/** Panel legend rows, in bar-segment order; each color class carries the shared swatch/segment tint. */
const ROWS = [
{ key: 'systemTokens', label: 'context.system', color: css.colorSystem },
{ key: 'toolsTokens', label: 'context.tools', color: css.colorTools },
{ key: 'messageTokens', label: 'context.messages', color: css.colorMessages },
] as const
export interface ContextMeterProps {
useProjection: UseProjection
/** The owning bar's locale seat, passed down as a plain prop. */
t: ComposerBarProps['t']
}
export function ContextMeter({ useProjection, t }: ContextMeterProps) {
const pressure = useProjection('contextPressure')
const breakdown = useProjection('contextBreakdown')
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLSpanElement | null>(null)
const context = contextOccupancy(pressure)
const available = context !== null
// A model switch can temporarily remove capacity while this component stays
// mounted. Close the now-unavailable panel instead of preserving stale UI.
useEffect(() => {
if (!available && open) setOpen(false)
}, [available, open])
// Outside click / Escape close, one document listener while open (Menu's pattern).
useEffect(() => {
if (!open || !available) return
const onPointerDown = (e: PointerEvent): void => {
if (e.target instanceof Node && rootRef.current?.contains(e.target) === true) return
setOpen(false)
}
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('pointerdown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('pointerdown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [available, open])
if (context === null) return null
const percent = context.percent
const reading = `${percent}%`
const [headBefore = '', headAfter = ''] = t('context.aria', { percent: READING_SLOT })
.split(READING_SLOT)
.map(part => part.trim())
// The bar's overall length stays the provider-exact percent; the heuristic
// breakdown only proportions its colored parts. A zero-width part is dropped
// instead of rendered: `.segment`'s min-width keeps a hairline part visible,
// which at 0% occupancy would draw a filled bar over an empty context.
const breakdownTotal = breakdown === undefined
? 0
: breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens
const parts = breakdown === undefined || breakdownTotal === 0
? [{ key: 'total', color: undefined, width: percent }]
: ROWS.map(row => ({ key: row.key, color: row.color, width: percent * breakdown[row.key] / breakdownTotal }))
const segments = parts.filter(part => part.width > 0)
return (
<span ref={rootRef} className={css.root}>
<Tooltip label={t('context.aria', { percent: reading })} side="top" delayMs={200} disabled={open}>
<button
type="button"
className={css.trigger}
aria-label={t('context.aria', { percent: reading })}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => { setOpen(!open) }}
>
<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden>
<circle className={css.track} cx="7" cy="7" r={RADIUS} />
<circle
className={css.fill}
cx="7"
cy="7"
r={RADIUS}
strokeDasharray={`${CIRCUMFERENCE * percent / 100} ${CIRCUMFERENCE}`}
transform="rotate(-90 7 7)"
/>
</svg>
</button>
</Tooltip>
{open && (
<div className={css.panel} role="dialog" aria-label={t('context.used')}>
<div className={css.header}>
{/* Empty sides collapse through `.headline:empty` so the locale that
needs no leading (or trailing) text spends no header gap. */}
<span className={css.headline}>{headBefore}</span>
<span className={css.percent}>{reading}</span>
<span className={css.headline}>{headAfter}</span>
<span className={css.figures}>
{`~${formatTokens(context.usedTokens)} / ${formatTokens(context.contextWindow)}`}
</span>
</div>
<div className={css.bar}>
{segments.map(segment => (
<div
key={segment.key}
className={segment.color === undefined ? css.segment : `${css.segment} ${segment.color}`}
style={{ width: `${segment.width}%` }}
/>
))}
</div>
{breakdown !== undefined && (
<dl className={css.rows}>
{ROWS.map(row => (
<div key={row.key} className={css.row}>
<dt>
<span className={`${css.swatch} ${row.color}`} aria-hidden />
{t(row.label)}
</dt>
<dd>{`~${formatTokens(breakdown[row.key])}`}</dd>
</div>
))}
</dl>
)}
</div>
)}
</span>
)
}

View File

@@ -19,6 +19,7 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import type { DraftDecorations } from '../input/decorations.ts'
import { ContextMeter } from './ContextMeter.tsx'
import { PermissionSelect } from './PermissionSelect.tsx'
import css from './InputBar.module.css'
@@ -512,6 +513,7 @@ export function InputBar({
<div className={css.trailing}>
{rightItems}
{renderSlot('conversation.input.model', { locked })}
<ContextMeter useProjection={useProjection} t={t} />
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
<Tooltip label={primaryLabel} side="top" delayMs={500}>
<button

View File

@@ -137,19 +137,18 @@ export function TodoDock({ useProjection, t }: TodoDockProps) {
}
/**
* The plan strip as a plain registrant plugin (QueueDock posture).
* `inject: ['conversation']` is the ordering seam: the conversation service
* mounts after ui-conversation's slot registrations, so the
* 'conversation.input.dock' declaration is on the ledger by then.
* The plan strip as a plain registrant plugin (QueueDock posture), following
* the input-dock declaration across independent activation and reload.
*/
export const todoDockEntry = {
name: 'conversation-todo-dock',
inject: ['slots', 'conversation'],
inject: ['slots'],
/**
* Register the plan strip before the goal and queue entries (order 0).
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
ctx.slots.inject('conversation.input.dock', () =>
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock))
},
}

View File

@@ -83,19 +83,19 @@ export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowPr
}
/**
* The ask-question row as a plain registrant plugin, riding the same
* load-order seam as todo-toolview: `inject: ['conversation']` guarantees the
* chat entry (and with it the 'conversation.chat.toolview' declaration) is on
* the ledger.
* The ask-question row as a plain registrant plugin following the chat
* toolview declaration across independent activation and reload lifetimes.
*/
export const askQuestionToolview = {
name: 'ask-question-toolview',
inject: ['slots', 'conversation'],
inject: ['slots'],
/**
* Register the ask-question 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: 'ask_user_question', locale: NS }, AskQuestionRow)
ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({
name: 'conversation.chat.toolview', key: 'ask_user_question', locale: NS,
}, AskQuestionRow))
},
}

View File

@@ -166,19 +166,18 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
}
/**
* 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.
* The sample as a plain registrant plugin. Slot injection follows the chat
* toolview declaration across independent activation and reload lifetimes.
*/
export const bashToolviewSample = {
name: 'bash-toolview-sample',
inject: ['slots', 'conversation'],
inject: ['slots'],
/**
* 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', locale: NS }, BashRow)
ctx.slots.inject('conversation.chat.toolview', () =>
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash', locale: NS }, BashRow))
},
}

View File

@@ -53,21 +53,21 @@ export function FileMutationRow({ toolName, block, cwd, openFile, inspect, t }:
}
/**
* The file-mutation rows 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.
* The file-mutation rows as a plain registrant plugin following the chat
* toolview declaration across independent activation and reload lifetimes.
*/
export const fileMutationToolview = {
name: 'file-mutation-toolview',
inject: ['slots', 'conversation'],
inject: ['slots'],
/**
* Register the file-mutation row into the chat view's keyed toolview hole
* under both mutation tool names.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit', locale: NS }, FileMutationRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write', locale: NS }, FileMutationRow)
ctx.slots.inject('conversation.chat.toolview', function* () {
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit', locale: NS }, FileMutationRow)
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write', locale: NS }, FileMutationRow)
})
},
}

View File

@@ -48,19 +48,18 @@ export function ReadRow({ toolName, block, cwd, openFile, inspect, t }: ReadRowP
}
/**
* The read row 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.
* The read row as a plain registrant plugin following the chat toolview
* declaration across independent activation and reload lifetimes.
*/
export const readToolview = {
name: 'read-toolview',
inject: ['slots', 'conversation'],
inject: ['slots'],
/**
* Register the read 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: 'read', locale: NS }, ReadRow)
ctx.slots.inject('conversation.chat.toolview', () =>
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read', locale: NS }, ReadRow))
},
}

View File

@@ -61,22 +61,22 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
}
/**
* The search toolview 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.
* The one component registers under both keys, since `grep` and `glob` are the
* same visual object discriminated only by the result view's `kind`.
* The search toolview follows the chat toolview declaration across activation
* and reload. One component registers under both keys because `grep` and
* `glob` are the same visual object discriminated by the result view's `kind`.
*/
export const searchToolview = {
name: 'search-toolview',
inject: ['slots', 'conversation'],
inject: ['slots'],
/**
* Register the search row into the chat view's keyed toolview hole under both
* the `grep` and `glob` tool names.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob', locale: NS }, SearchRow)
ctx.slots.inject('conversation.chat.toolview', function* () {
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob', locale: NS }, SearchRow)
})
},
}

View File

@@ -71,18 +71,18 @@ export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
}
/**
* The todo row as a plain registrant plugin, riding the same load-order seam
* as the bash sample: `inject: ['conversation']` guarantees the chat entry
* (and with it the 'conversation.chat.toolview' declaration) is on the ledger.
* The todo row as a plain registrant plugin following the chat toolview
* declaration across independent activation and reload lifetimes.
*/
export const todoToolview = {
name: 'todo-toolview',
inject: ['slots', 'conversation'],
inject: ['slots'],
/**
* Register the todo 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: 'todo_write', locale: NS }, TodoRow)
ctx.slots.inject('conversation.chat.toolview', () =>
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow))
},
}

View File

@@ -55,20 +55,20 @@ export function WebRow({ toolName, block, inspect, t }: WebRowProps) {
}
/**
* The web rows as a plain registrant plugin, riding the same load-order seam as
* the bash sample: `inject: ['conversation']` guarantees the chat entry (and
* with it the 'conversation.chat.toolview' declaration) is on the ledger. One
* WebRow component registers under both web tool names.
* The web rows follow the chat toolview declaration across activation and
* reload. One WebRow component registers under both web tool names.
*/
export const webToolview = {
name: 'web-toolview',
inject: ['slots', 'conversation'],
inject: ['slots'],
/**
* Register the web row under both web tool names' keyed toolview holes.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search', locale: NS }, WebRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch', locale: NS }, WebRow)
ctx.slots.inject('conversation.chat.toolview', function* () {
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search', locale: NS }, WebRow)
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch', locale: NS }, WebRow)
})
},
}

View File

@@ -124,11 +124,13 @@ describe('AskQuestionRow', () => {
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
})
it('askQuestionToolview is a plain registrant riding the conversation load-order seam', () => {
it('askQuestionToolview injects the toolview declaration directly', () => {
expect(askQuestionToolview.name).toBe('ask-question-toolview')
expect(askQuestionToolview.inject).toEqual(['slots', 'conversation'])
const register = vi.fn()
askQuestionToolview.apply({ slots: { register } } as never)
expect(askQuestionToolview.inject).toEqual(['slots'])
const register = vi.fn(() => () => undefined)
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
askQuestionToolview.apply({ slots: { inject, register } } as never)
expect(inject).toHaveBeenCalledWith('conversation.chat.toolview', expect.any(Function))
expect(register).toHaveBeenCalledWith(
{ name: 'conversation.chat.toolview', key: 'ask_user_question', locale: 'conversation' },
AskQuestionRow,

View File

@@ -3,8 +3,8 @@
// as the first 'conversation.view' ring entry declaring the keyed toolview
// hole, the slot registrations land against a root entry's children
// declarations (the AppFrame role), the shared store handle rides all strict
// session entries, and the bash sample + todo row mount through the
// load-order seam as keyed entries. Full-chain rendering belongs to the
// session entries, and the bash sample + todo row mount through declaration
// injection as keyed entries. Full-chain rendering belongs to the
// machinery spec (chat-toolview-slot.spec.tsx) and the shell e2e; this spec
// stops at the assembly surface.
@@ -90,10 +90,9 @@ describe('apply wiring', () => {
await b.runtime.dispose()
})
it('mounts the bash sample, the read row, the file-mutation rows, the search rows (grep + glob), the web rows, and the product rows as keyed entries through the load-order seam', async () => {
it('mounts the tool rows as keyed entries through declaration injection', async () => {
const b = await bench()
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first. The
// The actual toolview declaration activates every registrant. The
// file-mutation registrant claims both write and edit for the diff card; the
// one search row registers under both grep and glob; the web rows register
// one component under both web tool names.

View File

@@ -18,9 +18,18 @@ import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { zh } from '../src/client/locales.ts'
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.unstubAllGlobals()
})
// Mirrors the real lookup chain (conversation namespace, then common).
@@ -545,12 +554,13 @@ describe('small branch tails', () => {
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine
t={t}
useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']}
useProjection={(key: string) => key === 'tokenUsage'
? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 }
: undefined}
/>,
)
expect(view.container.textContent).toBe('1 turns · 1 steps| Input 0 tok · Output 10 tok')
expect(view.container.textContent).toBe('1 轮 · 1 步| 输入 0 tok · 输出 10 tok')
})
})

View File

@@ -3,25 +3,40 @@
// hard acceptance — zero renders during streaming. Bash sample row: ToolRow
// chrome (Bash · description) without a row click target.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
import { zh } from '../src/client/locales.ts'
import { en, zh } from '../src/client/locales.ts'
type BashRowProps = Parameters<typeof BashRow>[0]
// Mirrors the real lookup chain (conversation namespace, then common).
const t: BashRowProps['t'] = makeTranslate(zh, commonZh)
const tEn: StatsLineProps['t'] = makeTranslate(en, commonEn)
afterEach(cleanup)
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
vi.restoreAllMocks()
vi.useRealTimers()
})
const SID = 's1' as SessionId
@@ -66,8 +81,11 @@ describe('deriveStats', () => {
expect(stats.turns).toBe(2)
expect(stats.steps).toBe(3)
// Window-scoped by design: the paged window is not an accounting source, so
// the fold exposes no token fields at all (billing rides the projection).
expect(Object.keys(stats).sort()).toEqual(['llmMs', 'steps', 'toolMs', 'turns'])
// the fold exposes no billing fields (billing rides the projection);
// decodeTokens is a throughput input, not a billed total.
expect(Object.keys(stats).sort()).toEqual(
['decodeMs', 'decodeTokens', 'llmMs', 'steps', 'toolMs', 'ttftMs', 'ttftSteps', 'turns'],
)
})
it('ignores tool results with no call time', () => {
@@ -97,6 +115,23 @@ describe('deriveStats', () => {
expect(stats.llmMs).toBe(2_500)
expect(stats.toolMs).toBe(3_000)
})
it('sums ttft per recorded step and decode throughput inputs per usage-carrying step', () => {
const sampled: AssistantMessageNode = {
...assistant(1, 1, { outputTokens: 40 }),
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
}
const ttftOnly: AssistantMessageNode = {
...assistant(2, 1),
timing: { stepStartTime: 5_000, firstTokenTime: 5_400, completedTime: 7_400 },
}
const stats = deriveStats([sampled, ttftOnly, assistant(3, 2)])
expect(stats.ttftMs).toBe(1_200)
expect(stats.ttftSteps).toBe(2)
// The usage-less step contributes no decode share, keeping the ratio honest.
expect(stats.decodeMs).toBe(3_000)
expect(stats.decodeTokens).toBe(40)
})
})
describe('formatters', () => {
@@ -125,7 +160,7 @@ describe('StatsLine', () => {
source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void },
values: Record<string, unknown> = { tokenUsage: USAGE },
): StatsLineProps {
return { useSession: bindSnapshotSelector(source), useProjection: projections(values) }
return { useSession: bindSnapshotSelector(source), useProjection: projections(values), t: tEn }
}
it('renders the grouped stats row and hides a brand-new empty session', () => {
@@ -142,47 +177,84 @@ describe('StatsLine', () => {
expect(emptyView.container.textContent).toBe('')
})
it('keeps durable token and context groups after the visible step window is empty', () => {
it('reveals the full line in a delayed hover tooltip only while the row is clipped', () => {
vi.useFakeTimers()
// jsdom lays nothing out; fake a row narrower than its content.
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(800)
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(400)
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source)} />)
fireEvent.mouseEnter(view.container.firstElementChild!)
act(() => { vi.advanceTimersByTime(499) })
expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
act(() => { vi.advanceTimersByTime(1) })
expect(view.container.querySelector('[role="tooltip"]')?.textContent)
.toBe('1 turns · 1 steps | Cache hit 90% | Input 100 tok · Output 5 tok')
})
it('suppresses the tooltip while the row fits without truncation', () => {
vi.useFakeTimers()
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source)} />)
fireEvent.mouseEnter(view.container.firstElementChild!)
act(() => { vi.advanceTimersByTime(500) })
expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
})
it('renders window latency and throughput beside the wall-time group', () => {
const timed: AssistantMessageNode = {
...assistant(1, 1, { outputTokens: 60 }),
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
}
const { source } = makeSource({ nodes: [timed] })
const view = render(<StatsLine {...props(source)} />)
expect(view.container.textContent).toContain('LLM 3.8s| TTFT avg 0.8s · 20 tok/s')
})
it('takes every stats label from the active locale', () => {
const timed: AssistantMessageNode = {
...assistant(1, 1, { outputTokens: 60 }),
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
}
const { source } = makeSource({ nodes: [timed] })
const view = render(<StatsLine {...props(source)} t={t} />)
expect(view.container.textContent)
.toBe('1 轮 · 1 步| LLM 3.8s| 首 token 平均 0.8s · 20 tok/s| 缓存命中 90%| 输入 100 tok · 输出 5 tok')
})
it('renders without ResizeObserver support', () => {
vi.unstubAllGlobals()
const { source } = makeSource({ nodes: [assistant(1, 1)] })
expect(() => render(<StatsLine {...props(source)} />)).not.toThrow()
})
it('keeps durable token groups after the visible step window is empty', () => {
const { source } = makeSource()
const view = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
})} />)
// Context occupancy lives on the composer's ContextMeter ring, not here.
expect(view.container.textContent)
.toBe('Context 25% of 128K| Cache hit 90%| Input 100 tok · Output 5 tok')
.toBe('Cache hit 90%| Input 100 tok · Output 5 tok')
})
it('renders context occupancy only when the projection knows a capacity', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const withCapacity = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
})} />)
expect(withCapacity.container.textContent).toContain('Context 25% of 128K')
// Pressure without capacity has no denominator: the group drops out.
const noCapacity = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 32_000 },
})} />)
expect(noCapacity.container.textContent).not.toContain('Context')
// Capacity arrives before usage in the log; no provider sample means there
// is no numerator yet, rather than a synthetic 0%.
const noPressure = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { contextWindow: 128_000 },
})} />)
expect(noPressure.container.textContent).not.toContain('Context')
})
it('clamps occupancy at 100% when pressure exceeds the recorded capacity', () => {
// Capacity and pressure are independent last-wins fields, so a model switch
// can pair a smaller new window with the previous route's larger prompt.
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 300_000, contextWindow: 128_000 },
})} />)
expect(view.container.textContent).toContain('Context 100% of 128K')
it('computes context occupancy only when both a numerator and capacity are known', () => {
// The projected figure wins: it is the provider sample carried forward over
// the surface's movement, so a compaction shows without waiting a request.
expect(contextOccupancy({ pressureTokens: 32_000, projectedTokens: 6_000, contextWindow: 128_000 }))
.toEqual({ percent: 5, usedTokens: 6_000, contextWindow: 128_000 })
// A log whose projection predates the field still reads its bare sample.
expect(contextOccupancy({ pressureTokens: 32_000, contextWindow: 128_000 }))
.toEqual({ percent: 25, usedTokens: 32_000, contextWindow: 128_000 })
// A numerator without capacity has no denominator; capacity without a
// provider sample has no numerator yet, rather than a synthetic 0%.
expect(contextOccupancy({ pressureTokens: 32_000 })).toBeNull()
expect(contextOccupancy({ contextWindow: 128_000 })).toBeNull()
expect(contextOccupancy(undefined)).toBeNull()
// Capacity and the sample are independent last-wins fields, so a model
// switch can pair a smaller new window with the previous route's prompt.
expect(contextOccupancy({ pressureTokens: 300_000, contextWindow: 128_000 })?.percent).toBe(100)
})
it('drops every token group when no projection is composed', () => {

View File

@@ -6,9 +6,8 @@
// 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.
// (sessionId) => I into row components, and a registrant can activate before
// the declaration then land through slots.inject when the chat entry appears.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent } from '@testing-library/react'
@@ -191,8 +190,8 @@ describe('keyed toolview hole through the real machinery', () => {
})
})
describe('registrant load-order seam', () => {
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
describe('registrant declaration injection', () => {
it('runs the plugin before ui-conversation and waits on the actual toolview declaration', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
@@ -200,31 +199,26 @@ describe('registrant load-order seam', () => {
runtime.slots.installLocale(locale)
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
// semantics hold it — apply must not run while 'conversation' is absent.
// Uses ctx.plugin directly (the deliberate-suspension escape hatch; mount()
// would fail loud on the missing service). (Plain arrow, not vi.fn: mock
// functions carry a prototype and trip the fiber's isConstructor branch.)
// Third-party posture, mounted BEFORE ui-conversation. Plugin apply runs,
// while slots.inject waits for the declaration itself.
let applyRuns = 0
const registrantApply = (registrantCtx: typeof runtime.ctx): void => {
applyRuns += 1
registrantCtx.slots.register(
{ name: 'conversation.chat.toolview', key: 'late' }, () => null)
registrantCtx.slots.inject('conversation.chat.toolview', () => registrantCtx.slots.register(
{ name: 'conversation.chat.toolview', key: 'late' }, () => null))
}
const late = runtime.ctx.plugin({
name: 'late-registrant',
inject: ['slots', 'conversation'],
inject: ['slots'],
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.
await runtime.mount({ inject: [...inject], apply })
await late.await()
expect(applyRuns).toBe(1)
expect(runtime.slots.entries('conversation.chat.toolview')).toHaveLength(0)
// Mounting the package declares the slot and activates the waiting entry.
await runtime.mount({ inject: [...inject], apply })
expect(runtime.slots.entries('conversation.chat.toolview').map(e => e.options.key))
.toEqual(expect.arrayContaining(['bash', 'late']))
await runtime.dispose()

View File

@@ -534,6 +534,45 @@ describe('ChatView', () => {
expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
})
it('the settled footer appends first-step ttft and turn decode throughput', () => {
const first: AssistantMessageNode = {
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'mid' }],
timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
usage: { outputTokens: 40 },
}
const second: AssistantMessageNode = {
kind: 'assistant', seq: 16, time: 16_000, turn: 1, step: 2, blocks: [{ kind: 'text', text: 'final' }],
timing: { stepStartTime: 10_000, firstTokenTime: 10_200, completedTime: 12_200 },
usage: { outputTokens: 60 },
}
const h = makeHarness({
nodes: [user(1, 'hi'), first, second],
turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
turnEnds: new Map([[1, 20]]),
})
const view = render(<h.ChatView {...h.props} />)
// First-step ttft (1.2s) plus 100 tokens over 5s of decode.
expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
expect(view.getAllByText(/首 token 1\.2秒/)).toHaveLength(1)
expect(view.getAllByText(/20 tok\/s/)).toHaveLength(1)
})
it('withholds ttft and throughput while the turn is still running', () => {
const settled: AssistantMessageNode = {
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'answer' }],
timing: { stepStartTime: 1_000, firstTokenTime: 1_500, completedTime: 2_000 },
usage: { outputTokens: 10 },
}
const h = makeHarness({
nodes: [user(1, 'hi'), settled],
turnTimings: new Map([[1, { startTime: 1_000 }]]),
turnEnds: new Map(),
running: true,
})
const view = render(<h.ChatView {...h.props} />)
expect(view.queryByText(/首 token|tok\/s/)).toBeNull()
})
it('user and assistant message containers scope the hover-revealed time chrome', () => {
const h = makeHarness({
nodes: [user(1, 'hi'), assistant(2, 'answer')],

View File

@@ -0,0 +1,158 @@
// @vitest-environment jsdom
// ContextMeter (composer trailing control): occupancy ring gating, the
// click-open breakdown panel, and its close gestures.
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn, zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/index.ts'
import { ContextMeter, type ContextMeterProps } from '../src/client/skeleton/ContextMeter.tsx'
import css from '../src/client/skeleton/ContextMeter.module.css'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
// Mirrors the real lookup chain (conversation namespace, then common).
const t = makeTranslate(zh, commonZh) as ContextMeterProps['t']
const tEn = makeTranslate(en, commonEn) as ContextMeterProps['t']
const BREAKDOWN = { systemTokens: 120, toolsTokens: 21_500, messageTokens: 477_000 }
const segmentClass = css.segment
if (segmentClass === undefined) throw new Error('segment class missing from ContextMeter.module.css')
/** Stub the projection seat: a key-addressed table of whole values. */
function projections(values: Record<string, unknown>): ContextMeterProps['useProjection'] {
return (key: string) => values[key]
}
function meter(values: Record<string, unknown>, translate: ContextMeterProps['t'] = t) {
return render(<ContextMeter useProjection={projections(values)} t={translate} />)
}
describe('ContextMeter', () => {
it('renders nothing until both pressure and capacity are known', () => {
expect(meter({}).container.textContent).toBe('')
expect(meter({ contextPressure: { pressureTokens: 32_000 } }).container.textContent).toBe('')
expect(meter({ contextPressure: { contextWindow: 128_000 } }).container.textContent).toBe('')
})
it('shows the occupancy ring and opens the breakdown panel on click', () => {
const view = meter({
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
contextBreakdown: BREAKDOWN,
})
const trigger = view.getByRole('button', { name: '上下文已用 25%' })
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
fireEvent.click(trigger)
const panel = view.container.querySelector('[role="dialog"]')!
expect(panel.textContent).toContain('~32K / 128K')
expect(panel.textContent).toContain('25%')
expect(panel.textContent).toContain('上下文已用')
expect(panel.textContent).toContain('系统提示词~120')
expect(panel.textContent).toContain('工具~21.5K')
expect(panel.textContent).toContain('对话消息~477K')
// The occupancy bar splits into one colored segment per composition row.
expect(panel.getElementsByClassName(segmentClass)).toHaveLength(3)
// Clicking the trigger again toggles the panel shut.
fireEvent.click(trigger)
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
})
it('lets each locale own the headline word order around the reading', () => {
const values = {
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
contextBreakdown: BREAKDOWN,
}
const zhView = meter(values)
fireEvent.click(zhView.getByRole('button', { name: '上下文已用 25%' }))
// The reading follows the label in Chinese and leads it in English; both
// headers read as one sentence rather than a concatenated fragment.
expect(zhView.container.querySelector('[role="dialog"]')!.textContent)
.toMatch(/^上下文已用25%/)
const enView = meter(values, tEn)
fireEvent.click(enView.getByRole('button', { name: '25% of context used' }))
expect(enView.container.querySelector('[role="dialog"]')!.textContent)
.toMatch(/^25%of context used/)
})
it('draws no bar segment at zero occupancy', () => {
const view = meter({
contextPressure: { pressureTokens: 0, contextWindow: 128_000 },
contextBreakdown: BREAKDOWN,
})
fireEvent.click(view.getByRole('button', { name: '上下文已用 0%' }))
const panel = view.container.querySelector('[role="dialog"]')!
// `.segment` carries a min-width, so a zero-width part would still paint a
// filled sliver over an empty context.
expect(panel.getElementsByClassName(segmentClass)).toHaveLength(0)
expect(panel.textContent).toContain('~0 / 128K')
})
it('reads the ring from the projected figure so a compaction shows at once', () => {
// Same provider sample, a surface a compaction just shrank: the ring must
// follow the projection rather than the sample it is anchored to.
const view = meter({
contextPressure: { pressureTokens: 32_000, projectedTokens: 3_000, contextWindow: 128_000 },
contextBreakdown: BREAKDOWN,
})
const trigger = view.getByRole('button', { name: '上下文已用 2%' })
fireEvent.click(trigger)
expect(view.container.querySelector('[role="dialog"]')!.textContent).toContain('~3K / 128K')
})
it('omits the composition rows while the contextBreakdown projection is absent', () => {
const view = meter({ contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 } })
fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
const panel = view.container.querySelector('[role="dialog"]')!
expect(panel.textContent).toContain('~32K / 128K')
expect(panel.textContent).not.toContain('系统提示词')
expect(panel.textContent).not.toContain('对话消息')
// Without composition shares, the bar falls back to one plain segment.
expect(panel.getElementsByClassName(segmentClass)).toHaveLength(1)
})
it('closes when capacity disappears and stays closed when it returns', () => {
let values: Record<string, unknown> = {
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
contextBreakdown: BREAKDOWN,
}
const view = render(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
expect(view.container.querySelector('[role="dialog"]')).not.toBeNull()
values = { contextPressure: { pressureTokens: 32_000 }, contextBreakdown: BREAKDOWN }
view.rerender(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
expect(view.container.textContent).toBe('')
values = {
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
contextBreakdown: BREAKDOWN,
}
view.rerender(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
expect(view.getByRole('button', { name: '上下文已用 25%' }).getAttribute('aria-expanded')).toBe('false')
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
})
it('closes on outside pointerdown and Escape — but not inside clicks', () => {
const view = meter({
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
contextBreakdown: BREAKDOWN,
})
const trigger = view.getByRole('button', { name: '上下文已用 25%' })
const openPanel = () => {
fireEvent.click(trigger)
return view.container.querySelector('[role="dialog"]')!
}
// A pointerdown inside the panel keeps it open; outside closes it.
const again = openPanel()
fireEvent.pointerDown(again)
expect(view.container.querySelector('[role="dialog"]')).not.toBeNull()
fireEvent.pointerDown(document.body)
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
// Escape.
openPanel()
fireEvent.keyDown(document, { key: 'Escape' })
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
})
})

View File

@@ -274,8 +274,14 @@ describe('fileMutationToolview registration', () => {
it('registers one component under both edit and write, and each disposes', () => {
const registered: { key: string; locale: unknown; disposed: boolean }[] = []
const disposers: (() => void)[] = []
let disposeInjection = (): void => {}
const ctx = {
slots: {
inject: (_name: string, callback: () => Iterable<() => void>) => {
const active = [...callback()]
disposeInjection = () => { for (const dispose of active.reverse()) dispose() }
return disposeInjection
},
register: ({ key, locale }: { name: string; key: string; locale?: string }) => {
const entry = { key, locale, disposed: false }
registered.push(entry)
@@ -289,10 +295,9 @@ describe('fileMutationToolview registration', () => {
expect(registered.map(r => r.key).sort()).toEqual(['edit', 'write'])
// Both keys claim the conversation locale seat ToolRow's body copy needs.
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
// The registrant's inject seam is the load-order contract the row relies on.
expect(fileMutationToolview.inject).toEqual(['slots', 'conversation'])
expect(fileMutationToolview.inject).toEqual(['slots'])
// Disposal removes each contribution (packages/AGENTS.md registry contract).
for (const dispose of disposers) dispose()
disposeInjection()
expect(registered.every(r => r.disposed)).toBe(true)
})
})

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
@@ -18,7 +18,18 @@ import { zh } from '../src/client/locales.ts'
// Mirrors the real lookup chain (conversation namespace, then common).
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
afterEach(cleanup)
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
const SID = 's1' as SessionId
@@ -56,11 +67,12 @@ describe('render branch tails', () => {
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine
t={t}
useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
useProjection={() => undefined}
/>,
)
expect(view.container.textContent).toBe('2 turns · 3 steps')
expect(view.container.textContent).toBe('2 轮 · 3 步')
})
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {

View File

@@ -375,8 +375,10 @@ describe('QueueDock', () => {
it('registers as the terminal composer-context entry', () => {
expect(queueDockEntry.name).toBe('conversation-queue-dock')
expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions'])
const register = vi.fn()
queueDockEntry.apply({ slots: { register } } as never)
const register = vi.fn(() => () => undefined)
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
queueDockEntry.apply({ slots: { inject, register } } as never)
expect(inject).toHaveBeenCalledWith('conversation.input.dock', expect.any(Function))
expect(register).toHaveBeenCalledWith(
expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }),
QueueDock,

View File

@@ -237,11 +237,14 @@ describe('ReadRow keyed toolview', () => {
it('registers under the read key of the keyed toolview slot', () => {
const registered: { name: unknown; key?: unknown }[] = []
const ctx = { slots: { register: (options: { name: unknown; key?: unknown }) => { registered.push(options) } } } as unknown as Context
const ctx = { slots: {
inject: (_name: string, callback: () => () => void) => callback(),
register: (options: { name: unknown; key?: unknown }) => { registered.push(options); return () => undefined },
} } as unknown as Context
readToolview.apply(ctx)
// The row composes ToolRow, so it declares its locale namespace at the seat.
expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read', locale: 'conversation' }])
expect(readToolview.inject).toContain('conversation')
expect(readToolview.inject).toEqual(['slots'])
})
})

View File

@@ -349,8 +349,13 @@ describe('SearchRow keyed card', () => {
const registered: { key: unknown; locale: unknown; component: unknown }[] = []
const ctx = {
slots: {
inject: (_name: string, callback: () => Iterable<() => void>) => {
for (const _dispose of callback()) { /* exhaust transactional setup */ }
return () => undefined
},
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
registered.push({ key: options.key, locale: options.locale, component })
return () => undefined
},
},
} as never
@@ -361,7 +366,7 @@ describe('SearchRow keyed card', () => {
// One component, two keys.
expect(registered[0]!.component).toBe(SearchRow)
expect(registered[1]!.component).toBe(SearchRow)
expect(searchToolview.inject).toEqual(['slots', 'conversation'])
expect(searchToolview.inject).toEqual(['slots'])
})
})

View File

@@ -111,9 +111,11 @@ describe('TodoDock', () => {
it('registers before the goal and queue entries', () => {
expect(todoDockEntry.name).toBe('conversation-todo-dock')
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
const register = vi.fn()
todoDockEntry.apply({ slots: { register } } as never)
expect(todoDockEntry.inject).toEqual(['slots'])
const register = vi.fn(() => () => undefined)
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
todoDockEntry.apply({ slots: { inject, register } } as never)
expect(inject).toHaveBeenCalledWith('conversation.input.dock', expect.any(Function))
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
})
})
@@ -196,11 +198,13 @@ describe('TodoRow', () => {
expect(screen.getByText('todo_write · c1')).toBeTruthy()
})
it('todoToolview is a plain registrant riding the conversation load-order seam', () => {
it('todoToolview injects the toolview declaration directly', () => {
expect(todoToolview.name).toBe('todo-toolview')
expect(todoToolview.inject).toEqual(['slots', 'conversation'])
const register = vi.fn()
todoToolview.apply({ slots: { register } } as never)
expect(todoToolview.inject).toEqual(['slots'])
const register = vi.fn(() => () => undefined)
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
todoToolview.apply({ slots: { inject, register } } as never)
expect(inject).toHaveBeenCalledWith('conversation.chat.toolview', expect.any(Function))
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow)
})
})

View File

@@ -0,0 +1,154 @@
// Per-turn latency/throughput fold and the footer figure formatters.
import { describe, expect, it } from 'vitest'
import type { AssistantMessageNode, ConversationNode, UserMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
import { assistantStepReading, deriveTurnMetrics } from '../src/client/chat/turn-metrics.ts'
import { formatLatencySeconds, formatTokensPerSecond } from '../src/client/chat/message-chrome.ts'
interface StepSpec {
seq: number
turn: number
step: number
timing?: AssistantMessageNode['timing']
usage?: unknown
}
const assistant = ({ seq, turn, step, timing, usage }: StepSpec): AssistantMessageNode => ({
kind: 'assistant', seq, time: seq * 1_000, turn, step, blocks: [{ kind: 'text', text: `t${seq}` }],
...(timing === undefined ? {} : { timing }),
...(usage === undefined ? {} : { usage }),
})
const user = (seq: number): UserMessageNode => ({
kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text: 'hi' }] as never, source: null,
})
describe('assistantStepReading', () => {
it('derives ttft, decode time, and output tokens from a fully recorded step', () => {
const reading = assistantStepReading(assistant({
seq: 2, turn: 1, step: 1,
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 6_800 },
usage: { outputTokens: 200 },
}))
expect(reading).toEqual({ ttftMs: 800, decodeMs: 5_000, outputTokens: 200 })
})
it('returns nulls when timing is absent', () => {
const reading = assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, usage: { outputTokens: 5 } }))
expect(reading).toEqual({ ttftMs: null, decodeMs: null, outputTokens: 5 })
})
it('needs both boundaries for ttft and clamps negative spans to zero', () => {
expect(assistantStepReading(assistant({
seq: 2, turn: 1, step: 1,
timing: { stepStartTime: null, firstTokenTime: 1_800, completedTime: 6_800 },
}))).toEqual({ ttftMs: null, decodeMs: 5_000, outputTokens: null })
expect(assistantStepReading(assistant({
seq: 2, turn: 1, step: 1,
timing: { stepStartTime: 1_000, firstTokenTime: null, completedTime: 6_800 },
}))).toEqual({ ttftMs: null, decodeMs: null, outputTokens: null })
expect(assistantStepReading(assistant({
seq: 2, turn: 1, step: 1,
timing: { stepStartTime: 2_000, firstTokenTime: 1_500, completedTime: 1_200 },
}))).toEqual({ ttftMs: 0, decodeMs: 0, outputTokens: null })
})
it('rejects non-object, missing, and non-finite usage token counts', () => {
const timing = { stepStartTime: 1_000, firstTokenTime: 1_500, completedTime: 2_000 }
expect(assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, timing, usage: 'weird' })).outputTokens).toBeNull()
expect(assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, timing, usage: {} })).outputTokens).toBeNull()
const nan = assistant({ seq: 2, turn: 1, step: 1, timing, usage: { outputTokens: Number.NaN } })
expect(assistantStepReading(nan).outputTokens).toBeNull()
expect(assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, timing, usage: { outputTokens: -3 } })).outputTokens).toBeNull()
})
})
describe('deriveTurnMetrics', () => {
it('takes ttft from the lowest step and throughput over all sampled steps', () => {
const nodes: ConversationNode[] = [
user(1),
// Out of step order on purpose: the lowest step owns the ttft slot.
assistant({
seq: 4, turn: 1, step: 2,
timing: { stepStartTime: 10_000, firstTokenTime: 10_200, completedTime: 12_200 },
usage: { outputTokens: 60 },
}),
assistant({
seq: 2, turn: 1, step: 1,
timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
usage: { outputTokens: 40 },
}),
]
// 100 tokens over 5s of decode.
expect(deriveTurnMetrics(nodes).get(1)).toEqual({ ttftMs: 1_200, tokensPerSecond: 20 })
})
it('emits ttft without throughput when no step carries usage', () => {
const nodes = [assistant({
seq: 2, turn: 1, step: 1,
timing: { stepStartTime: 1_000, firstTokenTime: 1_900, completedTime: 3_000 },
})]
expect(deriveTurnMetrics(nodes).get(1)).toEqual({ ttftMs: 900 })
})
it('emits throughput without ttft when only a later step is recorded', () => {
const nodes = [
assistant({ seq: 2, turn: 1, step: 1 }),
assistant({
seq: 4, turn: 1, step: 2,
timing: { stepStartTime: 10_000, firstTokenTime: 10_500, completedTime: 12_500 },
usage: { outputTokens: 30 },
}),
]
expect(deriveTurnMetrics(nodes).get(1)).toEqual({ tokensPerSecond: 15 })
})
it('omits turns with no readings and zero-decode throughput', () => {
const nodes = [
assistant({ seq: 2, turn: 1, step: 1 }),
assistant({
seq: 4, turn: 2, step: 1,
timing: { stepStartTime: null, firstTokenTime: 5_000, completedTime: 5_000 },
usage: { outputTokens: 10 },
}),
]
expect(deriveTurnMetrics(nodes).size).toBe(0)
})
it('keeps turns independent and ignores non-assistant nodes', () => {
const nodes: ConversationNode[] = [
user(1),
assistant({
seq: 2, turn: 1, step: 1,
timing: { stepStartTime: 1_000, firstTokenTime: 1_400, completedTime: 2_400 },
usage: { outputTokens: 10 },
}),
user(3),
assistant({
seq: 4, turn: 2, step: 1,
timing: { stepStartTime: 4_000, firstTokenTime: 4_100, completedTime: 6_100 },
usage: { outputTokens: 100 },
}),
]
const metrics = deriveTurnMetrics(nodes)
expect(metrics.get(1)).toEqual({ ttftMs: 400, tokensPerSecond: 10 })
expect(metrics.get(2)).toEqual({ ttftMs: 100, tokensPerSecond: 50 })
})
})
describe('footer figure formatters', () => {
it('formats latency with one decimal under ten seconds and whole seconds beyond', () => {
expect(formatLatencySeconds(840)).toBe('0.8')
expect(formatLatencySeconds(1_000)).toBe('1')
expect(formatLatencySeconds(9_949)).toBe('9.9')
expect(formatLatencySeconds(12_400)).toBe('12')
expect(formatLatencySeconds(-5)).toBe('0')
})
it('formats throughput with whole tokens from ten up and one decimal below', () => {
expect(formatTokensPerSecond(34.4)).toBe('34')
expect(formatTokensPerSecond(9.96)).toBe('10')
expect(formatTokensPerSecond(3.14)).toBe('3.1')
expect(formatTokensPerSecond(-1)).toBe('0')
})
})

View File

@@ -272,6 +272,10 @@ describe('web toolview registration', () => {
const registered: { key: string; locale: unknown; component: unknown }[] = []
const ctx = {
slots: {
inject: (_name: string, callback: () => Iterable<() => void>) => {
for (const _dispose of callback()) { /* exhaust transactional setup */ }
return () => undefined
},
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
registered.push({ key: options.key, locale: options.locale, component })
return () => {}
@@ -285,7 +289,6 @@ describe('web toolview registration', () => {
// One component under both keys, not two thin rows.
expect(registered[0]?.component).toBe(WebRow)
expect(registered[1]?.component).toBe(WebRow)
// The load-order seam the render site depends on.
expect(webToolview.inject).toEqual(['slots', 'conversation'])
expect(webToolview.inject).toEqual(['slots'])
})
})

View File

@@ -53,52 +53,47 @@ export function apply(ctx: ClientContext): void {
const { goals } = (ctx.get('connection') as ConnectionHandle).api
// Conditional mount: 'conversation.input.dock' is declared by the
// conversation entry; the conversation service being up is the
// registration-safe signal (the TodoDock/QueueDock seam).
ctx.inject(['slots', 'conversation', 'sessions'], (scope: ClientContext) => {
const sessions = scope.sessions
const sessions = ctx.sessions
/** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */
const refOf = (sessionId: SessionId): GoalRef | undefined => {
const face = sessions.binding(sessionId)?.session.projections.faceOf('goal')
const projection = face?.getSnapshot() as GoalProjection | null | undefined
if (projection == null) return undefined
return { id: projection.goal.id, revision: projection.goal.revision }
}
/** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */
const refOf = (sessionId: SessionId): GoalRef | undefined => {
const face = sessions.binding(sessionId)?.session.projections.faceOf('goal')
const projection = face?.getSnapshot() as GoalProjection | null | undefined
if (projection == null) return undefined
return { id: projection.goal.id, revision: projection.goal.revision }
}
const noCurrentGoal: GoalActionResult = {
ok: false,
error: { code: 'no-current-goal', message: 'no current goal to mutate' },
}
const noCurrentGoal: GoalActionResult = {
ok: false,
error: { code: 'no-current-goal', message: 'no current goal to mutate' },
}
scope.effect(() => scope.slots.register({
name: 'conversation.input.dock',
id: 'goal',
order: 10,
locale: NS,
inject: (sessionId): GoalBarActions => ({
onEdit: async (objective) => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.edit({ sessionId, ref, objective })).result)
},
onPause: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.pause({ sessionId, ref })).result)
},
onResume: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.resume({ sessionId, ref })).result)
},
onClear: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.clear({ sessionId, ref })).result)
},
}),
}, GoalDock), 'ui-goal: GoalBar dock registration')
})
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
name: 'conversation.input.dock',
id: 'goal',
order: 10,
locale: NS,
inject: (sessionId): GoalBarActions => ({
onEdit: async (objective) => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.edit({ sessionId, ref, objective })).result)
},
onPause: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.pause({ sessionId, ref })).result)
},
onResume: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.resume({ sessionId, ref })).result)
},
onClear: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.clear({ sessionId, ref })).result)
},
}),
}, GoalDock))
}

View File

@@ -14,7 +14,7 @@ import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { afterEach } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { GoalProjection } from '@deepseek-ai/dsh-goal/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -45,7 +45,7 @@ function makeProjection(revision = 3): GoalProjection {
}
/** Boot the plugin over fake faces; goals verbs record payloads and answer per the script. */
function bench(options: { projection?: GoalProjection | null | undefined; failWith?: { code: string; message: string } } = {}) {
async function bench(options: { projection?: GoalProjection | null | undefined; failWith?: { code: string; message: string } } = {}) {
const ctx = new Context()
const calls: { method: string; payload: unknown }[] = []
function answer<T>(method: string, value: T) {
@@ -65,14 +65,10 @@ function bench(options: { projection?: GoalProjection | null | undefined; failWi
resume: answer('goal.resume', { ref }),
clear: answer('goal.clear', { cleared: true as const }),
} } })
const entries = new Map<string, { id?: string; order?: number; locale?: string; inject?: (sessionId: SessionId) => GoalBarActions }>()
ctx.provide('slots', {
register(reg: { name: string; id?: string; order?: number; locale?: string; inject?: (sessionId: SessionId) => GoalBarActions }) {
entries.set(reg.name, reg)
return () => { entries.delete(reg.name) }
},
})
ctx.provide('conversation', {})
await ctx.plugin(SlotsService).await()
ctx.slots.register({
name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } },
} as never, (() => null) as never)
ctx.provide('locale', new LocaleService(ctx))
ctx.provide('sessions', {
binding: (id: SessionId) => ({
@@ -89,20 +85,28 @@ function bench(options: { projection?: GoalProjection | null | undefined; failWi
ctx,
fiber,
calls,
entry: () => entries.get('conversation.input.dock'),
entry: () => {
const entry = ctx.slots.entries('conversation.input.dock')[0]
if (entry === undefined) return undefined
return {
...entry.options,
locale: entry.locale,
inject: entry.inject as unknown as ((sessionId: SessionId) => GoalBarActions) | undefined,
}
},
}
}
describe('ui-goal browser plugin', () => {
it('registers the GoalBar dock entry with the documented id and order', async () => {
const b = bench()
const b = await bench()
await b.fiber.await()
expect(b.entry()).toMatchObject({ id: 'goal', order: 10, locale: 'goal' })
expect(b.entry()?.inject).toBeTypeOf('function')
})
it('verbs read the CAS ref from the current projected value at call time', async () => {
const b = bench({ projection: makeProjection(5) })
const b = await bench({ projection: makeProjection(5) })
await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1'))
expect(await verbs.onEdit('New objective')).toEqual({ ok: true })
@@ -119,7 +123,7 @@ describe('ui-goal browser plugin', () => {
it('a null or absent projection short-circuits every verb without touching the wire', async () => {
for (const projection of [null, undefined]) {
const b = bench({ projection })
const b = await bench({ projection })
await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1'))
for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) {
@@ -130,14 +134,14 @@ describe('ui-goal browser plugin', () => {
})
it('maps a settled RPC error onto the inline-render shape', async () => {
const b = bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } })
const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } })
await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1'))
expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision' } })
})
it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => {
const b = bench()
const b = await bench()
await b.fiber.await()
expect(b.entry()).toBeDefined()
await b.fiber.dispose()

View File

@@ -148,12 +148,10 @@ export function apply(ctx: ClientContext): void {
})
// Entry 2: the composer's named model seat over the SAME directory.
// Conditional mount: the seat is declared by the composer-bar entry; the
// conversation service's presence is the registration-safe signal.
ctx.inject(['slots', 'conversation', 'models'], (scope: ClientContext) => {
ctx.inject(['slots', 'models'], (scope: ClientContext) => {
const models = scope.models
const sessions = scope.sessions
scope.effect(() => scope.slots.register({
scope.slots.inject('conversation.input.model', () => scope.slots.register({
name: 'conversation.input.model',
locale: NS,
inject: (sessionId): ModelSelectInjected => {
@@ -170,6 +168,6 @@ export function apply(ctx: ClientContext): void {
: Promise.resolve(false),
}
},
}, ModelSelect), 'ui-model: composer model seat registration')
}, ModelSelect))
})
}

View File

@@ -85,12 +85,12 @@ async function bench() {
locale: string | undefined
}>()
ctx.provide('slots', {
inject(_name: string, callback: () => () => void) { return callback() },
register(options: { name: string; locale?: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) {
seats.set(options.name, { inject: options.inject, locale: options.locale })
return () => { seats.delete(options.name) }
},
})
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
const scopes = new Map<SessionId, Context>()
const addressed = new Set<SessionId>()

View File

@@ -6,7 +6,6 @@
* packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry).
@@ -47,7 +46,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void {
/**
* Required services (cordis fiber inject). The target slot is declared by
* ui-settings' apply, whose activation order relative to this one is NOT
* constrained; registration goes through declaration-aware deferral.
* constrained; registration depends on each slot through `slots.inject()`.
*/
export const inject = ['slots', 'locale', 'connection']
@@ -91,29 +90,17 @@ export function apply(ctx: ClientContext): void {
return () => { for (const dispose of disposers) dispose() }
}, 'ui-models: pushed invalidations')
ctx.effect(() => {
const section = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () =>
ctx.slots.register({
name: 'settings.section',
id: 'models',
order: 10,
label: () => t('nav'),
inject: injected,
}, ModelsSection))
const onboarding = deferRegistration(
ctx.slots,
'settings.onboarding',
DeepSeekOnboardingDialog,
() => ctx.slots.register({
name: 'settings.onboarding',
id: 'deepseek-official',
order: 0,
inject: onboardingInjected,
}, DeepSeekOnboardingDialog),
)
return () => {
section.dispose()
onboarding.dispose()
}
}, 'ui-models: settings registrations')
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'models',
order: 10,
label: () => t('nav'),
inject: injected,
}, ModelsSection))
ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
name: 'settings.onboarding',
id: 'deepseek-official',
order: 0,
inject: onboardingInjected,
}, DeepSeekOnboardingDialog))
}

View File

@@ -1,4 +1,4 @@
/** Models section registration: declaration-aware deferral, the locale-following label thunk, and HMR recovery. */
/** Models section registration: slot declaration injection, the locale-following label thunk, and HMR recovery. */
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'

View File

@@ -19,7 +19,6 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
import { PermissionRow } from './PermissionRow.tsx'
import type { PermissionRowInjected } from './PermissionRow.tsx'
@@ -133,17 +132,13 @@ export function apply(ctx: ClientContext): void {
}
}, 'ui-permission: settings invalidations')
ctx.effect(() => {
const row = deferRegistration(ctx.slots, 'settings.general.item', PermissionRow, () =>
ctx.slots.register({
name: 'settings.general.item',
id: 'permission',
order: -20,
locale: 'settings.permission',
inject: injected,
}, PermissionRow))
return () => { row.dispose() }
}, 'ui-permission: General settings row')
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',
id: 'permission',
order: -20,
locale: 'settings.permission',
inject: injected,
}, PermissionRow))
ctx.effect(() => command.decorate({
name: 'permission',

View File

@@ -39,12 +39,8 @@ export interface PlanChipInjected {
exitPlanMode: () => Promise<string | null>
}
/**
* Required services: the seat's slot registry, the transport, the copy's
* locale registry, and the conversation service whose presence guarantees
* the seat is declared.
*/
export const inject = ['slots', 'connection', 'conversation', 'locale']
/** Required services: the seat's slot registry, transport, and locale registry. */
export const inject = ['slots', 'connection', 'locale']
/**
* Client plugin body: register the plan chip over the command channel.
@@ -53,7 +49,7 @@ export const inject = ['slots', 'connection', 'conversation', 'locale']
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plan: dictionaries')
ctx.effect(() => ctx.slots.register({
ctx.slots.inject('conversation.input.plan', () => ctx.slots.register({
name: 'conversation.input.plan',
locale: NS,
inject: (sessionId: SessionId): PlanChipInjected => ({
@@ -66,5 +62,5 @@ export function apply(ctx: ClientContext): void {
return null
},
}),
}, PlanChip), 'ui-plan: composer plan chip registration')
}, PlanChip))
}

View File

@@ -28,28 +28,32 @@ async function bench() {
const execute = vi.fn((_payload: { sessionId: SessionId; line: string }) =>
Promise.resolve({ result: { ok: true as const, value: { matched: true as const, commandId: 'c1' } } }))
ctx.provide('connection', { api: { commands: { execute } } })
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
return { ctx, slots, execute }
}
describe('ui-plan browser apply', () => {
it('declares every service it binds', () => {
expect(inject).toEqual(['slots', 'connection', 'conversation', 'locale'])
expect(inject).toEqual(['slots', 'connection', 'locale'])
})
it('node-half apply is an intentional no-op', () => {
expect(() => { nodeApply() }).not.toThrow()
})
it('fails loud when conversation did not declare the plan seat', async () => {
it('waits until conversation declares the plan seat', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
ctx.provide('connection', {})
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
await expect(ctx.plugin({ inject: [...inject], apply }))
.rejects.toThrow(/slot "conversation.input.plan" is not declared/)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(ctx.slots.entries('conversation.input.plan')).toHaveLength(0)
ctx.slots.register({
name: 'root', children: { 'conversation.input.plan': { kind: 'single', scope: 'session' } },
} as never, () => null)
await Promise.resolve()
expect(ctx.slots.entries('conversation.input.plan')).toHaveLength(1)
})
it('registers the chip, executes /plan off, and unregisters on teardown', async () => {

View File

@@ -36,13 +36,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Dictionary namespace owned by this plugin. */
const NS = 'question'
/**
* Required services (cordis fiber inject). 'conversation' is an ordering
* edge, not a call dependency: the 'conversation.composer' chain slot is
* declared by ui-conversation's apply, and register() into an undeclared
* slot throws — service waiting orders this apply after the declaring one.
*/
export const inject = ['slots', 'conversation', 'locale']
/** Required services: the slot registry and the question composer's copy. */
export const inject = ['slots', 'locale']
/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
@@ -58,11 +53,8 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-question: dictionaries')
ctx.effect(
() => ctx.slots.register(
{ name: 'conversation.composer', select: selectQuestion, locale: NS },
QuestionComposer,
),
'ui-question: composer chain registration',
)
ctx.slots.inject('conversation.composer', () => ctx.slots.register(
{ name: 'conversation.composer', select: selectQuestion, locale: NS },
QuestionComposer,
))
}

View File

@@ -2,7 +2,7 @@
* apply wiring on a real cordis Context + SlotsService: QuestionComposer
* registered as the `question` entry of the conversation-declared composer
* slot with ZERO business face (data and verbs ride the dispatched carrier),
* load-order fail-loud, and fiber-teardown unregistration. Component and
* declaration-aware activation, and fiber-teardown unregistration. Component and
* domain-face behavior is covered props-direct in question-composer.spec.tsx;
* no renderer machinery here.
*/
@@ -22,27 +22,28 @@ async function bench() {
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
() => null,
)
// 'conversation' inject is an ordering edge (the declaring plugin provides
// it after declaring the chain); the bench declares the chain itself.
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
return { ctx, slots }
}
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slots', 'conversation', 'locale'])
expect(inject).toEqual(['slots', 'locale'])
})
it('fails loud when no live entry has declared the composer slot', async () => {
it('waits until a live entry declares the composer slot', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
// Satisfy the ordering inject without declaring the chain: apply must
// then hit the undeclared-slot throw, not sit waiting on the service.
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
await expect(ctx.plugin({ inject: [...inject], apply }))
.rejects.toThrow(/slot "conversation.composer" is not declared/)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(ctx.slots.entries('conversation.composer')).toHaveLength(0)
ctx.slots.register(
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
() => null,
)
await Promise.resolve()
expect(ctx.slots.entries('conversation.composer')).toHaveLength(1)
})
it('registers the question entry: routing selector, no inject face', async () => {

View File

@@ -6,7 +6,6 @@
* Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item).
@@ -50,7 +49,7 @@ const NS = 'settings'
/**
* Required services (cordis fiber inject). The target slots are declared by
* ui-settings' apply, whose activation order relative to this one is NOT
* constrained; registration goes through declaration-aware deferral.
* constrained; registrations depend on their slots through `slots.inject()`.
*/
export const inject = ['slots', 'locale', 'connection']
@@ -97,47 +96,34 @@ export function apply(ctx: ClientContext): void {
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-settings-general: metadata invalidations')
ctx.effect(() => {
const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () =>
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
const header = deferRegistration(ctx.slots, 'settings.header', HeaderContent, () =>
ctx.slots.register({ name: 'settings.header', locale: NS }, HeaderContent))
const action = documentInjected === undefined
? undefined
: deferRegistration(ctx.slots, 'settings.action', SettingsDocumentAction, () =>
ctx.slots.register({
name: 'settings.action',
id: 'open-document',
order: 0,
locale: NS,
inject: documentInjected,
}, SettingsDocumentAction))
const close = deferRegistration(ctx.slots, 'settings.close', CloseLabel, () =>
ctx.slots.register({ name: 'settings.close', locale: NS }, CloseLabel))
const general = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () =>
ctx.slots.register({
name: 'settings.section',
id: 'general',
order: 0,
label: () => t('general.nav'),
locale: NS,
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
}, GeneralSection))
const welcome = deferRegistration(ctx.slots, 'settings.onboarding', WelcomeNotice, () =>
ctx.slots.register({
name: 'settings.onboarding',
id: 'welcome-notice',
order: -100,
locale: NS,
inject: welcomeInjected,
}, WelcomeNotice))
return () => {
trigger.dispose()
header.dispose()
action?.dispose()
close.dispose()
general.dispose()
welcome.dispose()
}
}, 'ui-settings-general: chrome, action, section, and onboarding registrations')
ctx.slots.inject('settings.trigger', () =>
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
ctx.slots.inject('settings.header', () =>
ctx.slots.register({ name: 'settings.header', locale: NS }, HeaderContent))
if (documentInjected !== undefined) {
ctx.slots.inject('settings.action', () => ctx.slots.register({
name: 'settings.action',
id: 'open-document',
order: 0,
locale: NS,
inject: documentInjected,
}, SettingsDocumentAction))
}
ctx.slots.inject('settings.close', () =>
ctx.slots.register({ name: 'settings.close', locale: NS }, CloseLabel))
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'general',
order: 0,
label: () => t('general.nav'),
locale: NS,
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
}, GeneralSection))
ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
name: 'settings.onboarding',
id: 'welcome-notice',
order: -100,
locale: NS,
inject: welcomeInjected,
}, WelcomeNotice))
}

View File

@@ -12,7 +12,7 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// read (nav labels may be locale-following thunks; the shell still ships no
// copy of its own and takes no hard locale dependency).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { deferRegistration, resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import type {
SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow,
} from './contract/slots.ts'
@@ -27,8 +27,8 @@ export type {
/**
* Required services (cordis fiber inject). The target slot is declared by
* ui-sidebar's apply, whose activation order relative to this one is NOT
* constrained (dshClient.inject edges are informational); registration goes
* through declaration-aware deferral.
* constrained (dshClient.inject edges are informational); registration
* depends on the slot through `slots.inject()`.
*/
export const inject = ['slots']
@@ -96,20 +96,16 @@ export function apply(ctx: ClientContext): void {
},
},
})
ctx.effect(() => {
const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () =>
ctx.slots.register({
name: 'sidebar.settings',
children: {
'settings.trigger': { kind: 'single', scope: 'root' },
'settings.header': { kind: 'single', scope: 'root' },
'settings.action': { kind: 'list', scope: 'root' },
'settings.close': { kind: 'single', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
'settings.onboarding': { kind: 'list', scope: 'root' },
},
inject: injected,
}, SettingsRoot))
return () => { deferred.dispose() }
}, 'ui-settings: shell registration')
ctx.slots.inject('sidebar.settings', () => ctx.slots.register({
name: 'sidebar.settings',
children: {
'settings.trigger': { kind: 'single', scope: 'root' },
'settings.header': { kind: 'single', scope: 'root' },
'settings.action': { kind: 'list', scope: 'root' },
'settings.close': { kind: 'single', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
'settings.onboarding': { kind: 'list', scope: 'root' },
},
inject: injected,
}, SettingsRoot))
}

View File

@@ -1,4 +1,4 @@
/** Settings shell registration: declaration-aware deferral, the ledger projections, and HMR recovery. */
/** Settings shell registration: slot declaration injection, the ledger projections, and HMR recovery. */
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'

View File

@@ -55,14 +55,10 @@ export const inject = ['sessions', 'locale']
export function apply(ctx: ClientContext): void {
ctx.plugin(SlashService)
ctx.effect(() => ctx.locale.register(MENU_NS, { zh, en }), 'ui-slash: menu dictionaries')
// Conditional mount: 'conversation.input.overlay' is declared by the
// conversation composer entry, and the conversation service is mounted
// after that declaration lands on the ledger — its presence is the
// registration-safe signal (same seam as toolview registrants).
ctx.inject(['slots', 'conversation', 'slash', 'sessions'], (scope: ClientContext) => {
ctx.inject(['slots', 'slash', 'sessions'], (scope: ClientContext) => {
const slash = scope.slash
const sessions = scope.sessions
scope.effect(() => scope.slots.register({
scope.slots.inject('conversation.input.overlay', () => scope.slots.register({
name: 'conversation.input.overlay',
id: 'slash-menu',
order: 0,
@@ -79,6 +75,6 @@ export function apply(ctx: ClientContext): void {
onDismiss: () => { controller.dismiss() },
}
},
}, MenuView), 'ui-slash: MenuView overlay registration')
}, MenuView))
})
}

View File

@@ -1,12 +1,11 @@
/**
* apply wiring on a real cordis Context + SlotsService: SlashService mounts
* as ctx.slash once its sessions dependency is up; the MenuView overlay
* registration waits on the conversation seam (ctx.inject scope), lands once
* the declarer is up, resolves the per-session controller from the slot's
* registration follows the slot declaration, resolves the per-session controller from the slot's
* sessionId, and unregisters on fiber teardown.
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
@@ -25,8 +24,8 @@ async function bench() {
await ctx.plugin(SlotsService).await()
const slots = ctx.get('slots') as SlotsService
// Stand-in for the ui-conversation composer entry: declare the overlay
// slot, then provide the conversation service (declaration precedes the
// service exactly as the real apply orders them).
// slot without providing ConversationService, which is not its lifecycle
// signal.
slots.register(
{ name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } } } as never,
() => null,
@@ -67,11 +66,7 @@ describe('apply', () => {
it('registers MenuView into the overlay and resolves the per-session controller by slot sessionId', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
ctx.provide('conversation', {})
// The inject scope activates asynchronously on the service arrival.
await vi.waitFor(() => { expect(slots.entries('conversation.input.overlay')).toHaveLength(1) })
expect(slots.entries('conversation.input.overlay')).toHaveLength(1)
const entries = slots.entries('conversation.input.overlay')
expect(entries[0]!.options.id).toBe('slash-menu')
// Copy rides the standard locale seat, not the business face.
@@ -100,8 +95,7 @@ describe('apply', () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
ctx.provide('conversation', {})
await vi.waitFor(() => { expect(slots.entries('conversation.input.overlay')).toHaveLength(1) })
expect(slots.entries('conversation.input.overlay')).toHaveLength(1)
await fiber.dispose()
expect(slots.entries('conversation.input.overlay')).toHaveLength(0)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-slots/README.md
README.md: ed6f052b3a47e08d693928b6763e32427b829467
README.zh.md: e12e4bdad738c70657927b62d4a7bbd46d4b1519
README.md: bb489dea0c3848cf3d501dcf095a65fe1cef9ef6
README.zh.md: b4a2915b9d85c45ef7c6dccf27761794e21aa65f

View File

@@ -19,7 +19,7 @@ The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are
The store family (`defineStore` spec in / `StoreHandle<T, A>` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in the runtime package (the engine's home) and satisfies the `DefineStore` contract exported here. Engine products and the renderer host contract carry bare snapshot sources (`getSnapshot`/`subscribe`), never React hooks — hook binding is the render machinery's side of the seam; only the props-contract hook type (`SnapshotSelectorHook`) lives here.
`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes, a chain registration without `select` — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot.
`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes, a chain registration without `select` — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. Each key also carries a declaration epoch that advances only on declaration and collapse; the runtime uses it for [`ctx.slots.inject`](../runtime/README.md#slot-declaration-injection), independently from ordinary entry versions. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot.
## Model Experience

View File

@@ -19,7 +19,7 @@ chain-kind slot 会反转键控路由:条目自行提名,而不是由分发
store 家族(输入 `defineStore` 规范/输出 `StoreHandle<T, A>`)为 store seat 建模:`init` 推断状态 schema`actions` 是完整的 draft-transform 写入集合;`BakedActions` 移除 draft 参数,成为组件和 inject factory 收到的回调。`defineStore` 值实现位于 runtime 包(引擎所属位置),并满足这里导出的 `DefineStore` 契约。引擎产物与 renderer host 契约携带裸快照 source`getSnapshot``subscribe`),绝不携带 React hookhook 绑定属于渲染机制这一侧的 seam只有 props 契约 hook 类型(`SnapshotSelectorHook`)位于这里。
`SlotCore` 在构造时预置 `'root'` slot并强制执行加载时验证注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。条目的 disposer 会递归移除其声明的子 slot账本行、贡献和 store 挂载都会随同一生命周期结束而移除。`renderer.ts` 携带安装 seam`SlotRenderer``SlotRendererHost`)以及 `StaleAuthorizationError``SlotOwnershipError`;实现在 web-react 中,安装则在外壳启动中完成。
`SlotCore` 在构造时预置 `'root'` slot并强制执行加载时验证注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。条目的 disposer 会递归移除其声明的子 slot账本行、贡献和 store 挂载都会随同一生命周期结束而移除。每个 key 还携带一个 declaration epoch声明代次它只在声明与折叠时递增运行时将其用于 [`ctx.slots.inject`](../runtime/README.md#slot-declaration-injection),且与普通条目版本相互独立。`renderer.ts` 携带安装 seam`SlotRenderer``SlotRendererHost`)以及 `StaleAuthorizationError``SlotOwnershipError`;实现在 web-react 中,安装则在外壳启动中完成。
## 模型体验

View File

@@ -1,128 +0,0 @@
/**
* Declaration-aware registration deferral: the shared timing machinery for
* registering into a slot whose declaring entry activates in unconstrained
* order (dshClient.inject edges never sequence apply). Presence is judged on
* the LEDGER, not a local flag — after an HMR collapse re-declares the slot,
* the cascade has already removed the entry while the local disposer went
* stale, and a flag guard would block the re-registration.
*/
/** Minimal registry face the deferral reads (SlotsService satisfies it). */
export interface DeferralRegistry {
/** Declared spec lookup (undefined = not declared yet). */
spec(name: string): unknown
/** Current entries of the slot (component identity is the presence judge). */
entries(name: string): readonly { component: unknown }[]
/** Subscribe to the slot's ledger changes; returns the unsubscriber. */
subscribe(name: string, listener: () => void): () => void
}
/** Handle over one deferred registration. */
export interface DeferredRegistration {
/**
* Drop the current registration (stale disposers are harmless no-ops) and
* immediately re-attempt — the refresh path for registrants whose options
* carry localized text.
*/
refresh(): void
/** Unsubscribe and unregister (idempotent through the slot core). */
dispose(): void
}
/**
* Register into `name` as soon as its declaration is on the ledger, and
* re-register whenever the declaration reappears after a collapse.
* @param registry - the slot registry face.
* @param name - target slot name.
* @param component - the component whose ledger presence marks "registered".
* @param register - performs the actual registration; returns its disposer.
* @param onFailure - owns a registration failure that fires from a LATER
* ledger flush (a declaration landing after two providers deferred, say):
* the deferral first removes its own subscription, then hands the error
* over instead of throwing through the flush — the callback's chance to
* roll back sibling deferrals and surface the conflict on a loud channel.
* Absent, a late failure rethrows out of the flush.
* @returns the deferral handle (dispose in the owning effect's disposer).
* @throws the immediate registration's failure, after removing the
* just-installed subscription — a throwing construction leaves nothing live.
*/
export function deferRegistration(
registry: DeferralRegistry,
name: string,
component: unknown,
register: () => () => void,
onFailure?: (error: unknown) => void,
): DeferredRegistration {
let dispose: (() => void) | undefined
const tryRegister = (): void => {
if (registry.spec(name) === undefined) return
if (registry.entries(name).some(e => e.component === component)) return
dispose = register()
}
const unsubscribe = registry.subscribe(name, () => {
try {
tryRegister()
} catch (error) {
unsubscribe()
if (onFailure === undefined) throw error
onFailure(error)
}
})
try {
tryRegister()
} catch (error) {
// A synchronous registration failure (the declared slot is already
// occupied) must not leave the just-installed subscription behind: the
// caller receives no handle to dispose it through.
unsubscribe()
throw error
}
return {
refresh() {
dispose?.()
dispose = undefined
tryRegister()
},
dispose() {
unsubscribe()
dispose?.()
},
}
}
/**
* Defer ONE occupant into several holes as a unit. Construction that throws
* partway (a declared hole already occupied registers synchronously) rolls
* every earlier deferral back before rethrowing; a failure surfacing from a
* LATER ledger flush (holes declared after rival providers activated) rolls
* the whole group back the same way and re-raises the wrapped error on the
* global channel the boot's fail-loud handler owns — never a throw through
* the slot flush, never partial occupancy from the group's owner.
* @param registry - the slot registry face.
* @param names - the target holes (one registration per name).
* @param component - the occupant whose ledger presence marks "registered".
* @param register - performs one hole's registration; returns its disposer.
* @returns the group handle (dispose in the owning effect's disposer).
* @throws the immediate registration's failure, after rolling the group back.
*/
export function deferGroupRegistration<K extends string>(
registry: DeferralRegistry,
names: readonly K[],
component: unknown,
register: (name: K) => () => void,
): { dispose: () => void } {
const deferred: DeferredRegistration[] = []
const lateFailure = (error: unknown): void => {
for (const entry of deferred) entry.dispose()
queueMicrotask(() => { throw error instanceof Error ? error : new Error(String(error)) })
}
try {
for (const name of names) {
deferred.push(deferRegistration(registry, name, component, () => register(name), lateFailure))
}
} catch (error) {
for (const entry of deferred) entry.dispose()
throw error
}
return { dispose: () => { for (const entry of deferred) entry.dispose() } }
}

View File

@@ -19,7 +19,6 @@ import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDec
export * from './store.ts'
export * from './renderer.ts'
export * from './deferred.ts'
/** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */
export interface SlotMap {}
@@ -457,9 +456,12 @@ interface SlotRecord {
spec: SlotSpec<SlotEntryDef> | undefined
/** Diagnostics: which slot's entry declared this key ('(built-in)' for root). */
declaredBy: string | undefined
/** Monotonic declaration lifetime, distinct from ordinary entry mutations. */
declarationEpoch: number
entries: readonly StoredEntry[]
version: number
listeners: Set<() => void>
declarationListeners: Set<() => void>
}
const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
@@ -473,8 +475,10 @@ const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
*
* Change propagation contract: versions bump and {@link SlotCore.onMutate}
* fires synchronously per mutation (registry state is consistent when they
* fire); {@link SlotCore.subscribe} notifications batch per microtask, so N
* same-tick mutations produce one notification per touched key.
* fire); {@link SlotCore.subscribeDeclaration} fires synchronously for each
* declaration lifetime boundary; {@link SlotCore.subscribe} notifications
* batch per microtask, so N same-tick mutations produce one notification per
* touched key.
*/
export class SlotCore {
private records = new Map<string, SlotRecord>()
@@ -491,6 +495,7 @@ export class SlotCore {
const root = this.record('root')
root.spec = { kind: 'single', scope: 'root' }
root.declaredBy = '(built-in)'
root.declarationEpoch = 1
}
/**
@@ -631,12 +636,22 @@ export class SlotCore {
rec.entries = next
this.markDirty(options.name, rec)
if (options.children) {
const declarations: [key: string, record: SlotRecord][] = []
for (const [childKey, childSpec] of Object.entries(options.children)) {
const childRec = this.record(childKey)
childRec.spec = childSpec
childRec.declaredBy = `an entry in "${options.name}"${options.registrant ? ` (${options.registrant})` : ''}`
childRec.declarationEpoch += 1
declarations.push([childKey, childRec])
}
// Synchronous listeners may register into or try to redeclare a sibling;
// publish only after the whole children table owns its declarations.
for (const [childKey, childRec] of declarations) {
this.markDirty(childKey, childRec)
}
for (const [, childRec] of declarations) {
this.notifyDeclaration(childRec)
}
}
return () => {
if (!rec.entries.includes(entry)) return
@@ -692,6 +707,16 @@ export class SlotCore {
return this.records.get(key)?.spec
}
/**
* Read the declaration lifetime of a key. Entry additions and removals do
* not change it; declaration creation and collapse each advance it.
* @param key - slot key.
* @returns monotonic epoch (0 before the first declaration).
*/
declarationEpoch(key: string): number {
return this.records.get(key)?.declarationEpoch ?? 0
}
/**
* Subscribe to registration changes for a key (microtask-batched).
* Subscribing ahead of declaration is allowed; the declaration notifies.
@@ -705,6 +730,22 @@ export class SlotCore {
return () => { rec.listeners.delete(fn) }
}
/**
* Subscribe to declaration lifetime boundaries for a key. Notifications
* are synchronous so declaration teardown finishes before a subsequent
* same-tick registration can observe stale resources. Ordinary entry
* mutations do not notify this surface. A children table commits every
* sibling declaration before its first notification.
* @param key - slot key.
* @param fn - declaration or collapse callback.
* @returns unsubscribe.
*/
subscribeDeclaration(key: string, fn: () => void): () => void {
const rec = this.record(key)
rec.declarationListeners.add(fn)
return () => { rec.declarationListeners.delete(fn) }
}
/**
* Monotonic version for a key, bumped synchronously per mutation so a
* uSES getSnapshot read is never stale when its batched notification lands.
@@ -746,8 +787,10 @@ export class SlotCore {
const doomed = childRec.entries
childRec.spec = undefined
childRec.declaredBy = undefined
childRec.declarationEpoch += 1
childRec.entries = NO_ENTRIES
this.markDirty(childKey, childRec)
this.notifyDeclaration(childRec)
for (const dead of doomed) this.releaseEntry(dead)
}
}
@@ -755,7 +798,15 @@ export class SlotCore {
private record(key: string): SlotRecord {
let rec = this.records.get(key)
if (!rec) {
rec = { spec: undefined, declaredBy: undefined, entries: NO_ENTRIES, version: 0, listeners: new Set() }
rec = {
spec: undefined,
declaredBy: undefined,
declarationEpoch: 0,
entries: NO_ENTRIES,
version: 0,
listeners: new Set(),
declarationListeners: new Set(),
}
this.records.set(key, rec)
}
return rec
@@ -771,6 +822,10 @@ export class SlotCore {
}
}
private notifyDeclaration(rec: SlotRecord): void {
for (const fn of [...rec.declarationListeners]) fn()
}
private flush(): void {
// Reset before iterating so a mutation from inside a listener re-schedules.
this.flushScheduled = false

View File

@@ -231,6 +231,22 @@ describe('store scope pinning', () => {
})
describe('subscription surface', () => {
it('tracks declaration epochs separately from ordinary entry mutations', () => {
const core = new SlotCore()
expect(core.declarationEpoch('root')).toBe(1)
expect(core.declarationEpoch('test.list')).toBe(0)
const disposeFrame = mountFrame(core)
const declared = core.declarationEpoch('test.list')
expect(declared).toBe(1)
const disposeEntry = core.register({ name: 'test.list', id: 'a' }, Comp)
disposeEntry()
expect(core.declarationEpoch('test.list')).toBe(declared)
disposeFrame()
expect(core.declarationEpoch('test.list')).toBe(declared + 1)
mountFrame(core)
expect(core.declarationEpoch('test.list')).toBe(declared + 2)
})
it('entries() returns a stable cached reference between mutations', () => {
const core = new SlotCore()
mountFrame(core)
@@ -267,6 +283,44 @@ describe('subscription surface', () => {
expect(fn).toHaveBeenCalledTimes(1)
})
it('notifies declaration subscribers synchronously, excluding entries, until unsubscribe', () => {
const core = new SlotCore()
const fn = vi.fn()
const unsubscribe = core.subscribeDeclaration('test.list', fn)
const disposeFrame = mountFrame(core)
expect(fn).toHaveBeenCalledTimes(1)
core.register({ name: 'test.list', id: 'ordinary' }, Comp)
expect(fn).toHaveBeenCalledTimes(1)
disposeFrame()
expect(fn).toHaveBeenCalledTimes(2)
unsubscribe()
mountFrame(core)
expect(fn).toHaveBeenCalledTimes(2)
})
it('commits sibling declarations before notifying declaration subscribers', () => {
const core = new SlotCore()
let duplicateDeclaration: unknown
const unsubscribe = core.subscribeDeclaration('test.single', () => {
core.register({ name: 'test.list', id: 'from-listener' }, Comp)
try {
core.register({
name: 'test.single',
children: { 'test.list': { kind: 'list', scope: 'root' } },
}, Comp as never)
} catch (error) {
duplicateDeclaration = error
}
})
const disposeFrame = mountFrame(core)
expect(core.entries('test.list')).toHaveLength(1)
expect(String(duplicateDeclaration)).toContain('already declared')
unsubscribe()
disposeFrame()
expect(core.specDynamic('test.list')).toBeUndefined()
})
it('notifies only subscribers of the touched key; unsubscribe stops delivery', async () => {
const core = new SlotCore()
mountFrame(core)

View File

@@ -1,126 +0,0 @@
// deferRegistration lifecycle: declaration-aware registration, HMR
// re-registration, and — the failure contract — no subscription survives a
// construction that throws synchronously (an already-occupied single slot).
import { describe, expect, it, vi } from 'vitest'
import { deferGroupRegistration, deferRegistration, SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
// Shares the merges declared by core.spec.ts (same program); reuse its keys.
const HOLE = 'test.single' as const
function declared(): SlotCore {
const core = new SlotCore()
core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never)
return core
}
describe('deferRegistration', () => {
it('registers immediately under an existing declaration and disposes cleanly', () => {
const core = declared()
const component = (): null => null
const handle = deferRegistration(core, HOLE, component, () =>
core.register({ name: HOLE } as never, component as never))
expect(core.entries(HOLE)).toHaveLength(1)
handle.dispose()
expect(core.entries(HOLE)).toHaveLength(0)
})
it('hands a late registration failure to onFailure after unsubscribing itself', async () => {
const core = new SlotCore()
const component = (): null => null
const foreign = (): null => null
const failures: unknown[] = []
// Nothing is declared yet: the deferral just subscribes and waits.
const register = vi.fn(() => core.register({ name: HOLE } as never, component as never))
deferRegistration(core, HOLE, component, register, (error) => { failures.push(error) })
// The declaration lands with a foreign occupant racing in first: the
// deferral's flush-time attempt fails, unsubscribes itself, and reports
// through onFailure instead of throwing out of the flush.
core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never)
const disposeForeign = core.register({ name: HOLE } as never, foreign as never)
await Promise.resolve()
expect(failures.map(String).join('')).toContain('already has a registration')
// Unsubscribed: freeing the hole must not resurrect the loser.
disposeForeign()
await Promise.resolve()
expect(core.entries(HOLE)).toHaveLength(0)
})
it('drops its subscription when the immediate registration throws', async () => {
const core = declared()
const foreign = (): null => null
const disposeForeign = core.register({ name: HOLE } as never, foreign as never)
const component = (): null => null
const register = vi.fn(() => core.register({ name: HOLE } as never, component as never))
// The single hole is occupied: the immediate attempt throws out of the
// constructor, and the caller never receives a handle to dispose.
expect(() => deferRegistration(core, HOLE, component, register)).toThrow(/already has a registration/)
expect(register).toHaveBeenCalledOnce()
// The subscription rolled back with it: freeing the hole flushes a
// notification that must not resurrect the failed registration.
disposeForeign()
await Promise.resolve()
expect(register).toHaveBeenCalledOnce()
expect(core.entries(HOLE)).toHaveLength(0)
})
})
describe('deferGroupRegistration', () => {
const HOLES = ['test.single', 'test.grandchild'] as const
function declaredPair(): SlotCore {
const core = new SlotCore()
core.register({
name: 'root',
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
} as never, (() => null) as never)
return core
}
it('registers the whole group and disposes it as a unit', () => {
const core = declaredPair()
const component = (): null => null
const group = deferGroupRegistration(core, HOLES, component, name =>
core.register({ name } as never, component as never))
for (const name of HOLES) expect(core.entries(name)).toHaveLength(1)
group.dispose()
for (const name of HOLES) expect(core.entries(name)).toHaveLength(0)
})
it('rolls the group back when construction fails partway', () => {
const core = declaredPair()
const component = (): null => null
core.register({ name: HOLES[1] } as never, (() => null) as never)
expect(() => deferGroupRegistration(core, HOLES, component, name =>
core.register({ name } as never, component as never))).toThrow(/already has a registration/)
// The first hole's registration and subscription rolled back with it.
expect(core.entries(HOLES[0])).toHaveLength(0)
})
it('rolls the group back and re-raises loudly on a late conflict', async () => {
const core = new SlotCore()
const component = (): null => null
const failures: unknown[] = []
const onLoud = (reason: unknown): void => { failures.push(reason) }
process.on('uncaughtException', onLoud)
try {
const group = deferGroupRegistration(core, HOLES, component, name =>
core.register({ name } as never, component as never))
// Declaration lands with a rival racing in ahead of the flush.
core.register({
name: 'root',
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
} as never, (() => null) as never)
core.register({ name: HOLES[0] } as never, (() => null) as never)
core.register({ name: HOLES[1] } as never, (() => null) as never)
await new Promise(resolve => setTimeout(resolve, 20))
expect(failures.map(String).join('')).toContain('already has a registration')
// No partial occupancy from the group's owner survives.
for (const name of HOLES) {
expect(core.entries(name).filter(entry => entry.component === component)).toHaveLength(0)
}
group.dispose()
} finally {
process.off('uncaughtException', onLoud)
}
})
})

View File

@@ -36,7 +36,7 @@ export type {
} from './SubagentReadOnlyComposer.tsx'
/** Required services for references, conversation slots, and session navigation. */
export const inject = ['slash', 'sessions', 'conversation', 'slots', 'locale']
export const inject = ['slash', 'sessions', 'slots', 'locale']
/** Claim the composer for one-shot history or an unavailable continuation owner. */
function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null {
@@ -103,7 +103,8 @@ export function apply(ctx: ClientContext): void {
sessions.setSubagentCatalogOpen(parentSessionId, open)
},
})
ctx.effect(
ctx.slots.inject(
'conversation.session.header.actions',
() => ctx.slots.register({
name: 'conversation.session.header.actions',
id: 'subagent-catalog',
@@ -111,15 +112,14 @@ export function apply(ctx: ClientContext): void {
locale: NS,
inject: catalogActions,
}, SubagentCatalogAction),
'ui-subagent: lazy descendant catalog action',
)
ctx.effect(
ctx.slots.inject(
'conversation.composer',
() => ctx.slots.register({
name: 'conversation.composer',
priority: -10,
locale: NS,
select: selectReadOnlySubagent,
}, SubagentReadOnlyComposer),
'ui-subagent: read-only addressed composer',
)
}

View File

@@ -75,7 +75,6 @@ async function provideSlotFaces(ctx: Context): Promise<void> {
'conversation.composer': { kind: 'chain', scope: 'session' },
},
} as never, () => null)
ctx.provide('conversation', {})
}
/** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */
@@ -113,7 +112,7 @@ const req = (query: string) =>
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots', 'locale'])
expect(inject).toEqual(['slash', 'sessions', 'slots', 'locale'])
})
it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => {

View File

@@ -7,7 +7,7 @@
* section — the theme feature owns its own settings surface.
*/
import type { Context } from 'cordis'
import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -254,16 +254,12 @@ export function apply(ctx: ClientContext): void {
setTheme: (id) => { theme.setTheme(id) },
}
}
ctx.effect(() => {
const deferred = deferRegistration(ctx.slots, 'settings.general.item', AppearanceRow, () =>
ctx.slots.register({
name: 'settings.general.item',
id: 'appearance',
order: 10,
store,
locale: SETTINGS_NS,
inject: injected,
}, AppearanceRow))
return () => { deferred.dispose() }
}, 'ui-theme: appearance settings row registration')
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',
id: 'appearance',
order: 10,
store,
locale: SETTINGS_NS,
inject: injected,
}, AppearanceRow))
}

View File

@@ -10,14 +10,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createTrajectoryDurationStore } from './duration-store.ts'
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
/**
* Required services (cordis fiber inject). 'conversation' is an ordering
* edge, not a call dependency: the 'conversation.view' slot is declared by
* ui-conversation's apply (which then provides the service), and register()
* into an undeclared slot throws — service waiting is what orders this
* apply after the declaring one.
*/
export const inject = ['slots', 'conversation', 'sessionHistory']
/** Required services: the conversation view slot and independent history source. */
export const inject = ['slots', 'sessionHistory']
/**
* Client plugin body: register the trajectory view tab. The registration
@@ -26,7 +20,7 @@ export const inject = ['slots', 'conversation', 'sessionHistory']
*/
export function apply(ctx: Context): void {
const duration = createTrajectoryDurationStore()
ctx.slots.register({
ctx.slots.inject('conversation.view', () => ctx.slots.register({
name: 'conversation.view',
id: 'trajectory',
order: 10,
@@ -40,5 +34,5 @@ export function apply(ctx: Context): void {
setActualDuration: (value) => { duration.set(value) },
}
},
}, TrajectoryView)
}, TrajectoryView))
}

View File

@@ -61,7 +61,7 @@ describe('tsdown client artifact', () => {
const { handoff, surface } = await loadArtifact()
expect(handoff.id).toBe(PLUGIN_ID)
expect(surface.apply).toBeTypeOf('function')
expect(surface.inject).toEqual(['slots', 'conversation', 'sessionHistory'])
expect(surface.inject).toEqual(['slots', 'sessionHistory'])
})
it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
@@ -73,10 +73,8 @@ describe('tsdown client artifact', () => {
name: 'root',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
}, (_p: { renderSlot?: unknown }) => null)
// The plugin injects 'conversation' as an ordering edge and
// 'sessionHistory' for its per-session history source; this bench
// supplies both.
ctx.provide('conversation', {})
// The plugin reads sessionHistory for its per-session history source;
// slot availability is tracked by slots.inject.
ctx.provide('sessionHistory', {})
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
await fiber.await()

View File

@@ -168,9 +168,6 @@ async function bench(snapshot = historySnapshot(NODES)) {
const chatBody = vi.fn(() => <div data-testid="chat-body" />)
slots.register(
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
// 'conversation' inject is an ordering edge; the bench declares the ring
// itself, so a stub satisfies the wait.
ctx.provide('conversation', {})
ctx.provide('sessionHistory', { source: () => history })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
README.md: 3107793233d18c65b12a9a91a5be85d22acc733a
README.zh.md: 98912ac079a13fa62d5829d7d2469ff63c3da5bb
README.md: bd7313b560e76378e4fff274c99bb976819aebae
README.zh.md: 734a897b9cb9c3469d8f402b13bff4b62753f9b2

View File

@@ -14,7 +14,7 @@ The Session row's Fork action forks at the source's last completed turn, increme
Session rows render the runtime's live `pendingInteraction` classification: approvals report **Waiting for approval**, plan reviews report **Plan awaiting review**, and ordinary questions report **Waiting for answer**. Every pending interaction uses an amber warning dot that takes precedence over the running indicator; ordinary rows repeat the localized status in their hover card, and both ordinary and search-result rows carry the same text as a visually hidden label for assistive technology. Running uses the blue indicator and its hidden label; an idle row leaves the reserved status slot empty.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
Both target slots are declared by other plugins, so `apply` uses `slots.inject()` to register for each declaration lifetime and re-register after a declaring slot is restored.
The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Ordinary forks remain visible because lineage alone does not set that origin. The runtime keeps hidden rows available for conversation, title, and addressed transport state.

View File

@@ -14,7 +14,7 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork
Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**等待审批**,计划审阅显示**计划待审**,普通问题显示**等待回答**。每个待处理交互都使用一枚琥珀色警告点,优先级高于运行指示器;普通行的悬浮卡片重复显示本地化状态,普通行和搜索结果行则都以相同文本提供面向辅助技术的视觉隐藏标签。运行状态使用蓝色指示器及其隐藏标签;空闲行会保留空的状态槽位。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
两个目标 slot 都由其他插件声明,因此 `apply` 使用 `slots.inject()` 在各自的声明生命周期内完成注册,并在目标 slot 的声明恢复后重新注册。
共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。普通 fork 仍然可见,因为仅有谱系不会设置该 origin。运行时仍保留隐藏行供对话、标题与已寻址传输状态使用。

View File

@@ -8,7 +8,6 @@
* client half (see the contract module doc). Export discipline:
* packages/client/AGENTS.md.
*/
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
@@ -40,8 +39,8 @@ const NS = 'workspace'
* the ui-sidebar / ui-conversation applies, whose activation order relative
* to this one is NOT constrained: dshClient.inject edges are informational
* (loading/prefetch metadata, never apply sequencing) and neither owner
* provides a waitable service. apply therefore registers via
* declaration-aware deferral instead of assuming order.
* provides a waitable service. apply therefore depends on each slot
* declaration through `slots.inject()` instead of assuming order.
*/
export const inject = ['slots', 'sessions', 'workspaces', 'locale']
@@ -103,36 +102,25 @@ export function apply(ctx: ClientContext): void {
createWorkspace: input => ctx.workspaces.create(input),
hooks: { directoryFlow: pickerFlowSource },
})
// Declaration-aware registration (deferRegistration): each owner's
// declaring apply may activate after this one, and a register into an
// undeclared slot throws; the deferral also re-registers after an HMR
// collapse re-declares the slot. Each registration declares its own
// directory-flow child hole in the same call (declaration = render
// authorization, one table).
ctx.effect(() => {
const deferred = [
deferRegistration(ctx.slots, 'sidebar.workspaces', WorkspaceBrowser, () =>
ctx.slots.register(
{
name: 'sidebar.workspaces',
children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } },
store: createWorkspaceViewStore(),
inject: browserInjected,
locale: NS,
},
WorkspaceBrowser,
)),
deferRegistration(ctx.slots, 'conversation.hero.workspace', WorkspacePicker, () =>
ctx.slots.register(
{
name: 'conversation.hero.workspace',
children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } },
inject: pickerInjected,
locale: NS,
},
WorkspacePicker,
)),
]
return () => { for (const entry of deferred) entry.dispose() }
}, 'ui-workspace: browser + picker registrations')
// Each registration declares its directory-flow child in the same call;
// slot injection follows both the owner and declaration HMR lifetimes.
ctx.slots.inject('sidebar.workspaces', () => ctx.slots.register(
{
name: 'sidebar.workspaces',
children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } },
store: createWorkspaceViewStore(),
inject: browserInjected,
locale: NS,
},
WorkspaceBrowser,
))
ctx.slots.inject('conversation.hero.workspace', () => ctx.slots.register(
{
name: 'conversation.hero.workspace',
children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } },
inject: pickerInjected,
locale: NS,
},
WorkspacePicker,
))
}